diff --git a/python_payload/apps/violin/__init__.py b/python_payload/apps/violin/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..841286344a3869a67717726e10cf8b4129cdb86d --- /dev/null +++ b/python_payload/apps/violin/__init__.py @@ -0,0 +1,606 @@ +import captouch +import bl00mbox +import leds + +import cmath +import math + +from st3m.application import Application +from st3m.ui import led_patterns, widgets + +# i stole a waveform from wikipedia and all i got was this lousy waveform +# fmt: off +wavetable_G = [ -31908, -31214, -29327, -25617, -19835, -13173, -5860, 430, 6178, 9928, 10517, 8145, 5175, 5247, 9082, + 14845, 21967, 27964, 31532, 32686, 31010, 27298, 23469, 19145, 16123, 12363, 7738, 4198, 1774, 470, + -1782, -5034, -8392, -9682, -7834, -4587, 384, 5137, 8867, 9100, 5054, -1239, -7294, -11015, -11725, + -11018, -10495, -10393, -11481, -13339, -15281, -17068, -18206, -18616, -18261, -17259, -16580, -14969, + -13589, -11777, -9421, -6406, -3500, -622, 190, 275, 1082, 3731, 6917, 10332, 13113, 15365, 15551, 13645, + 9494, 4473, 822, -571, -225, 3121, 6186, 8317, 8741, 8079, 5348, 2070, -961, -2887, -3492, -1762, 154, + 2427, 3639, 3447, 1661, 836, 1407, 2701, 4776, 5851, 6284, 5217, 3111, 1050, -478, -2179, -3504, -5057, + -7776, -10438, -9483, -2039, 7867, 14062, 14379, 11906, 9318, 7723, 6459, 5114, 5220, 5800, 5678, 2184, + -5499, -14483, -22161, -27325, -29727] + +wavetable_A = [ -32767, -31475, -30284, -28603, -26239, -24215, -23129, -21511, -19390, -17493, -16263, -15358, -14344, + -12856, -10677, -8105, -4704, -682, 3135, 7347, 12576, 17683, 21531, 24140, 26388, 27561, 27630, 27327, + 25666, 22949, 19891, 17256, 13853, 10037, 6571, 3688, 843, -355, -869, -792, -50, 3607, 7571, 11713, + 15976, 20482, 24073, 27454, 30329, 31943, 31778, 30676, 28887, 26673, 24447, 21763, 19467, 17814, 16826, + 16464, 16516, 17115, 18404, 20744, 23196, 25173, 26214, 25629, 23905, 20921, 16930, 12316, 7937, 4004, + 385, -2880, -5435, -6997, -7843, -7838, -6809, -5498, -4290, -3957, -5058, -8246, -11763, -14893, -17610, + -20118, -22048, -22408, -21980, -21133, -18463, -14705, -11003, -7341, -3085, -1132, 173, 569, -268, + -3059, -5441, -8344, -11743, -14770, -16139, -16539, -16075, -14852, -13130, -11212, -9252, -7450, -6252, + -5424, -5269, -5695, -6513, -7898, -10547, -13393, -16203, -20419, -25175, -28446, -30443] +# fmt: on + +background_color = (0, 0, 0) +default_color = (0.7, 0.7, 0.7) +active_string_color = (0, 0.5, 1) +target_string_color = (0, 1, 0) + + +class WavetableOsc(bl00mbox.Patch): + def __init__(self, chan, curve): + super().__init__(chan) + + osc = self.new(bl00mbox.plugins.osc) + osc.antialiasing = False + osc.signals.waveform.switch.SAW = True + wave = self.new(bl00mbox.plugins.distortion) + wave.signals.input << osc.signals.output + + self._wave = wave + + self.signals.pitch = osc.signals.pitch + self.signals.output = wave.signals.output + self.set_curve_normalized(curve) + + def set_curve_normalized( + self, curve, autobias=True, autoscale=True, suggest_curve=True + ): + ref_curve = curve + + # for audio we want the average of the waveform to be 0. if fed by a saw or triangle wave, + # this directly transfers to the average of the wavetable, so we remove it. + # if you switch up the waveform of the oscillator this doesn't hold true and you + # must remove DC by other means. + if autobias: + avg = sum(curve) / len(curve) + curve = [x - avg for x in curve] + + # scale it to the max. must be done after zeroing average. + # maybe RMS would be smarter here but then we'd have to set an expected crest ratio + # and all that and we're not gonna do that :3 + if autoscale: + max_abs = max([abs(x) for x in curve]) + curve = [int(32767 * x / max_abs) for x in curve] + + self._wave.curve = curve + + # if you save your wavetable somewhere (as we do at the top of the file), you might as well + # save it as it is actually applied. if there's a difference this option prints it out + # on the REPL so that you can paste it in your code file. + # we already did that here, so we could call this with all optional arguemnts False to make + # loading a teeny tiny bit faster, but since we expect people to copy-paste this code and + # change up the wavetable we are not doing this here. but we could. + if suggest_curve: + curve = self._wave.curve + already_good = False + if len(ref_curve) == len(curve): + already_good = any( + [curve[x] != ref_curve[x] for x in range(len(curve))] + ) + if not already_good: + print("curve was resampled/-scaled/-biased, try this:") + print(self._wave.curve) + + +class ViolinPatch(bl00mbox.Patch): + def __init__(self, chan): + super().__init__(chan) + mp = self.new(bl00mbox.plugins.multipitch, 1) + lo_osc = self.new(WavetableOsc, wavetable_G) + lo_osc.signals.pitch << mp.signals.thru + hi_osc = self.new(WavetableOsc, wavetable_A) + hi_osc.signals.pitch << mp.signals.thru + + mixer = self.new(bl00mbox.plugins.mixer, 2) + mixer.signals.input[0] << hi_osc.signals.output + mixer.signals.input[1] << lo_osc.signals.output + + prs = [self.new(bl00mbox.plugins.range_shifter) for x in range(2)] + prs[1].signals.input << prs[0].signals.output + for x in range(2): + pr = prs[x] + mixer.signals.input_gain[x] << pr.signals.output + if x: + pr.signals.input_range[0] = 0 + pr.signals.input_range[1] = 4096 + pr.signals.output_range[0] = 4096 + pr.signals.output_range[1] = 0 + + mixer.signals.gain.mult = 0 + + lp = self.new(bl00mbox.plugins.filter) + lp.signals.cutoff.freq = 8000 + mixer.signals.output >> lp.signals.input + + # we were thinking about this for a while: wouldn't it be cool to just + # directly drive pitch bend from from user input instead of having this + # pesky osc in-between? it sounds good on paper, but turns out u can't + # switch up ur curl that quickly. how about modulo on the phase of position? + # ...interesting, we'd have to chase the average tilt of the wiggle, but also + # to control low wiggle sensitivity we'd probs need to adjust field on the fly + # or smth... it an amount of math in there. and this rigging works alright + # we think. + # about waveform: our widget vibrato output is unstable enough that we can get + # free random AM/FM from it, so that's cool + + vib_osc = self.new(bl00mbox.plugins.osc) + vib_osc.speed = "lfo" + vib_osc.signals.waveform.switch.SINE = True + vib_osc.signals.pitch.freq = 5 + mp.signals.mod_in << vib_osc.signals.output + mp.signals.mod_sens = 0 + + env = self.new(bl00mbox.plugins.env_adsr) + env.signals.input << lp.signals.output + env.signals.attack = 150 + env.signals.release = 200 + env.signals.sustain = 32767 + + self.signals.vibrato_speed = vib_osc.signals.pitch + self.signals.vibrato_depth = mp.signals.mod_sens + self.signals.volume = mixer.signals.gain + self.signals.timbre = prs[0].signals.input + self.signals.pitch = mp.signals.input + self.signals.output = env.signals.output + self.signals.trigger = env.signals.trigger + self.signals.env_gain = env.signals.env_output + + +class ViolinReverb(bl00mbox.Patch): + def __init__(self, chan): + super().__init__(chan) + num_delays = 4 + delays = [self.new(bl00mbox.plugins.delay_static) for x in range(num_delays)] + mixers = [ + self.new(bl00mbox.plugins.mixer, num_delays) for x in range(num_delays) + ] + buffer = self.new(bl00mbox.plugins.mixer, 1) + feedback_buffer = self.new(bl00mbox.plugins.mixer, 1) + + buffer.signals.output >> delays[0].signals.input + + self.signals.input = buffer.signals.input[0] + self.signals.output = mixers[0].signals.output + + delays[0].signals.feedback = 0 + delays[0].signals.dry_vol = 0 + + for x in range(1, num_delays): + delays[x].signals.input << mixers[x].signals.output + delays[x].signals.output >> mixers[0].signals.input[x] + mixers[x].signals.input[0] << delays[0].signals.output + for j in range(1, num_delays): + delays[j].signals.output >> mixers[x].signals.input[j] + delays[x].signals.feedback = 0 + delays[x].signals.dry_vol = 0 + delays[x].signals.time = min(30 * (x ** (3 / 2)), 500) + mixers[x].signals.gain << feedback_buffer.signals.output + + mixers[0].signals.input[0] << buffer.signals.output + mixers[0].signals.gain.mult = 1 + delays[0].signals.time = 100 + delays[0].signals.level = 0 + self.signals.volume = delays[0].signals.level + + # not great, be careful of oscillations + self.signals.feedback = feedback_buffer.signals.input[0] + feedback_buffer.signals.input[0] = 4096 * 0.4 + feedback_buffer.signals.gain.mult = -1 + + +def sqabs(val): + return val.real * val.real + val.imag * val.imag + + +class ViolinWidget(widgets.PetalWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.ring_len = 16 + self.min_len = 3 + self.min_log_len = 2 + self.max_log_len = 6 + self.avg_pos = None + self.curls = [0.0] * self.ring_len + self.sqvels = [0.0] * self.ring_len + self._autoclear() + + def think(self, ins, delta_ms): + log = ins.captouch.petals[self.petal].log + if not log: + return + for frame in log: + if not self._append_and_validate(frame): + continue + if self._log.length() < self.min_log_len: + continue + self._log.crop(-self.max_log_len) + pos = self._log.frames[-1].pos + prevpos = self._log.frames[-2].pos + # we're not using this timestamp as a negative cpu load + # random noise modulation load :> units are whatever + vel = pos - prevpos + pos = (pos + prevpos) / 2 + if self.avg_pos is None: + self.avg_pos = pos + else: + self.avg_pos += (pos - self.avg_pos) * 0.1 + pos -= self.avg_pos + sqvel = sqabs(vel) + self.sqvels[self.ring_index] = sqvel + curl = 0 + if pos: + # cross product to measure angle between movement + # and + curl = pos.real * vel.imag - pos.imag * vel.real + # it kinda helps for low amplitude circles + curl /= abs(pos) + # getting rid of sign here but also squaring it. + # curls need a lot of squaring kinda. + # but if u wanna not square do put an abs in there + self.curls[self.ring_index] = curl * curl + if self.num_entries < self.ring_len: + self.num_entries += 1 + self.ring_index += 1 + self.ring_index %= self.ring_len + self.active = self.num_entries >= self.min_len + if self.active: + curl = 0 + sqvel = 0 + for i in range(self.num_entries): + curl += self.curls[i] + sqvel += self.sqvels[i] + sqvel = sqvel / self.ring_len + curl /= self.num_entries + if sqvel: + # random thoughtless curve mangling, it feels alright ig? + # it is questionable whether this should be before the filter instead. + # but it's here, for now, and that's good enough ^w^ + curl /= sqvel * 4 + 0.2 + curl /= sqvel * 2 + 0.6 + else: + curl = 0 + # eyeballin it + # you'll probably have to change gain here if u make adjustments to + # the math above, just print values while interacting + sqvel *= 4 + curl *= 7 + curl *= curl + self.volume = min(sqvel, 1) + self.vibrato = min(curl, 1) + + def on_exit(self): + super().on_exit() + self._autoclear() + + def _autoclear(self): + self.avg_pos = None + self.num_entries = 0 + self.ring_index = 0 + self.volume = 0 + self.vibrato = 0 + + +class App(Application): + def __init__(self, app_ctx) -> None: + super().__init__(app_ctx) + self.captouch_conf = captouch.Config.default() + # we wamt equal spin times on top and bottom so that we don't have to deal + # with behavioral differences based on sample rate + self.captouch_conf.petals[1].mode = 1 + self.violin_widgets = [ + ViolinWidget(self.captouch_conf, petal) for petal in range(0, 10, 2) + ] + self.reverb_widget = widgets.Slider(self.captouch_conf, 5) + self.tilt_widget = widgets.Inclinometer(buffer_len=8) + self.widgets = self.violin_widgets + [self.reverb_widget, self.tilt_widget] + + # index of the widget we consider active. [0..4] or None. + self.active_widget_index = None + # which string we are targeting + self.target_string = 1 + # same but continuous + self.target_string_unfloored = 1 + + # fret corresponding to active_widget_index + self.fret = 4 + self.string = None + self.pitch = None + + # string that is targeted when setting tilt reference. + # lowest string is 0, highest is 3 + self.ref_string = 2 + # stores tilt reference + self.ref_tilt = 0 + # angular tilt distance between strings, in radians (here 12 degrees) + self.tilt_spacing = 12 * math.tau / 360 + # tilt hysteresis in units of tilt_spacing, i.e. 0.2*12deg = 2.4deg + self.tilt_hysteresis = 0.2 + self.bass = 1 + self.app_is_left = None + + # some helper storage for drawing + self.draw_sin = 0 + self.full_redraw = True + self.always_full_redraw = False + self.reverb_volume = 0.1 + self.show_reverb_bar = False + self.volume = 0 + self.vibrato = 0 + + def _build_synth(self): + self.blm = bl00mbox.Channel("violin") + self.synth = self.blm.new(ViolinPatch) + self.reverb = self.blm.new(ViolinReverb) + self.synth.signals.output >> self.reverb.signals.input + self.blm.signals.line_out << self.reverb.signals.output + + def draw(self, ctx) -> None: + ctx.text_align = ctx.CENTER + ctx.text_baseline = ctx.IDEOGRAPHIC + ctx.font = "Comic Mono" + if self.always_full_redraw: + self.full_redraw = True + if self.full_redraw: + ctx.rgb(*background_color).rectangle(-120, -120, 240, 240).fill() + ctx.rgb(*default_color) + num_holes = max(self.bass + 1, 1) + f_hole = "f" * num_holes + width = 90 - 3 * (3 - num_holes) + for x in range(2): + ctx.save() + if x: + ctx.apply_transform(-1, 0, 0, 0, 1, 0, 0, 0, 1) + ctx.translate(-width, 20) + ctx.move_to(1, 2) + ctx.rotate(-0.2) + if self.bass == -1: + ctx.rel_move_to(1, 6) + ctx.scale(0.8, 1) + ctx.font_size = 32 + ctx.text(f_hole) + ctx.move_to(0, 0) + ctx.rotate(math.pi) + if self.bass == -1: + ctx.rel_move_to(1, 6) + ctx.text(f_hole) + ctx.restore() + else: + ctx.rgb(*background_color).rectangle(-120, 75, 240, 10).fill() + ctx.rgb(*background_color).rectangle(-120, -105, 240, 10).fill() + pos = 63 + ctx.rgb(*background_color).rectangle(-pos, -120, pos * 2, 195).fill() + + ctx.save() + ctx.translate(0, 100) + size = 30 + if self.full_redraw: + ctx.line_width = 2 + ctx.rgb(*default_color) + if self.show_reverb_bar: + for x in range(2): + angle = 0.045 + rad = 20 + pos = size + 10 - rad # + x * 5 + ctx.arc( + -pos, + 0, + rad + x * 5, + (0.5 - angle) * math.tau, + (0.5 + angle) * math.tau, + 0, + ).stroke() + ctx.arc( + pos, + 0, + rad + x * 5, + (1 - angle) * math.tau, + (1 + angle) * math.tau, + 0, + ).stroke() + else: + ctx.move_to(-size - 10, -6) + ctx.rel_line_to(0, 11) + ctx.rel_curve_to(-5, -2, -5, -7, 0, -5) + ctx.stroke() + ctx.save() + ctx.translate(size + 10 - 1, 0) + ctx.move_to(3, -6) + ctx.rel_line_to(0, 11).stroke() + ctx.move_to(0, -2).rel_line_to(6, -1).stroke() + ctx.move_to(0, 2).rel_line_to(6, -1).stroke() + ctx.restore() + padding = 4 + ctx.round_rectangle( + -size - padding, + -5 - padding, + (size + padding) * 2, + (5 + padding) * 2, + 3 + padding - 1, + ).fill() + ctx.rgb(*background_color) + padding = 2 + ctx.round_rectangle( + -size - padding, + -5 - padding, + (size + padding) * 2, + 2 * (5 + padding), + 3 + padding + 1, + ).fill() + ctx.rgb(*default_color) + if self.show_reverb_bar: + ctx.round_rectangle(-size, -5, self.reverb_volume * 2 * size, 10, 3).fill() + else: + ctx.round_rectangle( + -self.vibrato * size, -5, self.vibrato * size * 2, 10, 3 + ).fill() + ctx.restore() + + if self.app_is_left is not None: + string_spacing = 30 + if not self.app_is_left: + string_spacing = -string_spacing + target_pos = (self.target_string_unfloored - 2) * string_spacing + ctx.rgb(*target_string_color) + ctx.arc(target_pos, 80, 3, 0, math.tau, 0).fill() + ctx.arc(target_pos, -100, 3, 0, math.tau, 0).fill() + for x in range(4): + ctx.move_to((x - 1.5) * string_spacing, -90) + ctx.line_width = 5 - x + if x == self.string: + ctx.rgb(*active_string_color) + # hey we could just take the output of the synthesizer here, wouldn't + # that be much better than this fake sine? + # ... yyyeah it doesn't really look all that good. sad. :'( + self.draw_sin %= math.tau + amplitude = 25 * self.volume * math.sin(self.draw_sin) + amplitude *= self.synth.signals.env_gain.mult + number = 3 + self.fret + quad_len = 160 / number + for _ in range(number): + amplitude *= -1 + ctx.rel_quad_to(amplitude, quad_len / 2, 0, quad_len) + else: + ctx.rgb(*default_color) + ctx.rel_line_to(0, 160) + if x == self.target_string: + ctx.rgb(*target_string_color) + ctx.stroke() + self.full_redraw = False + + def think(self, ins, delta_ms) -> None: + super().think(ins, delta_ms) + self.app_is_left = ins.buttons.app_is_left + for widget in self.widgets: + widget.think(ins, delta_ms) + + self.draw_sin += delta_ms / 20 + + bass = self.bass + bass += self.input.captouch.petals[1].whole.pressed + bass += self.input.captouch.petals[9].whole.pressed + bass -= self.input.captouch.petals[3].whole.pressed + bass -= self.input.captouch.petals[7].whole.pressed + bass = max(-1, min(2, bass)) + if bass != self.bass: + self.bass = bass + self.full_redraw = True + + tilt = self.tilt_widget.roll + if tilt is not None: + if ins.buttons.app == 2 or self.ref_tilt is None: + self.ref_tilt = tilt + tilt = None + else: + tilt -= self.ref_tilt + tilt_spacing = self.tilt_spacing + if not self.app_is_left: + tilt_spacing = -tilt_spacing + string = tilt / tilt_spacing + 0.5 + self.ref_string + predicted_string = math.floor(string) + if predicted_string > self.target_string: + string -= self.tilt_hysteresis + elif predicted_string < self.target_string: + string += self.tilt_hysteresis + self.target_string_unfloored = string + self.target_string = max(0, min(3, math.floor(string))) + + active_widget_index = None + volume = 0 + self.vibrato = 0 + for x, widget in enumerate(self.violin_widgets): + if widget.volume > volume: + volume = widget.volume + self.volume = volume + self.vibrato = widget.vibrato + active_widget_index = x + + new_note_happened = False + if self.active_widget_index != active_widget_index: + self.active_widget_index = active_widget_index + if active_widget_index is None: + self.string = None + self.synth.signals.trigger.stop() + else: + self.string = self.target_string + self.fret = 4 - self.active_widget_index + self.pitch = self.fret + self.string * 5 - self.bass * 12 - 14 + self.synth.signals.pitch.tone = self.pitch + self.synth.signals.trigger.start() + new_note_happened = True + + self.synth.signals.volume.value = 1000 * self.volume + + if self.string is not None: + self.synth.signals.vibrato_speed.freq = self.vibrato * 4 + 3 + self.synth.signals.vibrato_depth.value = 4096 * self.vibrato / 24 + # TODO: this isn't very nice or cover much range + timbre = (19 - self.pitch) / 19 + timbre = max(-1, min(1, timbre * 2 - 1)) + self.synth.signals.timbre.value = 32767 * timbre + + if self.reverb_widget.active != self.show_reverb_bar: + self.full_redraw = True + self.show_reverb_bar = self.reverb_widget.active + if self.reverb_widget.active: + self.reverb_volume = (1 - self.reverb_widget.pos.real) / 2 + self.reverb.signals.volume.value = self.reverb_volume * 30000 + + if new_note_happened: + led_patterns.pretty_pattern() + leds.update() + + def on_enter(self, vm): + super().on_enter(vm) + for widget in self.widgets: + widget.on_enter() + self.ref_tilt = None + self.string = None + self._build_synth() + self.captouch_conf.apply() + led_patterns.pretty_pattern() + leds.update() + self.always_full_redraw = True + self.full_redraw = True + + def on_exit(self): + super().on_exit() + for widget in self.widgets: + widget.on_exit() + self.blm = None + self.always_full_redraw = True + + def on_enter_done(self): + self.always_full_redraw = False + + def on_exit_done(self): + self.always_full_redraw = False + + def get_help(self): + ret = ( + "Violin Emulator\n\n" + 'Rub the top petals to play different "frets".\n\n' + "Tilt left/right to switch string. String switching occurs when the active petal changes. " + "Pressing the app button down zeros the tilt reference to the current orientation.\n\n" + "You can add vibrato by rubbing in circles.\n\n" + "Tapping petals 1 or 9 shifts the global pitch an octave lower, tapping petals 3 or 7 shifts it " + "an octave higher.\n\n" + "Petal 5 is a slider to control the amount of reverb." + ) + return ret + + +if __name__ == "__main__": + from st3m.run import run_app + + run_app(App, "/flash/sys/apps/violin") diff --git a/python_payload/apps/violin/flow3r.toml b/python_payload/apps/violin/flow3r.toml new file mode 100644 index 0000000000000000000000000000000000000000..5d9df0d1e98a4c7111b205852ab6e8e3b546f2d8 --- /dev/null +++ b/python_payload/apps/violin/flow3r.toml @@ -0,0 +1,8 @@ +[app] +name = "violin" +category = "Music" + +[metadata] +author = "Flow3r Badge Authors" +license = "LGPL-3.0-only" +url = "https://git.flow3r.garden/flow3r/flow3r-firmware"