diff --git a/python_payload/mypystubs/bl00mbox/_user.pyi b/python_payload/mypystubs/bl00mbox/_user.pyi index 1332563d5b8cf30c07c7281a231d9352bf2195cb..c36d4c5d58061b08650902a9bad3d92d18fa7e2e 100644 --- a/python_payload/mypystubs/bl00mbox/_user.pyi +++ b/python_payload/mypystubs/bl00mbox/_user.pyi @@ -1,4 +1,4 @@ -from typing import Optional, TypeVar, Any, Type, List, overload, Annotated +from typing import Optional, TypeVar, Any, Type, List, overload, Annotated, Callable import bl00mbox class Signal: diff --git a/python_payload/st3m/ui/elements/overlays.py b/python_payload/st3m/ui/elements/overlays.py index 22865417eb87c831b318019090cf78a61ee40852..8b715ca19a5ca087f3499bd0d7908f60f6d23d9f 100644 --- a/python_payload/st3m/ui/elements/overlays.py +++ b/python_payload/st3m/ui/elements/overlays.py @@ -16,6 +16,7 @@ from ctx import Context import st3m.wifi from st3m.application import viewmanager_is_in_application from st3m.ui.help import Help +from st3m.ui.mixer import AudioMixer import math import audio @@ -388,15 +389,15 @@ class OverlaySystemMenu(Overlay): self._menu_pos = 0 self._os_menu_entries = [ "resume", - "go home", "help", - # "mixer", + "mixer", + "go home", ] self._app_menu_entries = [ "resume", - "exit app", "help", - # "mixer", + "mixer", + "exit app", ] self.active = False self.sub = None @@ -427,16 +428,16 @@ class OverlaySystemMenu(Overlay): if self.input.buttons.app.middle.released: if self._menu_pos == 0: self.close_menu() - elif self._menu_pos == 2: + elif self._menu_pos == 1: self.sub = Help(self.input, self.get_help()) + elif self._menu_pos == 2: + self.sub = AudioMixer(self.input) elif self.in_app: - if self._menu_pos == 1: - self.exit_app() - self.close_menu() + self.exit_app() + self.close_menu() else: - if self._menu_pos == 1: - self.go_home() - self.close_menu() + self.go_home() + self.close_menu() self._menu_pos += self.input.buttons.app.right.pressed self._menu_pos -= self.input.buttons.app.left.pressed self._menu_pos %= len(self.entries) diff --git a/python_payload/st3m/ui/mixer.py b/python_payload/st3m/ui/mixer.py new file mode 100644 index 0000000000000000000000000000000000000000..98af388394bef0168ba864c727fca280474c6e23 --- /dev/null +++ b/python_payload/st3m/ui/mixer.py @@ -0,0 +1,358 @@ +import bl00mbox +from st3m import Responder, settings +import math +import media + + +def recall_blm_channel_stats(blm_chan): + if blm_chan.free: + return + chan = bl00mboxChannel(blm_chan) + if chan.get_name() in session_channel_vol_mute: + vol, mute = session_channel_vol_mute[chan.get_name()] + if mute: + vol = -math.inf + chan._set_vol_dB(vol) + + +bl00mbox.set_channel_init_callback(recall_blm_channel_stats) + + +class Channel: + index = 0 + _vol = 0 + + def get_name(self): + return "dummy" + str(self.index) + + def get_num_plugins(self): + return None + + def _get_vol_dB(self): + return self._vol + + def _set_vol_dB(self, val): + self._vol = val + + def __init__(self): + self.mute = False + + def get_rms_dB(self): + return -math.inf + + def get_mute(self): + mute = False + if self.get_name() in session_channel_vol_mute: + _, mute = session_channel_vol_mute[self.get_name()] + return bool(mute) + + def set_mute(self, mute): + if self.get_name() in session_channel_vol_mute: + vol, _ = session_channel_vol_mute[self.get_name()] + if mute: + vol = self._get_vol_dB() + self._set_vol_dB(-math.inf) + else: + self._set_vol_dB(vol) + session_channel_vol_mute[self.get_name()] = vol, mute + elif mute: + vol = self._get_vol_dB() + session_channel_vol_mute[self.get_name()] = vol, True + self._set_vol_dB(-math.inf) + + def get_vol_dB(self): + if self.get_mute(): + if self.get_name() in session_channel_vol_mute: + vol, _ = session_channel_vol_mute[self.get_name()] + return vol + else: + return -math.inf + else: + return self._get_vol_dB() + + def set_vol_dB(self, vol): + if not self.get_mute(): + self._set_vol_dB(vol) + session_channel_vol_mute[self.get_name()] = vol, self.get_mute() + + +class bl00mboxChannel(Channel): + def __init__(self, chan): + super().__init__() + self.blm = chan + + def get_name(self): + return self.blm.name + + def get_num_plugins(self): + return self.blm.num_plugins + + def _get_vol_dB(self): + return self.blm.sys_gain_dB + + def _set_vol_dB(self, vol): + self.blm.sys_gain_dB = vol + + def get_rms_dB(self): + return self.blm.sys_rms_dB + + +class mediaChannel(Channel): + def get_name(self): + return "media" + + def _get_vol_dB(self): + ret = media.get_volume() + if ret == 0: + return -math.inf + else: + return 20 * math.log(ret, 10) + + def _set_vol_dB(self, vol): + if vol == -math.inf: + vol = 0 + else: + vol = 10 ** (vol / 20) + media.set_volume(vol) + + +session_channel_vol_mute = {} + + +class ChannelColors: + bg = (0.3, 0.3, 0.3) + name = (0, 1, 1) + vol_bar = (0, 1, 0) + rms_bar = (1, 0, 0) + mute_bg = ((0.2, 0.2, 0.2), (0.5, 0.0, 0.0)) + mute_fg = ((0.5, 0.5, 0.5), (0.8, 0.8, 0.8)) + + +class HighlightColors: + bg = (0.5, 0.5, 0.5) + name = (0, 1, 1) + vol_bar = (0, 1, 0) + rms_bar = (1, 0, 0) + mute_bg = ((0.2, 0.2, 0.2), (0.8, 0.0, 0.0)) + mute_fg = ((0.5, 0.5, 0.5), (1.0, 1.0, 1.0)) + + +class AudioMixer(Responder): + def __init__(self, inputcontroller): + self.input = inputcontroller + self._pos = 0 + self.chan_width = 46 # scaling factor between _pos and draw_pos + self._editing_channel = 0 + self.colors = ChannelColors() + self.highlight_colors = HighlightColors() + + self._refresh() + self._draw_pos = [ + self.chan_width * max(min((len(self._chans) - 1) / 2, 1), 0), + 0, + 0, + ] # includes also 1st and 2nd derivatives per second(^2) + + self.override_os_button_back = False + repeat = settings.num_volume_repeat_ms.value + repeat_wait = settings.num_volume_repeat_wait_ms.value + self.vol_repeat = (repeat_wait, repeat) + self.channel_select_repeat = (500, 300) + self.volume_step_dB = settings.num_volume_step_db.value + self.volume_repeat_step_dB = settings.num_volume_repeat_step_db.value + self.input.buttons.app.right.repeat_enable(*self.channel_select_repeat) + self.input.buttons.app.left.repeat_enable(*self.channel_select_repeat) + self.acc_ms = 0 + # we don't have double-volume here yet so we share it with applications. + # if it has been changed by an application we accept that as the new value + # for now. + if "media" in session_channel_vol_mute: + vol, mute = session_channel_vol_mute["media"] + mediavol = media.get_volume() + if mute: + if mediavol != 0: + mute = False + vol = mediavol + else: + vol = mediavol + session_channel_vol_mute["media"] = (vol, mute) + + def _refresh(self): + self._chans = [mediaChannel()] + for i in range(32): + chan = bl00mboxChannel(bl00mbox.SysChannel(i)) + if not chan.blm.free and ( + chan.blm.foreground or chan.blm.background_mute_override + ): + chan.prev_compute_rms = chan.blm.compute_rms + chan.blm.compute_rms = True + self._chans += [chan] + """ + for i in range(5): + self._chans += [Channel()] + """ + for i, chan in enumerate(self._chans): + chan.index = i + self._pos = min(self._pos, len(self._chans) - 1) + + def think(self, ins, delta_ms): + self.override_os_button_back = bool(self._editing_channel) + lr_dir = self.input.buttons.app.right.pressed + lr_dir -= self.input.buttons.app.left.pressed + num_chans = len(self._chans) + if self.input.buttons.app.middle.pressed: + self._editing_channel += 1 + self._editing_channel %= 3 + if self._editing_channel == 1: + self.input.buttons.app.right.repeat_enable(*self.vol_repeat) + self.input.buttons.app.left.repeat_enable(*self.vol_repeat) + elif not self._editing_channel: + self.input.buttons.app.right.repeat_enable(*self.channel_select_repeat) + self.input.buttons.app.left.repeat_enable(*self.channel_select_repeat) + elif self._editing_channel == 1: + lr_dir_repeat = self.input.buttons.app.right.repeated + lr_dir_repeat -= self.input.buttons.app.left.repeated + lr_dir_repeat *= self.volume_repeat_step_dB + lr_dir *= self.volume_step_dB + lr_dir += lr_dir_repeat + if self._editing_channel: + if self.input.buttons.os.middle.released: + self._editing_channel = 0 + elif lr_dir and num_chans > self._pos: + chan = self._chans[self._pos] + if self._editing_channel == 1: + vol = chan.get_vol_dB() + lr_dir + vol = min(20, max(-50, vol)) + chan.set_vol_dB(vol) + if self._editing_channel == 2: + chan.set_mute(not chan.get_mute()) + else: + lr_dir += self.input.buttons.app.right.repeated + lr_dir -= self.input.buttons.app.left.repeated + self._pos += lr_dir + self._pos %= num_chans + self.acc_ms += delta_ms + if num_chans > 2: + target = min(num_chans - 2, max(self._pos, 1)) + elif num_chans == 2: + target = 0.5 + else: + target = 0 + target *= self.chan_width + + self.acc_ms += delta_ms + while self.acc_ms > 0: + self.acc_ms -= 20 + self._draw_pos[2] = (target - self._draw_pos[0]) / 2 + self._draw_pos[2] -= self._draw_pos[1] + self._draw_pos[1] += self._draw_pos[2] / 10 + self._draw_pos[0] += self._draw_pos[1] / 10 + + def draw(self, ctx): + ctx.rgb(0, 0, 0) + ctx.rectangle(-120, -120, 240, 240).fill() + ctx.text_align = ctx.CENTER + ctx.rgb(0x81 / 255, 0xCD / 255, 0xC6 / 255) + ctx.font_size = 24 + ctx.move_to(0, -90) + ctx.text("~ mixer ~") + for x, chan in enumerate(self._chans): + xpos = x * self.chan_width - self._draw_pos[0] + if abs(xpos) > 120 + self.chan_width / 2: + continue + highlight = self._pos == x + if highlight: + colors = self.highlight_colors + else: + colors = self.colors + ctx.save() + ctx.translate(xpos, (1 - highlight) * 2) + self.draw_channel(colors, ctx, chan, highlight) + ctx.restore() + + def draw_channel(self, colors, ctx, chan, highlight): + ctx.text_align = ctx.RIGHT + ctx.rgb(*colors.bg) + chan_len = 160 + chan_upper = -80 + chan_width = self.chan_width - 4 + ctx.round_rectangle(-chan_width / 2, chan_upper, chan_width, chan_len, 2).fill() + + ctx.font_size = 16 + # draw name + ctx.save() + ctx.rgb(*colors.name) + ctx.translate(0, 0) + ctx.rotate(-math.tau / 4) + ctx.move_to(-chan_upper - 4, -chan_width / 2 + 14) + name = chan.get_name() + while ctx.text_width(name) > 110: + name = name[:-1] + ctx.text(name) + ctx.restore() + + # draw number of plugins (bl00mbox only) + num_plugins = chan.get_num_plugins() + if num_plugins is not None: + ctx.font_size = 12 + ctx.rgb(*[0.7 * x for x in colors.bg]) + ctx.move_to(chan_width / 2 - 3, chan_upper + 12) + ctx.text(str(num_plugins)) + + # volume + ctx.rgb(0, 0, 0) + vol = chan.get_vol_dB() + ctx.move_to(chan_width / 2 - 3, 75) + ctx.font_size = 14 + ctx.text(f"{vol:.1f}") + + ctx.rgb(0, 0, 0) + len_bar = chan_len - 38 + bar_bottom = chan_upper + chan_len - 21 + ctx.rectangle(chan_width / 2 - 13, bar_bottom - len_bar, 9, len_bar).fill() + ctx.move_to(chan_width / 2 - 13, bar_bottom - len_bar * 5 / 7) + ctx.rel_line_to(-5, 0).stroke() + # vol bar + ctx.rgb(*colors.vol_bar) + vol_bar_len = (vol + 50) / 70 + vol_bar_len = min(1, max(0, vol_bar_len)) + vol_bar_len *= len_bar + ctx.rectangle( + chan_width / 2 - 12, bar_bottom - vol_bar_len, 3, vol_bar_len + ).fill() + if self._editing_channel == 1 and highlight: + ctx.rgb(0, 1, 0) + ctx.move_to(6, bar_bottom - vol_bar_len) + ctx.rel_line_to(-5, -3) + ctx.rel_line_to(0, 6) + ctx.rel_line_to(5, -3) + ctx.fill() + + # rms bar + # hmmmh do we reuse the 0dB notch for normalization...? + rms = chan.get_rms_dB() + 11 + ctx.rgb(*colors.rms_bar) + rms_bar_len = (rms + 50) / 70 + rms_bar_len = min(1, max(0, rms_bar_len)) + rms_bar_len *= len_bar + ctx.rectangle( + chan_width / 2 - 8, bar_bottom - rms_bar_len, 3, rms_bar_len + ).fill() + + # mute + mute = int(chan.get_mute()) + ctx.rgb(*(colors.mute_bg[mute])) + ctx.rectangle(-chan_width / 2 + 3, 41, 18, 18).fill() + ctx.text_align = ctx.CENTER + ctx.font_size = 16 + ctx.move_to(-chan_width / 2 + 12, 55) + ctx.rgb(*(colors.mute_fg[mute])) + ctx.text("M") + if self._editing_channel == 2 and highlight: + ctx.rgb(0, 1, 0) + ctx.round_rectangle(-chan_width / 2 + 3, 41, 18, 18, 2).stroke() + + def on_exit(self): + for chan in self._chans: + if isinstance(chan, bl00mboxChannel): + chan.blm.compute_rms = chan.prev_compute_rms diff --git a/sim/fakes/bl00mbox.py b/sim/fakes/bl00mbox.py index b9303bfcaeadb0f97f91020c3775c4a7db69edb6..030c6f8c9128a8edeb8bd57b96813a83789b5b3f 100644 --- a/sim/fakes/bl00mbox.py +++ b/sim/fakes/bl00mbox.py @@ -2,6 +2,10 @@ import pygame from _sim import path_replace +def set_channel_init_callback(fun): + pass + + class _mock(list): def __init__(self, *args, **kwargs): pass