diff --git a/AGENTS.md b/AGENTS.md index d58bdd38..cb45ddae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ syncs back to Python. | AnnotationWidget | `wigglystuff.annotation.AnnotationWidget` | `action`, `action_timestamp`, `note`, `listening`, `actions`, `keyboard_mapping`, `gamepad_mapping`, `debounce_ms`, `width` | Annotation input surface with buttons, keyboard, gamepad, and speech-to-text | | ApiDoc | `wigglystuff.api_doc.ApiDoc` | `doc`, `width`, `show_private` | Renders API docs for Python classes/functions | | Slider2D | `wigglystuff.slider2d.Slider2D` | `x`, `y`, `x_bounds`, `y_bounds`, `width`, `height` | 2D pointer for coupled parameters | +| Knob | `wigglystuff.knob.Knob` | `value`, `min_value`, `max_value`, `step`, `start_angle`, `end_angle`, `ticks`, `steps`, `size`, `label`, `show_value`, `color`, `midi`, `midi_cc`, `midi_channel`, `midi_device`, `midi_key`, `midi_scope` | Audio-panel rotary knob with configurable sweep, detents, and Web MIDI learn | +| Fader | `wigglystuff.fader.Fader` | `value`, `min_value`, `max_value`, `step`, `ticks`, `steps`, `orientation`, `length`, `label`, `show_value`, `color`, `midi`, `midi_cc`, `midi_channel`, `midi_device`, `midi_key`, `midi_scope` | Mixing-console fader with a configurable tick scale, detents, and Web MIDI learn | | BezierCurve | `wigglystuff.bezier_curve.BezierCurve` | `points`, `samples`, `x`, `y`, `t`, `closed`, `playing`, `loop`, `interval_ms`, `duration_ms`, `sync_throttle_ms`, `show_axes`, `n_samples`, `x_bounds`, `y_bounds`, `width`, `height` | Arbitrary-degree Bezier curve editor with draggable control points, playback, and optional axis ticks | | CurveEditor | `wigglystuff.curve_editor.CurveEditor` | `points`, `samples`, `x`, `y`, `t`, `curve`, `closed`, `playing`, `loop`, `tension`, `alpha`, `selected_index`, `show_axes`, `n_samples`, `x_bounds`, `y_bounds`, `width`, `height` | Chart-space curve editor with D3 line interpolators, path progress, and optional axis ticks | | ChartPuck | `wigglystuff.chart_puck.ChartPuck` | `x`, `y`, `x_bounds`, `y_bounds`, `axes_pixel_bounds`, `width`, `height`, `chart_base64`, `puck_radius`, `puck_color`, `throttle` | Draggable puck overlay for matplotlib charts | diff --git a/CHANGELOG.md b/CHANGELOG.md index 16ac9e71..e2cf1db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. +## [0.5.25] - 2026-08-12 + +### Added + +- New `Knob` widget: an audio-panel rotary knob. Partial-arc by default (configurable `start_angle`/`end_angle`, up to a gapless full 360° circle), with a pointer, a configurable tick scale (`ticks`), and discrete detents (`steps`) for a rotary selector. +- New `Fader` widget: a mixing-console fader (vertical or horizontal) with a configurable tick scale and the same `steps` detents. +- Both support optional Web MIDI "learn" (`midi=True`): click the button, move a hardware control, and the next control-change binds to the widget and drives its value. Bindings can be set upfront (`midi_cc`/`midi_channel`) and persist across restarts in browser localStorage, namespaced by the notebook URL path (override with `midi_key`/`midi_scope`). Web MIDI is Chromium-only and needs a secure context. +- Demos at `demos/knob.py` and `demos/fader.py`. + ## [0.5.24] - 2026-08-10 ### Fixed diff --git a/README.md b/README.md index fd4c9c5b..905df932 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,11 @@ uv pip install wigglystuff Excalidraw

molab · API · MD +Knob

molab · API · MD +Fader

molab · API · MD + + + LiveEdit

molab · API · MD FramePlayer

molab · API · MD AsyncFlow

