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