diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dcd7ed0..fce3fcc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,14 +90,15 @@ micropython-pydevices' `build_mp.py` with the `vst3-engine` variant (no sockets, SSL or FFI; see [What the shipped engine cannot do](docs/how-it-works/security.md)) and this repository among its modules, by path. It carries audiocomponents' instruments and effects frozen in, pydevices (the editor's `appdev` and `multimer`), +the editor's panel (`controlpanel`), audiodsp's DSP, audioif, LVGL and ulab: ```bash ../micropython-pydevices/build_mp.py --port windows --variant vst3-engine \ - --modules audiocomponents,audiodsp,audioif,lvgl,pydevices,ulab,$PWD \ + --modules audiocomponents,audiodsp,audioif,controlpanel,lvgl,pydevices,ulab,$PWD \ ENGINE_ICON=$PWD/installer/art/mpvst.ico ../micropython-pydevices/build_mp.py --port unix --variant vst3-engine \ - --modules audiocomponents,audiodsp,audioif,lvgl,pydevices,ulab,$PWD + --modules audiocomponents,audiodsp,audioif,controlpanel,lvgl,pydevices,ulab,$PWD ``` It lands in `../micropython-pydevices/builds//vst3-engine/`, and the diff --git a/docs/get-started/first-program.md b/docs/get-started/first-program.md index 8958934..7e6d505 100644 --- a/docs/get-started/first-program.md +++ b/docs/get-started/first-program.md @@ -6,7 +6,7 @@ You can play a library instrument in two minutes, and run a script of your own i MPVST does not install as one plug-in. Every instrument and effect in the library appears in your FX browser under its own name and category. Search for **TR-808**, **Minimoog** or **Tape Delay** and drop it on a track like any other VST3. -Its editor has a patch selector, a bypass switch, an engine-status light and a slider per macro, labelled with whatever that instrument calls its knobs. Click a control to focus it, then scroll: sideways adjusts it, up and down move between controls. If your host provides no editor, its generic parameter editor reaches the same parameters. +Its editor has patch pads, a bypass button, an engine light and an encoder per macro, labelled with whatever that instrument calls its knobs. Drag an encoder up to turn it, or click a control to focus it, then scroll: sideways adjusts it, up and down move between controls. If your host provides no editor, its generic parameter editor reaches the same parameters. The names are not compiled in. Each is a two-line Python script the plug-in builds when you insert it, and your project saves that script as plain text. Adding an instrument is writing a script, not rebuilding anything. @@ -62,7 +62,7 @@ Each new instance reads that file. Toggle `Reload Script` off and on to re-read What each line is for: - `on_event` receives every note, pressure and pitch-bend event at its exact sample position, and `vstaudio.output` tells the plug-in which node to pull audio from. The full list of events is in the [script reference](../reference/script-api.md). -- `MACRO_LABELS` and `PATCHES` are the script's declarations to the editor. This script has no macros, so it declares none and the editor draws no sliders. +- `MACRO_LABELS` and `PATCHES` are the script's declarations to the editor. This script has no macros, so it declares none and the editor's encoders stay dark. - Notes are keyed by pitch here. A script that has to cope with overlapping notes on one pitch would key them by `note_id`, as the bundle's `default_instrument.py` does. ## Hear it without a DAW diff --git a/docs/guides/linux.md b/docs/guides/linux.md index da0f45f..1f8cd95 100644 --- a/docs/guides/linux.md +++ b/docs/guides/linux.md @@ -47,10 +47,11 @@ will leave the plug-in unable to start its engine. ## Controls -Identical to Windows, including the plug-in's own editor: a patch selector, a -Reload button, a Bypass switch, a status light, and a slider per macro under -whatever labels the script declares. Click a control to focus it, then scroll -or swipe - sideways adjusts it, up and down moves between controls. +Identical to Windows, including the plug-in's own editor: patch pads, FINE, +Bypass and Reload buttons, an engine light, and an encoder per macro under +whatever labels the script declares. Drag an encoder up to turn it, or click +a control to focus it, then scroll or swipe - sideways adjusts it, up and +down moves between controls. The editor is a plain X11 child window driven by the host's own run loop, so a host that provides no `IRunLoop` gets no editor and the generic parameter diff --git a/docs/guides/windows.md b/docs/guides/windows.md index dcfd29f..6a71c9b 100644 --- a/docs/guides/windows.md +++ b/docs/guides/windows.md @@ -19,11 +19,11 @@ not in the library yet. With no script pointed at it that slot produces no audio, so a slot you did not choose is obvious rather than sounding like an instrument you meant to load. -The plug-in has an editor of its own: a patch selector, a Reload button, a -Bypass switch, an engine-status light, and a slider per macro, labelled with -whatever names the script declares. Click a control to focus it, then scroll -or swipe - sideways adjusts the focused control, up and down moves between -them. A drag or a burst of scrolling is recorded as one automation edit. +The plug-in has an editor of its own: patch pads, FINE, Bypass and Reload +buttons, an engine light, and an encoder with an LED ring per macro, labelled +with whatever names the script declares. Drag an encoder up to turn it, or +click a control to focus it, then scroll or swipe - sideways adjusts the +focused control, up and down moves between them. A drag or a burst of scrolling is recorded as one automation edit. The same parameters are all there in the host's generic editor: `Bypass`, `Reload Script`, `Patch`, and `Macro 01` through `Macro 16` (or the script's diff --git a/docs/how-it-works/editor.md b/docs/how-it-works/editor.md index d0c5124..ebef5da 100644 --- a/docs/how-it-works/editor.md +++ b/docs/how-it-works/editor.md @@ -1,6 +1,6 @@ # The editor -Every instance can show a panel of its own: a patch selector, a bypass switch, a reload button, an engine-status light and a slider per macro, labelled with whatever the script declares. This page is how that panel works and what happens when it breaks. It is one generic panel; per-script panels, a shared knob widget, meters and resizable editors aren't built. +Every instance can show a panel of its own, laid out like a hardware instrument. It has sixteen patch pads in eight banks, an encoder with a ring of LEDs for each macro, a dot-matrix display, an engine light, and FINE, BYPASS and RELOAD buttons, labelled with whatever the script declares. This page is how that panel works and what happens when it breaks. It is one generic panel, the `controlpanel` package, frozen into the engine. Per-script panels, meters and resizable editors aren't built. ## Who draws what @@ -12,13 +12,13 @@ When no editor is open the engine does no UI work at all: no timers, no painting ## Input -The panel takes pointer and wheel input, not keyboard. Click a control to focus it. The wheel (or a trackpad swipe) acts on the focused control: sideways adjusts it, rightward increasing, and up and down move focus to the previous or next control. +The panel takes pointer and wheel input, not keyboard. Drag an encoder up or right to turn it up. Click a control to focus it: the wheel (or a trackpad swipe) acts on the focused control, sideways adjusting it (rightward increasing) and up and down moving focus to the previous or next control. Double-click an encoder to return it to the value the patch gave it. FINE makes drags and the wheel finer. A pad picks a patch; the bank buttons show another bank's pads. ## Gestures become automation -A slider drag is reported to your host as one begin, a stream of changes, and one end, so it records as a single automation edit. A burst of wheel notches on a focused control is one gesture too: it begins on the first notch and ends after a short quiet. The host echoes each change back through the normal parameter path, and the panel treats the host's value as the truth. +An encoder drag is reported to your host as one begin, a stream of changes, and one end, so it records as a single automation edit. A burst of wheel notches on a focused control is one gesture too: it begins on the first notch and ends after a short quiet. The host echoes each change back through the normal parameter path, and the panel treats the host's value as the truth. -The panel edits the patch selector, the 16 macros, bypass and reload. All of them are ordinary automatable parameters your host's generic editor shows as well. +The panel edits the patch, the 16 macros, bypass and reload. When the patch changes from the host, every encoder and LED ring moves to the new patch's macros, and nothing is sent back. All of them are ordinary automatable parameters your host's generic editor shows as well. ## When the panel fails diff --git a/docs/how-it-works/index.md b/docs/how-it-works/index.md index c7092ae..1626302 100644 --- a/docs/how-it-works/index.md +++ b/docs/how-it-works/index.md @@ -168,7 +168,7 @@ It does not interpret the widgets or reconstruct the UI. The seam is: [`usermods/vstui/modvstui.c`](../../usermods/vstui/modvstui.c) exposes the shared framebuffer, input, open-state, edit, and error operations. [`lib/mpvst_board_config.py`](../../lib/mpvst_board_config.py) adapts those operations to the normal PyDevices display contract, and [`lib/mpvst_editor.py`](../../lib/mpvst_editor.py) and -[`lib/mpvst_panel/`](../../lib/mpvst_panel/) implement the product panel. [The editor](editor.md) covers what the panel does and how it fails. +[`lib/mpvst_panel/`](../../lib/mpvst_panel/) connect the engine to the panel, the `controlpanel` package frozen into the engine. [The editor](editor.md) covers what the panel does and how it fails. ## Parameters, scripts, and state diff --git a/docs/reference/known-limitations.md b/docs/reference/known-limitations.md index eb9bd43..4b67b65 100644 --- a/docs/reference/known-limitations.md +++ b/docs/reference/known-limitations.md @@ -1,7 +1,7 @@ # Known limitations - There is no host-visible diagnostic string. Both editors show only the ready and error parameters; the bounded diagnostic text is available through the transport API. -- The editor is one generic panel. Per-script panels, a shared knob widget, meters, and resizable or zoomable editors are not built. +- The editor is one generic panel. Per-script panels, meters, and resizable or zoomable editors are not built. - State embeds one source file, not a dependency bundle. Imports must resolve in the sidecar's own MicroPython environment. - `MPVST_SCRIPT_PATH` is process-wide, so two instances that follow a developer file cannot follow different scripts; they re-read it on restart and on save. A project that needs per-instance scripts embeds them in state instead, which [`reaper/matrix/build_effect_project.py`](../../reaper/matrix/build_effect_project.py) shows by writing the chunks directly. - The 2,080 hidden MIDI parameters are standards-compliant and pass the VST3 validator, but their cost at scan and project load hasn't been measured in real DAWs. diff --git a/lib/mpvst_editor.py b/lib/mpvst_editor.py index f58430a..1d1f0c6 100644 --- a/lib/mpvst_editor.py +++ b/lib/mpvst_editor.py @@ -155,7 +155,9 @@ def _build_panel(self): group.remove_all_objs() self._adapter = EngineAdapter(vstui, self._script_path) vstaudio.observe(self._observe) - self._panel = build(self._adapter, group=group) + # The wheel mapping above moves focus with one wheel, so the panel + # keeps its encoders in edit mode for the other. + self._panel = build(self._adapter, group=group, two_wheels=True) vstui.error(vstui.ERROR_NONE) def _observe(self, event_type, channel, note_id, data0, value0, value1, diff --git a/lib/mpvst_panel/__init__.py b/lib/mpvst_panel/__init__.py index 71ad2c4..e9a6b68 100644 --- a/lib/mpvst_panel/__init__.py +++ b/lib/mpvst_panel/__init__.py @@ -1,11 +1,13 @@ -"""The editor's Python half: a generic panel and the adapter it edits through. +"""The editor's Python half: the panel and the adapter it edits through. -Kept host-neutral on purpose. `panel` imports only LVGL and `panel_adapter` imports -nothing at all from the engine, so the pair can graduate to a sibling repo as -a portable PyDevices example without surgery. It starts here. +The panel is `controlpanel`, a package of its own beside pydevices' other +LVGL code, frozen into the engine. This package keeps the half that knows +the engine: `panel_adapter.EngineAdapter`, which the panel edits through +and which imports nothing from the engine itself. """ +from controlpanel import Panel, build + from .panel_adapter import EngineAdapter -from .panel import Panel, build __all__ = ["EngineAdapter", "Panel", "build"] diff --git a/lib/mpvst_panel/panel.py b/lib/mpvst_panel/panel.py deleted file mode 100644 index 3d40e7d..0000000 --- a/lib/mpvst_panel/panel.py +++ /dev/null @@ -1,286 +0,0 @@ -"""The generic Tier-0 panel: everything an instance already declares. - -Nothing here is specific to one script. The labels, the patch list and the -engine status all come from the adapter, so the same panel edits a TR-808 and -a hand-written soundtrack patch. Per-script panels are a later, separate thing -and they replace this module rather than the surface under it. - -Only LVGL is imported. There is no `vstui` and no `vstaudio` in this file, -which is what lets it run on a desktop against a mock adapter. -""" - -import lvgl as lv - -BG = lv.color_hex(0x14181D) -PANEL_BG = lv.color_hex(0x1C2128) -TRACK = lv.color_hex(0x2A313B) -ACCENT = lv.color_hex(0x4FD1C5) -ACCENT_DIM = lv.color_hex(0x2C7A73) -TEXT = lv.color_hex(0xE6EDF3) -MUTED = lv.color_hex(0x8B96A5) -GOOD = lv.color_hex(0x3FB950) -BAD = lv.color_hex(0xF85149) - -MACRO_COUNT = 16 -_COLUMN_ROWS = 8 -_ROW_HEIGHT = 40 - - -class Panel: - """The built widget tree, and the handful of things that update it.""" - - def __init__(self, adapter, screen=None, group=None): - self.adapter = adapter - self.screen = screen if screen is not None else lv.screen_active() - self.group = group if group is not None else lv.group_get_default() - self._sliders = [] - self._value_labels = [] - self._patch_dropdown = None - self._status_dot = None - # True while a widget is being updated from engine state, so the - # VALUE_CHANGED that update provokes is not published straight back as - # a fresh edit. Without it every echo starts a new gesture and the two - # sides chase each other. - self._echoing = False - self._build() - adapter.on_external_change(self._apply_external) - - # ---- construction --------------------------------------------------- - - def _focus_and_edit(self, _event): - """FOCUSED handler shared by every editable control. - - LVGL's pointer handling already moves group focus to whatever was - clicked, but a focus change always drops the group out of edit mode - first - so setting editing on PRESSED is silently clobbered a moment - later by that same focus move. Doing it on FOCUSED runs after the - focus machinery has settled, so it sticks, and the wheel adjusts the - control that was clicked instead of navigating away from it. - """ - self.group.set_editing(True) - - def _build(self): - adapter = self.adapter - screen = self.screen - screen.set_style_bg_color(BG, 0) - screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) - screen.set_style_pad_all(0, 0) - screen.set_style_pad_row(0, 0) - screen.remove_flag(lv.obj.FLAG.SCROLLABLE) - - header = lv.obj(screen) - header.remove_style_all() - header.set_width(lv.pct(100)) - header.set_height(64) - header.set_style_bg_color(PANEL_BG, 0) - header.set_style_bg_opa(lv.OPA.COVER, 0) - header.set_style_pad_hor(14, 0) - header.set_style_pad_column(14, 0) - header.set_flex_flow(lv.FLEX_FLOW.ROW) - header.set_flex_align( - lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER - ) - header.remove_flag(lv.obj.FLAG.SCROLLABLE) - - title = lv.label(header) - title.set_text(adapter.script_name) - title.set_style_text_color(TEXT, 0) - title.set_flex_grow(1) - - # No selector at all when there is nothing to select. An effect has - # no patches, and neither does a script that never declared any. - if adapter.patches: - patch = lv.dropdown(header) - patch.set_options("\n".join(adapter.patches)) - patch.set_selected(min(adapter.patch_index, - len(adapter.patches) - 1)) - patch.set_width(190) - patch.add_event_cb(self._focus_and_edit, lv.EVENT.FOCUSED, None) - patch.add_event_cb(self._on_patch, lv.EVENT.VALUE_CHANGED, None) - self._patch_dropdown = patch - - reload_button = lv.button(header) - reload_button.set_style_bg_color(ACCENT_DIM, 0) - reload_label = lv.label(reload_button) - reload_label.set_text("Reload") - reload_label.set_style_text_color(TEXT, 0) - reload_label.center() - reload_button.add_event_cb(self._focus_and_edit, lv.EVENT.FOCUSED, None) - reload_button.add_event_cb(self._on_reload, lv.EVENT.CLICKED, None) - - bypass_row = lv.obj(header) - bypass_row.remove_style_all() - bypass_row.set_flex_flow(lv.FLEX_FLOW.ROW) - bypass_row.set_flex_align( - lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER - ) - bypass_row.set_style_pad_column(8, 0) - bypass_row.set_width(lv.SIZE_CONTENT) - bypass_row.set_height(lv.SIZE_CONTENT) - bypass_row.remove_flag(lv.obj.FLAG.SCROLLABLE) - - bypass_label = lv.label(bypass_row) - bypass_label.set_text("Bypass") - bypass_label.set_style_text_color(MUTED, 0) - - bypass = lv.switch(bypass_row) - if adapter.bypass: - bypass.add_state(lv.STATE.CHECKED) - bypass.add_event_cb(self._focus_and_edit, lv.EVENT.FOCUSED, None) - bypass.add_event_cb(self._on_bypass, lv.EVENT.VALUE_CHANGED, None) - self._bypass_switch = bypass - - dot = lv.obj(header) - dot.remove_style_all() - dot.set_size(14, 14) - dot.set_style_radius(lv.RADIUS_CIRCLE, 0) - dot.set_style_bg_opa(lv.OPA.COVER, 0) - dot.remove_flag(lv.obj.FLAG.SCROLLABLE) - dot.remove_flag(lv.obj.FLAG.CLICKABLE) - self._status_dot = dot - self.refresh_status() - - content = lv.obj(screen) - content.remove_style_all() - content.set_width(lv.pct(100)) - content.set_flex_grow(1) - content.set_flex_flow(lv.FLEX_FLOW.ROW) - content.set_style_pad_all(16, 0) - content.set_style_pad_column(28, 0) - content.remove_flag(lv.obj.FLAG.SCROLLABLE) - - # One column while they fit, two when they do not. Sixteen rows split - # evenly; four get the full width to themselves rather than leaving - # half the panel blank. - rows = adapter.macro_count - if rows == 0: - empty = lv.label(content) - empty.set_text("No macro controls - this plug-in declares none.") - empty.set_style_text_color(MUTED, 0) - empty.center() - else: - columns = [_column(content)] - per_column = rows - if rows > _COLUMN_ROWS: - per_column = (rows + 1) // 2 - columns.append(_column(content)) - for index in range(rows): - self._macro_row(columns[index // per_column], index) - - footer = lv.label(screen) - footer.set_text( - "Click a control, then swipe or scroll: sideways adjusts it, " - "up and down moves between controls." - ) - footer.set_style_text_color(MUTED, 0) - footer.set_style_pad_hor(14, 0) - footer.set_style_pad_ver(4, 0) - - def _macro_row(self, parent, index): - adapter = self.adapter - row = lv.obj(parent) - row.remove_style_all() - row.set_width(lv.pct(100)) - # A fixed height rather than an even share of the column: with four - # macros an even share is a 90-pixel row holding a 14-pixel slider. - row.set_height(_ROW_HEIGHT) - row.set_flex_flow(lv.FLEX_FLOW.ROW) - row.set_flex_align( - lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER - ) - row.set_style_pad_column(10, 0) - row.remove_flag(lv.obj.FLAG.SCROLLABLE) - - label = lv.label(row) - label.set_text(adapter.macro_labels[index]) - label.set_style_text_color(MUTED, 0) - label.set_width(136) - - value = adapter.macro_values[index] - slider = lv.slider(row) - slider.set_range(0, 127) - slider.set_value(value, 0) - slider.set_flex_grow(1) - slider.set_height(14) - slider.set_style_bg_color(TRACK, lv.PART.MAIN) - slider.set_style_bg_opa(lv.OPA.COVER, lv.PART.MAIN) - slider.set_style_radius(lv.RADIUS_CIRCLE, lv.PART.MAIN) - slider.set_style_bg_color(ACCENT, lv.PART.INDICATOR) - slider.set_style_bg_opa(lv.OPA.COVER, lv.PART.INDICATOR) - slider.set_style_radius(lv.RADIUS_CIRCLE, lv.PART.INDICATOR) - slider.set_style_bg_color(TEXT, lv.PART.KNOB) - slider.set_style_bg_opa(lv.OPA.COVER, lv.PART.KNOB) - slider.set_style_pad_all(0, lv.PART.KNOB) - slider.add_event_cb(self._focus_and_edit, lv.EVENT.FOCUSED, None) - - readout = lv.label(row) - readout.set_text(str(value)) - readout.set_style_text_color(TEXT, 0) - readout.set_width(32) - readout.set_style_text_align(lv.TEXT_ALIGN.RIGHT, 0) - - def on_change(_event, macro=index, widget=slider, out=readout): - current = widget.get_value() - out.set_text(str(current)) - if self._echoing: - return - self.adapter.set_macro(macro, current) - - slider.add_event_cb(on_change, lv.EVENT.VALUE_CHANGED, None) - self._sliders.append(slider) - self._value_labels.append(readout) - - # ---- events --------------------------------------------------------- - - def _on_patch(self, _event): - if self._echoing: - return - self.adapter.set_patch(self._patch_dropdown.get_selected()) - - def _on_bypass(self, _event): - if self._echoing: - return - self.adapter.set_bypass( - self._bypass_switch.has_state(lv.STATE.CHECKED) - ) - - def _on_reload(self, _event): - self.adapter.request_reload() - - def _apply_external(self, kind, index, value): - self._echoing = True - try: - # A macro or patch the panel does not draw can still be - # automated - the parameters are all still there - so an echo for - # one is ignored rather than an index error. - if kind == "macro" and index < len(self._sliders): - self._sliders[index].set_value(value, 0) - self._value_labels[index].set_text(str(value)) - elif (kind == "patch" and self._patch_dropdown is not None - and index < len(self.adapter.patches)): - self._patch_dropdown.set_selected(value) - finally: - self._echoing = False - - def refresh_status(self): - """Repaint the engine-status dot from the adapter.""" - if self._status_dot is None: - return - healthy = self.adapter.engine_ready and self.adapter.engine_error == 0 - self._status_dot.set_style_bg_color(GOOD if healthy else BAD, 0) - - -def _column(parent): - column = lv.obj(parent) - column.remove_style_all() - column.set_flex_grow(1) - column.set_height(lv.pct(100)) - column.set_flex_flow(lv.FLEX_FLOW.COLUMN) - column.set_style_pad_row(4, 0) - column.remove_flag(lv.obj.FLAG.SCROLLABLE) - return column - - -def build(adapter, screen=None, group=None): - """Build the panel and return it.""" - return Panel(adapter, screen=screen, group=group) diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 9c0c36f..b9594b7 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -17,7 +17,7 @@ die() { log "ERROR: $*"; exit 1; } build_mp="$repo_dir/../micropython-pydevices/build_mp.py" [[ -x "$build_mp" ]] || die "no micropython-pydevices checkout beside this one (clone PyDevices/micropython-pydevices next to it)" # The engine's modules: CONTRIBUTING.md says what each one is for. -engine_modules="audiocomponents,audiodsp,audioif,lvgl-micropython,pydevices,ulab,$repo_dir" +engine_modules="audiocomponents,audiodsp,audioif,controlpanel,lvgl-micropython,pydevices,ulab,$repo_dir" log "fetching VST3 SDK" "$repo_dir/scripts/fetch-vst3-sdk.sh" || die "fetch-vst3-sdk.sh failed" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 410327a..88bb500 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -69,7 +69,15 @@ if(TARGET mpvst-stage-micropython-engine) # mpvst_editor_smoke covers it, through the real one. add_test(NAME mpvst_engine_modules COMMAND $/${mpvst_engine_name} - -c "import lvgl, vstaudio, vstui, displaydev, appdev, multimer, events") + -c "import lvgl, vstaudio, vstui, displaydev, appdev, multimer, events, controlpanel") + + # The editor's panel follows a host's patch change: every encoder and LED + # ring moves to the patch's macros, and nothing is sent back. The real + # EngineAdapter and panel, on a display with no window. + add_test(NAME mpvst_panel_follow COMMAND + $/${mpvst_engine_name} + ${CMAKE_CURRENT_SOURCE_DIR}/panel_follow.py + $) # The engine is prebuilt and every later build copies it in without asking # how old it is, so the suites below can certify a DSP core that is not the diff --git a/tests/panel_follow.py b/tests/panel_follow.py new file mode 100644 index 0000000..521952b --- /dev/null +++ b/tests/panel_follow.py @@ -0,0 +1,88 @@ +"""The editor's panel follows a patch change from the host. + +Run by the engine itself (ctest mpvst_panel_follow), with the staged bundle +directory as its argument: the real EngineAdapter, the real panel, LVGL on +a display with no window, and a stub where vstui would be. A host program +change must move every encoder and every LED ring to the patch's macros, +and publish nothing back to the host. + + mpvst-engine tests/panel_follow.py +""" + +import sys + +sys.path.insert(0, sys.argv[1]) + +import lvgl as lv + +import mpvst_script_namespace + +PATCHES = { + 0: ("Init", [64] * 16), + 1: ("Bright", [10 * i for i in range(13)] + [127, 0, 5]), + 2: ("Dark", [127 - 7 * i for i in range(16)]), +} +mpvst_script_namespace.namespace = { + "NAME": "follow-probe", + "MACRO_LABELS": tuple("Macro %d" % (i + 1) for i in range(16)), + "PATCHES": PATCHES, +} + + +class StubUi: + EDIT_BEGIN = 0 + EDIT_PERFORM = 1 + EDIT_END = 2 + + def __init__(self): + self.edits = [] + + def edit(self, kind, parameter, value): + self.edits.append((kind, parameter, value)) + + +lv.init() +frame = bytearray(800 * 480 * 2) +display = lv.display_create(800, 480) +display.set_color_format(lv.COLOR_FORMAT.RGB565) +display.set_flush_cb(lambda d, area, pixels: d.flush_ready()) +display.set_buffers(frame, None, len(frame), lv.DISPLAY_RENDER_MODE.DIRECT) + +from mpvst_panel import EngineAdapter, build # noqa: E402 + +ui = StubUi() +adapter = EngineAdapter(ui, ticks_ms=lambda: 0) +panel = build(adapter, group=lv.group_create()) +lv.refr_now(display) + +failures = [] + + +def check(name, ok, why=""): + print(("PASS " if ok else "FAIL ") + name + ("" if ok else ": " + why)) + if not ok: + failures.append(name) + + +def lit(enc): + return sum(1 for level in enc.lit() if level) + + +def expected_lit(value): + return sum(1 for i in range(16) if value * 16 / 127 - i >= 24 / 255) + + +check("opens on patch 0", [e.value for e in panel.encoders] == PATCHES[0][1]) +for program in (1, 2, 0): + adapter.note_program(program) + lv.refr_now(display) + values = PATCHES[program][1] + shown = [e.value for e in panel.encoders] + check("host program %d: every encoder follows" % program, shown == values, "%s != %s" % (shown, values)) + rings = [lit(e) for e in panel.encoders] + check("host program %d: every ring follows" % program, rings == [expected_lit(v) for v in values], str(rings)) + check("host program %d: its pad is lit" % program, panel.pads[program][3] == "lit") +check("nothing went back to the host", ui.edits == [], str(ui.edits)) + +print("RESULT " + ("FAIL" if failures else "OK")) +sys.exit(1 if failures else 0) diff --git a/tests/smoke_host/main.cpp b/tests/smoke_host/main.cpp index 4ab028a..10e4f21 100644 --- a/tests/smoke_host/main.cpp +++ b/tests/smoke_host/main.cpp @@ -1841,24 +1841,29 @@ bool editorDrivesParameters(const PluginFactory& factory, } (void)view.drainEdits(); - // Click the first macro slider, then swipe right along it. Both signs are - // asserted, because a sign is a fact about this input path and nothing - // else in the build would notice if it flipped. - constexpr std::int32_t kSliderX = 250; - constexpr std::int32_t kSliderY = 101; - view.push(MPVST_UI_INPUT_POINTER_MOVE, 0U, kSliderX, kSliderY, 0, 0); - view.push(MPVST_UI_INPUT_POINTER_DOWN, 1U, kSliderX, kSliderY, 0, 0); - view.push(MPVST_UI_INPUT_POINTER_UP, 0U, kSliderX, kSliderY, 0, 0); + // Click the first macro's encoder, then swipe right. The probe declares + // no patches, so the encoders span the panel and the first one's knob is + // at (118, 168) on the 800 x 480 panel. A click only focuses an encoder: + // it must not move it. Both swipe signs are asserted, because a sign is a + // fact about this input path and nothing else in the build would notice + // if it flipped. + constexpr std::int32_t kKnobX = 118; + constexpr std::int32_t kKnobY = 168; + view.push(MPVST_UI_INPUT_POINTER_MOVE, 0U, kKnobX, kKnobY, 0, 0); + view.push(MPVST_UI_INPUT_POINTER_DOWN, 1U, kKnobX, kKnobY, 0, 0); + view.push(MPVST_UI_INPUT_POINTER_UP, 0U, kKnobX, kKnobY, 0, 0); if (!pump(30)) return false; auto edits = view.drainEdits(); - if (edits.empty() || std::get<1>(edits.front()) != 100U) + if (!edits.empty()) { - std::cerr << "HOOK editor.click FAIL: no edit on the first macro\n"; + std::cerr << "HOOK editor.click FAIL: a click moved parameter " + << std::get<1>(edits.front()) << '\n'; return false; } - const auto afterClick = std::get<2>(edits.back()); - std::cout << "HOOK editor.click OK: macro 100 = " << afterClick << '\n'; + // A fresh instance with no patches opens every macro at 64. + const auto afterClick = 64.0F / 127.0F; + std::cout << "HOOK editor.click OK: the first encoder has focus, nothing moved\n"; for (int notch = 0; notch < 5; ++notch) view.push(MPVST_UI_INPUT_WHEEL, 0U, 0, 0, 0, MPVST_UI_WHEEL_NOTCH);