molab · API · MD diff --git a/demos/fader.py b/demos/fader.py new file mode 100644 index 00000000..bf43c00f --- /dev/null +++ b/demos/fader.py @@ -0,0 +1,116 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo", +# "wigglystuff==0.5.25", +# ] +# /// + +import marimo + +__generated_with = "0.23.16" +app = marimo.App(width="medium") + + +@app.cell +def _(mo): + mo.md(""" + # Fader + """) + return + + +@app.cell +def _(mo): + from wigglystuff import Fader + + level = mo.ui.anywidget( + Fader( + min_value=-60, + max_value=6, + value=0, + ticks=[(-60, "-60"), (-20, "-20"), (-6, "-6"), (0, "0"), (6, "+6")], + label="Level (dB)", + ) + ) + return Fader, level + + +@app.cell +def _(Fader, mo): + send = mo.ui.anywidget( + Fader(min_value=0, max_value=100, value=75, ticks=5, label="Send", color="teal") + ) + crossfade = mo.ui.anywidget( + Fader( + min_value=0, + max_value=1, + step=0.01, + value=0.5, + orientation="horizontal", + ticks=[(0, "A"), (1, "B")], + length=180, + label="Crossfade", + ) + ) + return crossfade, send + + +@app.cell +def _(crossfade, level, mo, send): + mo.hstack([level, send, crossfade], justify="center", align="center", gap=2) + return + + +@app.cell(hide_code=True) +def _(crossfade, level, mo, send): + mo.md(f""" + **Level:** `{level.value['value']:.1f} dB`   + **Send:** `{send.value['value']:.0f}`   + **Crossfade:** `{crossfade.value['value']:.2f}` + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## MIDI learn + + Click the **MIDI** button, then move a control on your hardware — the next + control-change message binds to the fader (like Ableton). The binding is + remembered in your browser (keyed by the fader's `label`), so it survives a + kernel restart or cell re-run. Needs a Chromium browser and a connected MIDI + device; right-click the button to clear. + """) + return + + +@app.cell +def _(Fader, mo): + midi_fader = mo.ui.anywidget( + Fader(min_value=0, max_value=127, value=64, midi=True, label="MIDI") + ) + midi_fader + return (midi_fader,) + + +@app.cell(hide_code=True) +def _(midi_fader, mo): + mo.md(f""" + **Value:** `{midi_fader.value['value']:.0f}`   + **Bound CC:** `{midi_fader.value['midi_cc']}`   + **Device:** `{midi_fader.value['midi_device'] or '—'}` + """) + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/demos/knob.py b/demos/knob.py new file mode 100644 index 00000000..de2ae229 --- /dev/null +++ b/demos/knob.py @@ -0,0 +1,124 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "marimo", +# "wigglystuff==0.5.25", +# ] +# /// + +import marimo + +__generated_with = "0.23.16" +app = marimo.App() + + +@app.cell +def _(mo): + mo.md(""" + # Knob + """) + return + + +@app.cell +def _(mo): + from wigglystuff import Knob + + gain = mo.ui.anywidget( + Knob(min_value=0, max_value=11, value=5, ticks=12, label="Gain", color="tomato") + ) + return Knob, gain + + +@app.cell +def _(Knob, mo): + # A wider sweep with labelled endpoints, and a smaller pan-style knob. + pan = mo.ui.anywidget( + Knob( + min_value=-1, + max_value=1, + step=0.05, + value=0, + ticks=[(-1, "L"), (0, "C"), (1, "R")], + label="Pan", + ) + ) + wide = mo.ui.anywidget( + Knob( + min_value=0, + max_value=100, + value=30, + start_angle=-160, + end_angle=160, + ticks=5, + size=110, + label="Wide sweep", + ) + ) + # A gapless full-circle knob (start_angle=0, end_angle=360) that wraps. + full = mo.ui.anywidget( + Knob( + min_value=0, + max_value=360, + value=90, + start_angle=0, + end_angle=360, + ticks=[(0, "N"), (90, "E"), (180, "S"), (270, "W")], + label="Full circle", + ) + ) + return full, pan, wide + + +@app.cell +def _(full, gain, mo, pan, wide): + mo.hstack([gain, pan, wide, full], justify="center", gap=2) + return + + +@app.cell(hide_code=True) +def _(full, gain, mo, pan, wide): + mo.md(f""" + **Gain:** `{gain.value['value']:.1f}`   + **Pan:** `{pan.value['value']:.2f}`   + **Wide:** `{wide.value['value']:.0f}`   + **Full:** `{full.value['value']:.0f}` + """) + return + + +@app.cell(hide_code=True) +def _(mo): + mo.md(""" + ## MIDI learn + + Click the **MIDI** button, then move a control on your hardware — the next + control-change message binds to the knob (like Ableton). The binding is + remembered in your browser (keyed by the knob's `label`), so it survives a + kernel restart or cell re-run. Needs a Chromium browser and a connected MIDI + device; right-click the button to clear. + """) + return + + +@app.cell +def _(Knob, mo): + midi_knob1 = mo.ui.anywidget( + Knob(min_value=0, max_value=127, value=64, midi=True, label="MIDI") + ) + midi_knob2 = mo.ui.anywidget( + Knob(min_value=0, max_value=127, value=64, midi=True, label="MIDI") + ) + mo.hstack([midi_knob1, midi_knob2], justify="center") + return + + +@app.cell +def _(): + import marimo as mo + + return (mo,) + + +if __name__ == "__main__": + app.run() diff --git a/docs/assets/gallery/fader.webp b/docs/assets/gallery/fader.webp new file mode 100644 index 00000000..db1f5f97 Binary files /dev/null and b/docs/assets/gallery/fader.webp differ diff --git a/docs/assets/gallery/knob.webp b/docs/assets/gallery/knob.webp new file mode 100644 index 00000000..99372cb2 Binary files /dev/null and b/docs/assets/gallery/knob.webp differ diff --git a/docs/index.md b/docs/index.md index b26a85b6..8b2a9616 100644 --- a/docs/index.md +++ b/docs/index.md @@ -243,6 +243,16 @@ The documentation for wigglystuff is designed for humans (via hosted marimo note + +