diff --git a/python_payload/apps/wobbler/__init__.py b/python_payload/apps/wobbler/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f8b404203b11b1243a2c7b85fc599f837c384bde
--- /dev/null
+++ b/python_payload/apps/wobbler/__init__.py
@@ -0,0 +1,187 @@
+from st3m.application import Application
+
+import math, cmath
+import bl00mbox
+import captouch
+import leds
+from st3m.ui import widgets, colours
+
+
+class Wobbler(Application):
+    def __init__(self, app_ctx):
+        super().__init__(app_ctx)
+        self.blm = None
+
+        self.any_playing = False
+        self.tilt_ref = None
+        self.tilt = None
+        self.pitch = -36
+
+    def on_enter(self, vm):
+        super().on_enter(vm)
+        if self.blm is not None:
+            try:
+                self.blm.foreground = True
+            except ReferenceError:
+                self.blm = None
+
+        if self.blm is None:
+            self.build_synth()
+
+        leds.set_slew_rate(min(160, leds.get_slew_rate()))
+
+    def on_exit(self):
+        super().on_exit()
+        if self.any_playing:
+            self.blm.background_mute_override = True
+        else:
+            self.blm.delete()
+            self.blm = None
+
+    def build_synth(self):
+        self.blm = bl00mbox.Channel("wobbler")
+        self.blm.gain_dB = 0
+        self.mixer = self.blm.new(bl00mbox.plugins.mixer, 2)
+        self.mixer.signals.gain.mult = 8
+        self.filter = self.blm.new(bl00mbox.plugins.filter)
+        self.filter.signals.gain.mult = 0.2
+        self.filter.signals.reso.value = 22000
+        self.env = self.blm.new(bl00mbox.plugins.env_adsr)
+        self.filter.signals.input << self.mixer.signals.output
+        self.env.signals.input << self.filter.signals.output
+        self.blm.signals.line_out << self.env.signals.output
+        self.oscs = [self.blm.new(bl00mbox.plugins.osc) for x in range(2)]
+
+        for x, osc in enumerate(self.oscs):
+            osc.signals.output >> self.mixer.signals.input[x]
+            osc.signals.waveform.switch.SAW = True
+
+        # background widget, doesn't do normal think/on_enter/on_exit
+        self.tilt_widget = widgets.Inclinometer(buffer_len=2)
+        self.tilt_widget.on_enter()
+
+        def synth_callback(ins, delta_ms):
+            self.tilt_widget.think(ins, delta_ms)
+            roll = self.tilt_widget.roll
+            if roll is not None:
+                tilt = complex(roll, self.tilt_widget.pitch)
+                if self.tilt_ref is None:
+                    self.tilt_ref = tilt
+                else:
+                    tilt = tilt - self.tilt_ref
+                    tilt *= 1.6
+                    abs_tilt = abs(tilt)
+                    if abs_tilt > 1:
+                        tilt /= abs_tilt
+                    self.tilt = tilt
+                    self.filter.signals.cutoff.tone = self.pitch + (2 - tilt.imag) * 20
+                    self.oscs[0].signals.pitch.tone = self.pitch + tilt.real * 2
+                    self.oscs[1].signals.pitch.tone = self.pitch - tilt.real * 2
+
+        self.blm.callback = synth_callback
+
+    def think(self, ins, delta_ms):
+        super().think(ins, delta_ms)
+
+        if self.input.buttons.app.middle.pressed:
+            roll = self.tilt_widget.roll
+            if roll is not None:
+                tilt = complex(roll, self.tilt_widget.pitch)
+                self.tilt_ref = tilt
+
+        any_playing = False
+        pos = 0
+        pos_div = 0
+        for x in range(0, 10, 2):
+            if pressed := ins.captouch.petals[x].pressed:
+                any_playing = True
+                pos += ins.captouch.petals[x].pos.real
+                pos_div += 1
+            """
+            if x == 2:
+                for x, osc in enumerate(self.oscs):
+                    if pressed:
+                         osc.signals.waveform.switch.SAW = True
+                    else:
+                         osc.signals.waveform.switch.TRI = True
+            elif x == 8:
+                self.mixer.signals.gain.mult = 8 if pressed else 2
+            """
+
+        if pos_div:
+            self.pitch = -44 + (pos / pos_div + 1) * 6
+
+        if self.any_playing != any_playing:
+            if any_playing:
+                self.env.signals.trigger.start()
+            else:
+                self.env.signals.trigger.stop()
+        self.any_playing = any_playing
+
+    def get_help(self):
+        ret = (
+            "Press any top petal to play the note. How far away from "
+            "the center you press controls pitch.\n\n"
+            "Tilt forward/backward to change filter cutoff and left/right "
+            "to detune the oscillators. Press app button down to zero the "
+            "tilt reference.\n\n"
+            "If you exit while playing the note it will continue playing "
+            "in the background while still responding to tilt."
+        )
+        return ret
+
+    def draw(self, ctx):
+        ctx.gray(0).rectangle(-120, -120, 240, 240).fill()
+
+        col = (1, 1, 1)
+        eye_pos = complex(0, 0)
+        hue = 0
+
+        tilt = self.tilt_widget.pitch
+        if self.tilt is not None:
+            eye_pos = self.tilt * 10
+            hue = (eye_pos.real + eye_pos.imag) % math.tau
+            eye_pos *= 5
+
+        ctx.radial_gradient(
+            eye_pos.real, eye_pos.imag, 0, eye_pos.real, eye_pos.imag, 100
+        )
+        for x in range(5):
+            rel = x / 4
+            nval = rel
+            if self.any_playing:
+                nval /= 3
+            col = colours.hsv_to_rgb(hue + math.tau * rel, 1 - rel / 2, 1 - nval)
+            ctx.add_stop(rel, col, 1)
+            for y in range(8):
+                leds.set_rgb(y * 5 + x, *col)
+        leds.update()
+
+        length = 100
+        openness = 60
+        ctx.line_width = 1
+        ctx.move_to(length, 0).quad_to(0, openness, -length, 0).stroke()
+        ctx.move_to(length, 0).quad_to(0, -openness, -length, 0).stroke()
+        for x in range(3):
+            lash_length = 0.3 if x == 1 else 0.2
+            t = (x + 1) / 4
+            nt = 1 - t
+            # nt^2 * p0 + 2 * nt * t * p1 + t^2 * P2
+            lash_start_x = (nt * nt - t * t) * length
+            lash_start_y = t * nt * openness * 2
+            # 2 * (nt - t) * p1 - 2 * nt * p0 + 2 * t * p2
+            lash_angle_x = 2 * length
+            lash_angle_y = 2 * (nt - t) * openness
+            ctx.move_to(lash_start_x, lash_start_y)
+            ctx.rel_line_to(
+                lash_angle_y * lash_length, lash_angle_x * lash_length
+            ).stroke()
+
+        ctx.arc(eye_pos.real, eye_pos.imag, 10, 0, math.tau, 0).stroke()
+        ctx.arc(eye_pos.real, eye_pos.imag, 20, 0, math.tau, 0).stroke()
+
+
+if __name__ == "__main__":
+    import st3m.run
+
+    st3m.run.run_app(Wobbler)
diff --git a/python_payload/apps/wobbler/flow3r.toml b/python_payload/apps/wobbler/flow3r.toml
new file mode 100644
index 0000000000000000000000000000000000000000..926c9988ceaa264eaba4c62995faf470abfc538a
--- /dev/null
+++ b/python_payload/apps/wobbler/flow3r.toml
@@ -0,0 +1,11 @@
+[app]
+name = "wobbler"
+category = "Music"
+
+[entry]
+class = "Wobbler"
+
+[metadata]
+author = "Flow3r Badge Authors"
+license = "LGPL-3.0-only"
+url = "https://git.flow3r.garden/flow3r/flow3r-firmware"