From e1cbc7e8065335a34fd094af14b266175bd6e7bf Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:23:38 -0500 Subject: [PATCH] panel: an adapter that lets a control panel edit an instrument --- CHANGELOG.md | 6 + docs/overview.md | 7 + docs/panel.md | 47 +++++++ lib/audioinstruments/panel.py | 237 ++++++++++++++++++++++++++++++++++ tests/mpy/run.py | 1 + tests/mpy/test_panel.py | 87 +++++++++++++ tests/test_panel.py | 144 +++++++++++++++++++++ 7 files changed, 529 insertions(+) create mode 100644 docs/panel.md create mode 100644 lib/audioinstruments/panel.py create mode 100644 tests/mpy/test_panel.py create mode 100644 tests/test_panel.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8677140..4653d61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `audioinstruments` are recorded here. +## Unreleased + +- `audioinstruments.panel`: wrap an instrument in the adapter a control panel + edits through, with macros, patches, bypass, reload and MIDI changes that + reach the panel. See [docs/panel.md](docs/panel.md). + ## 0.5.0 The first release from this repository. It replaces the `audioinstruments` diff --git a/docs/overview.md b/docs/overview.md index e2a937e..51b2d4d 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -85,6 +85,13 @@ MIDI File, with the drums on channel 10. [docs/soundfont.md](soundfont.md) has the options, and `python examples/soundfont_demo.py` plays a short phrase from a bank in `~/soundfonts/`. +## A control panel + +`audioinstruments.panel.create("poly106", 48000)` wraps an instrument for a +control panel: macros on the MIDI scale, patch names, bypass and reload, and +a callback when a controller moves something. [docs/panel.md](panel.md) lists +what it has. + ## Loading only what you use `import audioinstruments._rack` loads the rack and its core and nothing else. A synth is diff --git a/docs/panel.md b/docs/panel.md new file mode 100644 index 0000000..368e8e9 --- /dev/null +++ b/docs/panel.md @@ -0,0 +1,47 @@ +# A control panel + +`audioinstruments.panel` wraps an instrument in the adapter a control panel +edits through: sixteen knobs on the MIDI scale, the patch list, bypass and +reload. It imports no panel, so any panel or app that reads these attributes +can drive an instrument. + +```python +import audioinstruments.panel as panel + +adapter = panel.create("poly106", 48000, voices=4) +audio_out.play(adapter.instrument.output) +adapter.set_patch(1) # what a patch pad does +adapter.set_macro(0, 96) # what an encoder does +adapter.note_on(60) # notes through the adapter respect bypass +``` + +`panel.create(name, sample_rate, channel_count=2, transport=None, **options)` +takes what `audioinstruments.create()` takes and remembers it for a reload. +`panel.PanelAdapter(inst, rebuild=None, name=None)` wraps an instrument you +already have; `rebuild` is a callable that returns a new one. Without it a +reload creates the same instrument by name with the default options. + +## What it has + +| | | +|---|---| +| `instrument` | The instrument it edits. A reload replaces it | +| `script_name` | The instrument's display name, such as `"Poly106"`, or `name` | +| `macro_labels`, `macro_count` | The instrument's macro names (up to 16) and how many there are | +| `macro_values` | A list of 16 ints, 0 to 127, read back from the instrument | +| `patches`, `patch_index` | The patch names in index order (up to 128) and the last one chosen | +| `bypass` | True while bypassed: the instrument is silenced and `note_on` is dropped | +| `engine_ready`, `engine_error`, `engine_message` | False during a reload; 1 and the reason after a failed one | +| `set_macro(i, v)`, `set_patch(i)` | Turn macro `i` to `v`; choose patch `i` and read its macros back | +| `set_bypass(enabled)` | Bypass on or off. On sends every note off | +| `request_reload()` | Build the instrument again with the same patch and macros. True when the new one is in place; on a failure the old one keeps playing | +| `on_external_change(cb)` | `cb("macro", i, v)` and `cb("patch", i, i)` for changes from outside | +| `on_reload(cb)` | `cb(new, old)` after a reload. Play `new.output`, then deinit `old` | +| `note_cc(i, v)` | A controller moved macro `i` to `v`: the instrument follows and so do the listeners | +| `note_program(i)` | A program change from outside: the instrument follows, and the listeners hear the patch and every macro it moved | +| `note_on(pitch, velocity=127, channel=0)`, `note_off(pitch, channel=0)` | The instrument's, with `note_on` dropped while bypassed | + +`note_cc` takes a macro index, not a controller number: which controller +moves which macro is the host's choice. A change made through `set_macro` or +`set_patch` is not reported to the listeners, since the panel made it. The +adapter's own code allocates nothing on a note. diff --git a/lib/audioinstruments/panel.py b/lib/audioinstruments/panel.py new file mode 100644 index 0000000..7b12cde --- /dev/null +++ b/lib/audioinstruments/panel.py @@ -0,0 +1,237 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""An instrument as a control panel sees it. + +``PanelAdapter(inst)`` wraps an instrument from ``audioinstruments.create()`` +in the adapter a control panel edits through: sixteen macro values on the +MIDI scale, the patch names, bypass, reload, and a callback for changes that +come from somewhere else, such as a MIDI controller. It imports no panel, so +anything with the same attributes can use it. + + import audioinstruments.panel as panel + + adapter = panel.create("poly106", 48000) + audio_out.play(adapter.instrument.output) + adapter.note_on(60) # dropped while bypassed + adapter.note_cc(0, 96) # a controller moved macro 0: listeners hear it + adapter.set_patch(1) # what a pad press does + +``request_reload()`` builds the instrument again and swaps it in, so play +``adapter.instrument.output`` again after a reload, or register +``on_reload(callback)`` to hear about it. +""" + +MACRO_COUNT = 16 +PATCH_COUNT = 128 + + +def create(name, sample_rate, channel_count=2, transport=None, **options): + """Create instrument ``name`` and wrap it. A reload builds it with the same options.""" + import audioinstruments + + def rebuild(): + return audioinstruments.create(name, sample_rate, channel_count, transport, **options) + + return PanelAdapter(rebuild(), rebuild=rebuild) + + +class PanelAdapter: + """What a control panel needs from ``instrument``, and nothing else. + + ``rebuild`` is a callable that returns a new instrument, used by + ``request_reload()``. Without one, a reload creates the same instrument + by name at the same sample rate and channel count, with default options. + ``name`` replaces the instrument's display name on the faceplate. + """ + + def __init__(self, instrument, rebuild=None, name=None): + self.instrument = instrument + self._rebuild = rebuild + self._listeners = [] + self._reload_listeners = [] + self.macro_values = [64] * MACRO_COUNT + self.patch_index = 0 + self.bypass = False + self.engine_ready = True + self.engine_error = 0 + self.engine_message = "" + patch = getattr(instrument, "patch", None) + self.script_name = name or getattr(patch, "DISPLAY_NAME", None) or _name(instrument) + self._read_declarations() + index = getattr(instrument, "patch_index", None) + if index is not None: + self.patch_index = index + self._refresh() + + # ---- what the instrument declares --------------------------------- + + def _read_declarations(self): + instrument = self.instrument + labels = [] + for text in getattr(instrument, "macro_labels", ()): + if len(labels) >= MACRO_COUNT: + break + labels.append(str(text)) + self.macro_labels = labels + self.macro_count = len(labels) + declared = getattr(instrument, "patches", None) + names = [] + if declared: + count = min(max(declared) + 1, PATCH_COUNT) + for index in range(count): + entry = declared.get(index) + names.append(str(entry[0]) if entry else "Patch %03d" % (index + 1)) + self.patches = names + + def _refresh(self): + """Read every macro back from the instrument, in place.""" + values = self.macro_values + get = self.instrument.get_macro + for index in range(self.macro_count): + values[index] = _midi(get(index)) + + # ---- what the panel tells the instrument -------------------------- + + def set_macro(self, index, value): + if not 0 <= index < self.macro_count: + return + value = _midi(value) + self.macro_values[index] = value + self.instrument.set_macro(index, value) + + def set_patch(self, index): + self.patch_index = index + self.instrument.program_change(index) + self._refresh() + + def set_bypass(self, enabled): + """Bypassed, the instrument is silenced and ``note_on`` is dropped.""" + self.bypass = bool(enabled) + if self.bypass: + self.instrument.all_notes_off() + + def request_reload(self): + """Build the instrument again and swap it in, keeping the patch and macros. + + Returns True when the new instrument is in place. On a failure the old + one keeps playing, ``engine_error`` is 1 and ``engine_message`` says why. + """ + self.engine_ready = False + old = self.instrument + new = None + try: + rebuild = self._rebuild + if rebuild is None: + rebuild = _default_rebuild(old) + new = rebuild() + self._restore(new) + except Exception as error: # noqa: BLE001 - any failure is reported, not raised + self.engine_error = 1 + self.engine_message = "%s: %s" % (type(error).__name__, error) + self.engine_ready = True + if new is not None: + try: + new.deinit() + except Exception: # noqa: BLE001,S110 - it never played + pass + return False + try: + old.all_notes_off() + except Exception: # noqa: BLE001,S110 - the old one may already be gone + pass + self.instrument = new + self._read_declarations() + self._refresh() + self.engine_error = 0 + self.engine_message = "" + self.engine_ready = True + for callback in self._reload_listeners: + callback(new, old) + return True + + def _restore(self, new): + """Give a rebuilt instrument the patch and macros the old one had.""" + if self.patch_index in (getattr(new, "patches", None) or {}): + new.program_change(self.patch_index) + count = min(self.macro_count, len(getattr(new, "macro_labels", ()))) + for index in range(count): + value = self.macro_values[index] + if _midi(new.get_macro(index)) != value: + new.set_macro(index, value) + + # ---- what the panel listens to ------------------------------------ + + def on_external_change(self, callback): + """Register ``callback(kind, index, value)``: ``("macro", i, v)`` or ``("patch", i, i)``.""" + self._listeners.append(callback) + + def on_reload(self, callback): + """Register ``callback(new, old)``, called after a reload swaps instruments. + + Play ``new.output`` in place of ``old.output``. The old instrument is + silenced but not deinitialised: deinit it once nothing pulls from it. + """ + self._reload_listeners.append(callback) + + def _notify(self, kind, index, value): + for callback in self._listeners: + callback(kind, index, value) + + # ---- the MIDI side ------------------------------------------------ + + def note_cc(self, index, value): + """A controller moved macro ``index`` to ``value`` (0-127): play it, tell the listeners.""" + if not 0 <= index < self.macro_count: + return + value = _midi(value) + self.instrument.set_macro(index, value) + if self.macro_values[index] != value: + self.macro_values[index] = value + self._notify("macro", index, value) + + def note_program(self, index): + """A program change from outside: play it, tell the listeners the patch and every macro it moved.""" + if not 0 <= index < PATCH_COUNT: + return + self.instrument.program_change(index) + if self.patch_index != index: + self.patch_index = index + self._notify("patch", index, index) + values = self.macro_values + get = self.instrument.get_macro + for macro in range(self.macro_count): + value = _midi(get(macro)) + if values[macro] != value: + values[macro] = value + self._notify("macro", macro, value) + + def note_on(self, pitch, velocity=127, channel=0): + """``instrument.note_on``, dropped while bypassed.""" + if self.bypass: + return + self.instrument.note_on(pitch, velocity, 0.0, channel) + + def note_off(self, pitch, channel=0): + self.instrument.note_off(pitch, channel) + + +def _midi(value): + value = int(value + 0.5) + return 0 if value < 0 else (127 if value > 127 else value) + + +def _name(instrument): + patch = getattr(instrument, "patch", None) + return getattr(patch, "NAME", None) or type(instrument).__name__ + + +def _default_rebuild(old): + name = getattr(getattr(old, "patch", None), "NAME", None) + if not name: + raise ValueError("no rebuild given, and the instrument has no NAME to build it by") + import audioinstruments + + def rebuild(): + return audioinstruments.create(name, old.sample_rate, old.channel_count) + + return rebuild diff --git a/tests/mpy/run.py b/tests/mpy/run.py index bddefb6..2ef3033 100644 --- a/tests/mpy/run.py +++ b/tests/mpy/run.py @@ -73,6 +73,7 @@ "test_rhythm909", "test_electronic_drums5", "test_face", + "test_panel", ) diff --git a/tests/mpy/test_panel.py b/tests/mpy/test_panel.py new file mode 100644 index 0000000..16b489b --- /dev/null +++ b/tests/mpy/test_panel.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""audioinstruments.panel on MicroPython: the contract, the MIDI path, bypass, reload.""" + +import gc + +import audioinstruments +from audioinstruments import panel + + +def test_contract_fields(): + for name in ("poly106", "rhythm808"): + face = audioinstruments.load(name) + adapter = panel.create(name, 48000) + assert adapter.script_name == face.DISPLAY_NAME + assert adapter.macro_labels == list(face.MACRO_LABELS) + assert adapter.macro_count == len(face.MACRO_LABELS) + assert len(adapter.macro_values) == 16 + assert adapter.patches[0] == face.PATCHES[0][0] + assert adapter.macro_values[: adapter.macro_count] == list(face.PATCHES[0][1]) + assert adapter.engine_ready and adapter.engine_error == 0 + adapter.instrument.deinit() + + +def test_external_changes_reach_listeners(): + adapter = panel.create("poly106", 48000, voices=2) + heard = [] + adapter.on_external_change(lambda kind, index, value: heard.append((kind, index, value))) + adapter.set_macro(0, 100) + assert adapter.instrument.get_macro(0) == 100.0 and heard == [] + adapter.note_cc(1, 7) + assert heard == [("macro", 1, 7)] + adapter.note_program(2) + assert heard[1] == ("patch", 2, 2) + assert adapter.macro_values[:10] == list(audioinstruments.load("poly106").PATCHES[2][1]) + adapter.instrument.deinit() + + +def test_bypass_and_notes(): + adapter = panel.create("poly106", 48000, voices=2) + adapter.note_on(60) + adapter.instrument.fill() + adapter.set_bypass(True) + adapter.note_on(64) # dropped + adapter.note_off(60) + adapter.set_bypass(False) + adapter.note_on(64) + adapter.note_off(64) + adapter.instrument.deinit() + + +def test_note_paths_allocate_nothing(): + adapter = panel.create("poly106", 48000, voices=2) + adapter.set_bypass(True) + adapter.note_on(60) # warm + gc.collect() + gc.disable() + try: + before = gc.mem_free() + for _ in range(20): + adapter.note_on(60) + after = gc.mem_free() + finally: + gc.enable() + assert before == after, before - after + adapter.instrument.deinit() + + +def test_reload(): + adapter = panel.create("poly106", 48000, voices=2) + adapter.set_patch(1) + adapter.set_macro(3, 55) + old = adapter.instrument + assert adapter.request_reload() + assert adapter.instrument is not old + assert adapter.instrument.get_macro(3) == 55.0 and adapter.patch_index == 1 + old.deinit() + + def broken(): + raise MemoryError("no room") + + adapter._rebuild = broken + keep = adapter.instrument + assert not adapter.request_reload() + assert adapter.instrument is keep + assert adapter.engine_error == 1 and "no room" in adapter.engine_message + keep.deinit() diff --git a/tests/test_panel.py b/tests/test_panel.py new file mode 100644 index 0000000..26fba31 --- /dev/null +++ b/tests/test_panel.py @@ -0,0 +1,144 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""audioinstruments.panel: an instrument as a control panel edits it.""" + +import audioinstruments +import pytest +from audioinstruments import panel + + +def listen(adapter): + heard = [] + adapter.on_external_change(lambda kind, index, value: heard.append((kind, index, value))) + return heard + + +@pytest.mark.parametrize("name", ["poly106", "rhythm808"]) +def test_the_contract_fields_come_from_the_instrument(name): + face = audioinstruments.load(name) + adapter = panel.create(name, 48000, voices=2 if name == "poly106" else None) + assert adapter.script_name == face.DISPLAY_NAME + assert adapter.macro_labels == list(face.MACRO_LABELS) + assert adapter.macro_count == len(face.MACRO_LABELS) + assert len(adapter.macro_values) == panel.MACRO_COUNT + assert all(isinstance(v, int) and 0 <= v <= 127 for v in adapter.macro_values) + assert adapter.patches == [face.PATCHES[i][0] for i in range(len(face.PATCHES))] + assert adapter.patch_index == 0 + assert adapter.macro_values[: adapter.macro_count] == list(face.PATCHES[0][1]) + assert adapter.bypass is False + assert adapter.engine_ready is True + assert adapter.engine_error == 0 + + +def test_a_macro_and_a_patch_from_the_panel_reach_the_instrument(): + adapter = panel.create("poly106", 48000, voices=2) + heard = listen(adapter) + adapter.set_macro(0, 100) + assert adapter.instrument.get_macro(0) == 100.0 + assert adapter.macro_values[0] == 100 + adapter.set_patch(1) + assert adapter.patch_index == 1 + assert adapter.instrument.patch_index == 1 + assert adapter.macro_values[:10] == list(audioinstruments.load("poly106").PATCHES[1][1]) + assert heard == [] # the panel made those changes; nothing echoes back + + +def test_a_controller_and_a_program_change_move_the_listeners(): + adapter = panel.create("poly106", 48000, voices=2) + heard = listen(adapter) + adapter.note_cc(2, 12) + assert heard == [("macro", 2, 12)] + assert adapter.instrument.get_macro(2) == 12.0 + adapter.note_cc(2, 12) # no change, no echo + assert len(heard) == 1 + del heard[:] + adapter.note_program(3) + assert heard[0] == ("patch", 3, 3) + patches = audioinstruments.load("poly106").PATCHES + before = list(patches[0][1]) + before[2] = 12 + target = patches[3][1] + moved = [("macro", m, v) for m, v in enumerate(target) if v != before[m]] + assert moved and heard[1:] == moved + assert adapter.macro_values[:10] == list(target) + assert adapter.patch_index == 3 + + +def test_bypass_silences_and_drops_notes(): + adapter = panel.create("poly106", 48000, voices=2) + adapter.set_bypass(True) + assert adapter.bypass is True + calls = [] + real = adapter.instrument.note_on + adapter.instrument.note_on = lambda *a: calls.append(a) + adapter.note_on(64) + assert calls == [] + adapter.set_bypass(False) + adapter.note_on(64, 100) + assert calls == [(64, 100, 0.0, 0)] + adapter.instrument.note_on = real + + +def test_bypass_calls_all_notes_off(): + adapter = panel.create("rhythm808", 48000) + offs = [] + adapter.instrument.all_notes_off = lambda: offs.append(1) + adapter.set_bypass(True) + assert offs == [1] + + +def test_a_reload_swaps_the_instrument_and_keeps_the_settings(): + adapter = panel.create("poly106", 48000, voices=2) + adapter.set_patch(2) + adapter.set_macro(1, 99) + old = adapter.instrument + swaps = [] + adapter.on_reload(lambda new, was: swaps.append((new, was))) + assert adapter.request_reload() is True + assert adapter.instrument is not old + assert swaps == [(adapter.instrument, old)] + assert adapter.instrument.voices == 2 # the options came along + assert adapter.instrument.get_macro(1) == 99.0 + assert adapter.macro_values[1] == 99 + assert adapter.patch_index == 2 + assert (adapter.engine_ready, adapter.engine_error, adapter.engine_message) == (True, 0, "") + + +def test_a_reload_without_a_rebuild_creates_by_name(): + adapter = panel.PanelAdapter(audioinstruments.create("rhythm808", 48000)) + old = adapter.instrument + assert adapter.request_reload() is True + assert adapter.instrument is not old + assert adapter.script_name == "Rhythm808" + + +def test_a_failed_reload_keeps_the_old_instrument_and_says_why(): + inst = audioinstruments.create("poly106", 48000, voices=2) + seen = [] + + def rebuild(): + seen.append(adapter.engine_ready) + raise RuntimeError("out of memory") + + adapter = panel.PanelAdapter(inst, rebuild=rebuild) + assert adapter.request_reload() is False + assert seen == [False] # not ready while it builds + assert adapter.instrument is inst + assert adapter.engine_error == 1 + assert adapter.engine_message == "RuntimeError: out of memory" + assert adapter.engine_ready is True + # the next good reload clears the error + adapter._rebuild = lambda: audioinstruments.create("poly106", 48000, voices=2) + assert adapter.request_reload() is True + assert adapter.engine_error == 0 + + +def test_out_of_range_indexes_are_ignored(): + adapter = panel.create("rhythm808", 48000) + heard = listen(adapter) + before = list(adapter.macro_values) + adapter.set_macro(15, 5) + adapter.note_cc(15, 5) + adapter.note_program(500) + assert adapter.macro_values == before + assert heard == []