From d7870cd1d67a84279ed9bdb0674b299fdfaad802 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Wed, 21 Oct 2026 05:18:11 +0200 Subject: [PATCH 1/4] docs/zynthian: the Zynthian chain written as a strip profile, a parallel slot as fan-out and an explicit mix --- docs/zynthian/chain-profile.md | 126 +++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/zynthian/chain-profile.md diff --git a/docs/zynthian/chain-profile.md b/docs/zynthian/chain-profile.md new file mode 100644 index 00000000..ac998fec --- /dev/null +++ b/docs/zynthian/chain-profile.md @@ -0,0 +1,126 @@ +The Zynthian chain as a strip profile +===================================== + +The strip model, as OpenMixer's strip declaration states it: a strip is a sorted chain of elements operating on +sound, its source is `live`, `synth` or `sum`, control-domain input belongs to the source and sits outside the chain, +and a profile states what one kind of strip carries. This page writes one Zynthian chain in those terms. Every fact is read from zynthian-ui `f46b0189b616` +(the commit `zynthian_engine_plugind.patch` applies to); `chain.py` is `zyngine/zynthian_chain.py`, `manager.py` is +`zyngine/zynthian_chain_manager.py`, `processor.py` is `zyngine/zynthian_processor.py`, `autoconnect.py` is +`zynautoconnect/zynthian_autoconnect.py`. + +What a chain holds +------------------ + +- Three lists of slots: `midi_slots`, `synth_slots` ("should be single slot") and `audio_slots`; each slot is a list of + PARALLEL processors (`chain.py:57-61`). +- "Processors receive audio/MIDI input from the previous slot. Processors in first slot use the chain's audio/MIDI + input. An empty chain with audio input configured is pass-through" (`chain.py:43-49`); the switches are `midi_thru` + and `audio_thru` (`chain.py:67-68`). +- A processor goes to a list by its engine's `TYPE` (`processor.py:63`): `MIDI Tool` to `midi_slots`, `Audio Effect` + to `audio_slots`, any other type (`MIDI Synth`, `Audio Generator`, `Special`) to `synth_slots` + (`get_slots_by_type`, `chain.py:120-132`). +- The mixer channel strip is a processor of the audio list: `MI` (and `MR` for a mixbus) is an `Audio Effect` + (`manager.py:149-178`), added after the chain's first processor (`zyngui/zynthian_gui.py:1070-1076`); a new effect is + inserted just before it, pre-fader (`zyngui/zynthian_gui_chain_options.py:192-199`). Effects after it are post-fader. + +The profile +----------- + +| Field | Zynthian chain | Read from | +|---|---|---| +| source | `synth` when `synth_slots` holds a processor: its JACK client feeds the first audio slot. `live` when it does not and `audio_thru` is set: the chain's capture pairs feed it. `sum` for a mixbus chain (`MR`): the mixer's send `zynmixer_chan:send_` feeds it | `chain.py:322-333` | +| source, one generator | adding a `MIDI Synth` to a chain that has one removes the old one ("Cannot have multiple synth engines") | `manager.py:1101-1104` | +| control domain | `midi_slots`, outside the audio chain: slot `i` takes the MIDI of slot `i-1`, slot 0 the chain's input (autoconnect); the last MIDI slot feeds the synth's MIDI input; with no MIDI slot and `midi_thru`, the chain's MIDI input does | `chain.py:386-418` | +| control domain, to effects | the same MIDI feeds every audio processor that is not `MI`/`MR` | `chain.py:419-424` | +| chain | `audio_slots` in order: each slot's processors take the previous slot's outputs | `chain.py:335-347` | +| tail boundary | the last slot's outputs go to the chain's outputs (`audio_out`, default `[0]`, the main mixbus; chain 0 the first two playback ports) | `chain.py:356-360`, `chain.py:91-106` | +| fixed element | the mixer strip `MI`/`MR` (fader, balance, mute, solo, sends) sits in `audio_slots`, so the pre/post-fader split is a position in the list, not a boundary of it | `manager.py:149-178`, `zyngui/zynthian_gui_chain_options.py:192-199` | +| pass-through | an empty audio list with `audio_thru` routes the input pairs straight to the outputs (`prev_slot_sources` stays the first sources) | `chain.py:331-333`, `chain.py:359-360` | +| placement | where a processor runs is its engine's: jalv one process per LV2 plugin (`zynthian_engine_jalv.py:84-111`), mod-ui one `Special` processor (`zynthian_engine_modui.py:67`, client `mod-host`, `:92-93`) holding a whole pedalboard, plugind one instance in plugin-hostd (`effect_`) | as cited | +| realisation | the chain builds `audio_routes` (destination to sources, `chain.py:320-360`); `zynautoconnect` turns them into JACK links, pairing ports two at a time with mono spread (`autoconnect.py:1094-1098`), and disconnects only links it made itself (`zyn_routed_audio`, `autoconnect.py:1163-1186`) | as cited | + +Verbs on the chain +------------------ + +| Verb | Where | What it does to the slots | +|---|---|---| +| add at a position | `manager.add_processor(chain_id, eng_code, slot)` (`manager.py:1060-1135`) → `chain.insert_processor` (`chain.py:639-680`) | always a NEW slot: `None` appends, `< 0` inserts first, past the end appends, else inserts at `slot`; the parallel branch is commented out (`chain.py:669-670`); the engine is started after the insert (`manager.py:1106`), then the graph is rebuilt (`manager.py:1121-1125`) | +| remove | `manager.remove_processor` (`manager.py:1216-1270`) → `chain.remove_processor` (`chain.py:705-746`) | the processor leaves its slot, an emptied slot is dropped (`chain.py:722-725`), the graph is rebuilt (`chain.py:731`); unused engines stop after (`manager.py:1250-1251`) | +| move up/down | `manager.nudge_processor` (`manager.py:1155-1180`) → `chain.nudge_processor` (`chain.py:758-804`) | moving a single processor past its neighbour MERGES it into the neighbour's slot (`chain.py:782`, `chain.py:799`); moving a parallel one out makes it a slot of its own (`chain.py:771-777`, `:793-795`); the mixer strip moves slot by slot (`chain.py:767-770`); the main output is muted while the audio graph changes (`manager.py:1172-1177`) | +| move to another chain | `manager.nudge_processor` left/right (`manager.py:1181-1213`) | remove from one chain, append to the other's list | +| swap | `chain.swap_processors` (`chain.py:806-836`) | its guard `processor1.type != processor2.type or processor1.type` refuses every typed processor, so it is unreachable for effects at this commit | +| restore | `manager.set_chain_state` (`manager.py:1579-1595`) | the saved slots are read processor by processor and each is added as a new slot (the code's own TODO, `:1590-1591`): a saved parallel slot comes back in series | + +Limits +------ + +- One synth: `manager.py:1101-1104`. +- No limit on the number of audio or MIDI slots is enforced in these files. +- Movable: a synth or generator never (`manager.py:1137-1153`). +- Through plugind: instances are numbered by the engine, lowest free first (`zynthian_engine_plugind.py`, `__init__`); + the daemon's own limits (`add` beyond `MAX_INSTANCE` answers `-1`, `tests/daemon_test.py` placement test) apply. + +Parallel slots: fan-out and an explicit mix +------------------------------------------- + +What Zynthian does today: every processor of a slot gets the previous slot's sources (`chain.py:342-346`, the same +`prev_slot_sources` for each member: a fan-out), and the NEXT slot, or the chain's outputs, gets the list of all +members as its sources (`chain.py:347`, `:359-360`). autoconnect then connects every member's output ports to the same +input ports of the next destination (`autoconnect.py:1089-1097`), and JACK sums them at that input. The mix is +implicit: unity gain per branch, no level, no bypass, nothing to read back. + +In the strip profile a parallel slot is neither a pool (a SET of instances in one process, no order) nor a chain (a +series). It is a third thing: a FAN-OUT of the previous element's output to every member's input, then an explicit +MIX ELEMENT that sums the members' outputs, whose output feeds the next slot. No input port is ever fed by more than +one source. + +| Mix element field | Value | +|---|---| +| role | `mix` | +| inputs | N branches, one per member, in slot order, each a stereo pair of the mixer's own input ports | +| gain per branch | declared; default 1.0 per branch, which is what Zynthian's implicit sum does today (unity per connected output, `autoconnect.py:1089-1097`); not 1/N, since that would change the level of every existing parallel slot | +| bypass | the mixer instance's own mod-host `bypass` | +| latency | the maximum of the branch latencies; the protocol has no latency verb on plugin-hostd's main, so the value is unknown (None) and no branch is delay-compensated | +| realisation | one plugin-hostd instance of an N-input mixer plugin, declared to the engine by `PLUGIND_MIX` (its URI, branch count, port and gain names) | + +No N-input mixer is installed on the machine this was written on (`/usr/lib64/lv2` holds only the LV2 spec bundles, and +there is no CLAP directory), and the omx-plugins +catalogue (23 plugins, `plugins/` on its `main`, 2026-10-21) holds none: `omx-strip` is a channel strip, the +rest are effects and test plugins. omx-dsp has the summing kernel (`include/omxdsp/mix/omx_bus_sum.h`) but no plugin +around it. So the element and its link plan are defined and tested, and the realisation is refused with +`MIX_ELEMENT_ABSENT` when no mixer is declared; the refusal happens before the slot is formed (a nudge that would +merge two processors answers False and leaves the chain as it was). + +Boundaries of the plugind strip +------------------------------- + +The plugind strip (`zyngine/zynthian_plugind_strip.py`, in `zynthian_engine_plugind.patch`) owns the links between +two ADJACENT audio stages that are both plugin-hostd's: a stage is one processor, or a parallel slot with its mix +element. It makes them with mod-host's `connect` and takes them down with `disconnect` before an instance is removed. +Inside a parallel slot it owns every member-to-mix link. Every other link stays the chain's and autoconnect's: the +source into the first stage, a plugind stage next to the mixer strip or to another engine's processor, and the last +stage into the outputs; for those the strip only rewrites `audio_routes` so that a destination after a parallel slot +is fed by the mix element and never by the members. A parallel slot holding a processor that is not plugin-hostd's is +refused with `PARALLEL_SLOT_FOREIGN`, and one with more members than the declared mixer has branches with +`MIX_BRANCHES_EXCEEDED`. + +Open points +----------- + +- **Parallel slots in the strip model.** The strip model of the openmixer draft has no parallel element yet; this page + proposes fan-out plus a `mix` element as the third arrangement beside pool and chain. Whether the draft takes it, + and how a mix element's branch gains are presented, is not decided here. The only way a parallel slot forms at this + commit is a nudge into a neighbour (`chain.py:782`, `:799`); a restore flattens it (`manager.py:1590-1591`), so a + saved parallel slot does not survive a reload in Zynthian itself. A new mix element starts at the declared gains: a + member added to or taken from a slot gives the slot a new mixer instance, and branch gains set by hand are not + carried over. +- **Latency of a mix.** Branches of unequal latency are summed as they come; nothing compensates them. +- **Who owns a link.** autoconnect disconnects a link only if it once made it (`autoconnect.py:1176-1181`); a link + between two plugind instances must never be made by autoconnect, or a later pass may take the strip's link down. The + patch drops those destinations from `audio_routes` before autoconnect reads them; whether a link made by autoconnect + before the patch took effect (an engine swapped live) can linger in `zyn_routed_audio` is not tested here. +- **The mixer strip as an element.** `MI`/`MR` is a processor in the list, not a fixed boundary; in the strip model it + would be the `fader` element with a fixed position. Zynthian lets it move slot by slot (`chain.py:767-770`). Not + mapped. +- **MIDI to effects.** Every audio processor gets the chain's MIDI (`chain.py:419-424`); in the strip model that is + control-domain input reaching chain elements, which the draft spec places with the source. Not mapped. From 28b1766d6542f9671c045e89d98b62218108077d Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Wed, 21 Oct 2026 05:20:01 +0200 Subject: [PATCH 2/4] docs/zynthian: what the jalv and mod-ui engines give a chain against Path B, and chain_manager lines read at the pinned commit --- docs/zynthian/chain-profile.md | 24 ++++++++++---------- docs/zynthian/engine-gaps.md | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 docs/zynthian/engine-gaps.md diff --git a/docs/zynthian/chain-profile.md b/docs/zynthian/chain-profile.md index ac998fec..e0cf7711 100644 --- a/docs/zynthian/chain-profile.md +++ b/docs/zynthian/chain-profile.md @@ -20,7 +20,7 @@ What a chain holds to `audio_slots`, any other type (`MIDI Synth`, `Audio Generator`, `Special`) to `synth_slots` (`get_slots_by_type`, `chain.py:120-132`). - The mixer channel strip is a processor of the audio list: `MI` (and `MR` for a mixbus) is an `Audio Effect` - (`manager.py:149-178`), added after the chain's first processor (`zyngui/zynthian_gui.py:1070-1076`); a new effect is + (`manager.py:143-172`), added after the chain's first processor (`zyngui/zynthian_gui.py:1070-1076`); a new effect is inserted just before it, pre-fader (`zyngui/zynthian_gui_chain_options.py:192-199`). Effects after it are post-fader. The profile @@ -29,14 +29,14 @@ The profile | Field | Zynthian chain | Read from | |---|---|---| | source | `synth` when `synth_slots` holds a processor: its JACK client feeds the first audio slot. `live` when it does not and `audio_thru` is set: the chain's capture pairs feed it. `sum` for a mixbus chain (`MR`): the mixer's send `zynmixer_chan:send_` feeds it | `chain.py:322-333` | -| source, one generator | adding a `MIDI Synth` to a chain that has one removes the old one ("Cannot have multiple synth engines") | `manager.py:1101-1104` | +| source, one generator | adding a `MIDI Synth` to a chain that has one removes the old one ("Cannot have multiple synth engines") | `manager.py:1095-1098` | | control domain | `midi_slots`, outside the audio chain: slot `i` takes the MIDI of slot `i-1`, slot 0 the chain's input (autoconnect); the last MIDI slot feeds the synth's MIDI input; with no MIDI slot and `midi_thru`, the chain's MIDI input does | `chain.py:386-418` | | control domain, to effects | the same MIDI feeds every audio processor that is not `MI`/`MR` | `chain.py:419-424` | | chain | `audio_slots` in order: each slot's processors take the previous slot's outputs | `chain.py:335-347` | | tail boundary | the last slot's outputs go to the chain's outputs (`audio_out`, default `[0]`, the main mixbus; chain 0 the first two playback ports) | `chain.py:356-360`, `chain.py:91-106` | -| fixed element | the mixer strip `MI`/`MR` (fader, balance, mute, solo, sends) sits in `audio_slots`, so the pre/post-fader split is a position in the list, not a boundary of it | `manager.py:149-178`, `zyngui/zynthian_gui_chain_options.py:192-199` | +| fixed element | the mixer strip `MI`/`MR` (fader, balance, mute, solo, sends) sits in `audio_slots`, so the pre/post-fader split is a position in the list, not a boundary of it | `manager.py:143-172`, `zyngui/zynthian_gui_chain_options.py:192-199` | | pass-through | an empty audio list with `audio_thru` routes the input pairs straight to the outputs (`prev_slot_sources` stays the first sources) | `chain.py:331-333`, `chain.py:359-360` | -| placement | where a processor runs is its engine's: jalv one process per LV2 plugin (`zynthian_engine_jalv.py:84-111`), mod-ui one `Special` processor (`zynthian_engine_modui.py:67`, client `mod-host`, `:92-93`) holding a whole pedalboard, plugind one instance in plugin-hostd (`effect_`) | as cited | +| placement | where a processor runs is its engine's: jalv one process per LV2 plugin (`zynthian_engine_jalv.py:84-98`), mod-ui one `Special` processor (`zynthian_engine_modui.py:67`, client `mod-host`, `:92-93`) holding a whole pedalboard, plugind one instance in plugin-hostd (`effect_`) | as cited | | realisation | the chain builds `audio_routes` (destination to sources, `chain.py:320-360`); `zynautoconnect` turns them into JACK links, pairing ports two at a time with mono spread (`autoconnect.py:1094-1098`), and disconnects only links it made itself (`zyn_routed_audio`, `autoconnect.py:1163-1186`) | as cited | Verbs on the chain @@ -44,19 +44,19 @@ Verbs on the chain | Verb | Where | What it does to the slots | |---|---|---| -| add at a position | `manager.add_processor(chain_id, eng_code, slot)` (`manager.py:1060-1135`) → `chain.insert_processor` (`chain.py:639-680`) | always a NEW slot: `None` appends, `< 0` inserts first, past the end appends, else inserts at `slot`; the parallel branch is commented out (`chain.py:669-670`); the engine is started after the insert (`manager.py:1106`), then the graph is rebuilt (`manager.py:1121-1125`) | -| remove | `manager.remove_processor` (`manager.py:1216-1270`) → `chain.remove_processor` (`chain.py:705-746`) | the processor leaves its slot, an emptied slot is dropped (`chain.py:722-725`), the graph is rebuilt (`chain.py:731`); unused engines stop after (`manager.py:1250-1251`) | -| move up/down | `manager.nudge_processor` (`manager.py:1155-1180`) → `chain.nudge_processor` (`chain.py:758-804`) | moving a single processor past its neighbour MERGES it into the neighbour's slot (`chain.py:782`, `chain.py:799`); moving a parallel one out makes it a slot of its own (`chain.py:771-777`, `:793-795`); the mixer strip moves slot by slot (`chain.py:767-770`); the main output is muted while the audio graph changes (`manager.py:1172-1177`) | -| move to another chain | `manager.nudge_processor` left/right (`manager.py:1181-1213`) | remove from one chain, append to the other's list | +| add at a position | `manager.add_processor(chain_id, eng_code, slot)` (`manager.py:1054-1129`) → `chain.insert_processor` (`chain.py:639-680`) | always a NEW slot: `None` appends, `< 0` inserts first, past the end appends, else inserts at `slot`; the parallel branch is commented out (`chain.py:669-670`); the engine is started after the insert (`manager.py:1100`), then the graph is rebuilt (`manager.py:1118-1121`) | +| remove | `manager.remove_processor` (`manager.py:1210-1264`) → `chain.remove_processor` (`chain.py:705-746`) | the processor leaves its slot, an emptied slot is dropped (`chain.py:722-725`), the graph is rebuilt (`chain.py:731`); unused engines stop after (`manager.py:1250-1251`) | +| move up/down | `manager.nudge_processor` (`manager.py:1149-1174`) → `chain.nudge_processor` (`chain.py:758-804`) | moving a single processor past its neighbour MERGES it into the neighbour's slot (`chain.py:782`, `chain.py:799`); moving a parallel one out makes it a slot of its own (`chain.py:771-777`, `:793-795`); the mixer strip moves slot by slot (`chain.py:767-770`); the main output is muted while the audio graph changes (`manager.py:1165-1171`) | +| move to another chain | `manager.nudge_processor` left/right (`manager.py:1175-1207`) | remove from one chain, append to the other's list | | swap | `chain.swap_processors` (`chain.py:806-836`) | its guard `processor1.type != processor2.type or processor1.type` refuses every typed processor, so it is unreachable for effects at this commit | -| restore | `manager.set_chain_state` (`manager.py:1579-1595`) | the saved slots are read processor by processor and each is added as a new slot (the code's own TODO, `:1590-1591`): a saved parallel slot comes back in series | +| restore | `manager.set_chain_state` (`manager.py:1573-1589`) | the saved slots are read processor by processor and each is added as a new slot (the code's own TODO, `:1590-1591`): a saved parallel slot comes back in series | Limits ------ -- One synth: `manager.py:1101-1104`. +- One synth: `manager.py:1095-1098`. - No limit on the number of audio or MIDI slots is enforced in these files. -- Movable: a synth or generator never (`manager.py:1137-1153`). +- Movable: a synth or generator never (`manager.py:1131-1147`). - Through plugind: instances are numbered by the engine, lowest free first (`zynthian_engine_plugind.py`, `__init__`); the daemon's own limits (`add` beyond `MAX_INSTANCE` answers `-1`, `tests/daemon_test.py` placement test) apply. @@ -110,7 +110,7 @@ Open points - **Parallel slots in the strip model.** The strip model of the openmixer draft has no parallel element yet; this page proposes fan-out plus a `mix` element as the third arrangement beside pool and chain. Whether the draft takes it, and how a mix element's branch gains are presented, is not decided here. The only way a parallel slot forms at this - commit is a nudge into a neighbour (`chain.py:782`, `:799`); a restore flattens it (`manager.py:1590-1591`), so a + commit is a nudge into a neighbour (`chain.py:782`, `:799`); a restore flattens it (`manager.py:1584-1585`), so a saved parallel slot does not survive a reload in Zynthian itself. A new mix element starts at the declared gains: a member added to or taken from a slot gives the slot a new mixer instance, and branch gains set by hand are not carried over. diff --git a/docs/zynthian/engine-gaps.md b/docs/zynthian/engine-gaps.md new file mode 100644 index 00000000..9bbbc337 --- /dev/null +++ b/docs/zynthian/engine-gaps.md @@ -0,0 +1,40 @@ +What the jalv and mod-ui engines give a chain, and what Path B gives +=================================================================== + +The two engines plugind replaces, read at zynthian-ui `f46b0189b616` (`jalv.py` is `zyngine/zynthian_engine_jalv.py`, +`modui.py` is `zyngine/zynthian_engine_modui.py`, `engine.py` is `zyngine/zynthian_engine.py`, `manager.py` is +`zyngine/zynthian_chain_manager.py`, `chain.py` is `zyngine/zynthian_chain.py`), against Path B as merged in #125 +(`zynthian_engine_plugind.py` of `zynthian_engine_plugind.patch`; `spike-report.md:97-104` lists what it lacks) and +as this branch extends it (the strip, `zynthian_plugind_strip.py`). jalv is one engine and one process per LV2 plugin, +one processor per slot; mod-ui is ONE `Special` processor (`modui.py:67`) whose pedalboard, edited in mod-ui's own web +page, is the whole chain inside it. + +"Matters" says whether the gap matters for a Zynthian chain of single-processor slots: yes, the chain does not work +as a chain without it; use, a user loses a feature but the chain runs; no, it does not touch the chain. + +| What | jalv engine | mod-ui engine | Path B (#125) | Path B, this branch | Matters | +|---|---|---|---|---|---| +| add a plugin | a new engine and `jalv` process per processor (`manager.py:1427-1429`, `jalv.py:134`) | mod-ui's page adds it to the pedalboard; Zynthian hears `add` on the websocket (`modui.py:376-379`, `add_plugin_cb` `:517`) | `add ` in `start()` | unchanged | yes, had | +| add at a slot position | the chain inserts the slot (`chain.py:639-680`); autoconnect wires it | inside the pedalboard only, by mod-ui's own graph | the chain inserts; autoconnect wires it | the strip re-links from the new slot order on the rebuild (`connect`) | yes, had through autoconnect, now the strip's | +| remove a plugin | engine stopped when unused (`manager.py:1250-1251`) | `remove` on the websocket (`modui.py:381-387`, `remove_plugin_cb` `:654`) | `remove ` in `stop()` | the strip first takes the instance's links down (`disconnect`) and joins its neighbours, then `remove` | yes, healing was missing | +| order of processors in a slot sequence | JACK links made by autoconnect from `audio_routes` (`chain.py:335-347`, `autoconnect.py:1089-1097`) | the pedalboard's `connect`/`disconnect` (`modui.py:389-393`, `graph_connect_cb` `:674`) | autoconnect only: the engine says nothing about order | the strip's `connect` links between adjacent plugind stages, in slot order; `chain` (plugin-hostd#101) is the marked seam | yes, gap closed | +| move a processor | `chain.nudge_processor` + autoconnect (`chain.py:758-804`) | in the pedalboard | autoconnect only | the strip re-links: stale links `disconnect`ed, new ones `connect`ed | yes, gap closed | +| parallel processors in a slot | fan-out and implicit JACK summing (`chain.py:342-347`) | any graph the pedalboard draws | implicit summing through autoconnect | fan-out + an explicit mix element (declared gains); realisation refused `MIX_ELEMENT_ABSENT` with no mixer declared | not for single slots | +| presets: list, load | lilv presets, bank by bank (`jalv.py:1035-1051`) | pedalboards and per-effect presets (`modui.py:167-230`) | lilv (LV2), `preset_list`/`preset_get` (CLAP), `preset_load` | unchanged | use, had | +| presets: save, rename, delete, user banks | `save preset` to jalv and TTL editing (`jalv.py:973-1170`) | none in Zynthian (mod-ui's page) | none | none | use | +| parameters and controllers | from lilv (`jalv.py:328-555`), set with `symbol=value` (`:574-611`) | from mod-ui's `/effect/get` (`modui.py:519-641`), set with `param_set` on the websocket (`:299-305`) | the jalv base's lilv controllers, `param_set`/`param_get`; CLAP from `omx-clap-scan` | unchanged | yes, had | +| parameter feedback (plugin moves a control) | `#CTR>` lines parsed (`jalv.py:232-262`) | `param_set` from the websocket (`modui.py:401-402`, `set_param_cb` `:730`) | none: `output_set` is read for meters only | none | use | +| meters (output controls) | `monitors` command (`jalv.py:560-563`, `:264-280`) | none | `monitor_output` + `output_set` feedback | unchanged | no | +| bypass | the plugin's own bypass or enable port, if it has one (`jalv.py:342-346`, `:527-540`) | a host `:bypass` controller per plugin (`modui.py:629-643`), mod-host `bypass` | the plugin's own port only (the jalv base) | a host `:bypass` controller when the plugin has no bypass port, sent as mod-host `bypass <0/1>` | yes, gap closed | +| MIDI learn | Zynthian's own, by controller (`manager.py:1626`, `:1796`) for any engine | the same; mod-ui's native learn is unused ("These MOD-UI native functions are not used!", `modui.py:761`) | the same (controllers are `zynthian_controller`s) | unchanged, not tested here | use, had | +| MIDI channel | jalv base, per-plugin workarounds (`jalv.py:937-951`) | MIDI devices enabled in mod-ui (`modui.py:704-728`) | the jalv base's `set_midi_chan` | unchanged | no (effects) | +| native plugin GUI | `jalv -s`, jalv.qt5/gtk3 (`jalv.py:802-817`), `minimize_autogenerated_gui` (`:916`) | mod-ui's web page | none | none | use | +| Zynthian custom widgets | per plugin URL (`jalv.py:876-881`, `get_user_custom_gui` `:901`) | none | none | none | use | +| path (file) parameters | `set ` (`jalv.py:583-603`) | none | none | none | use | +| crash of one plugin | the jalv process dies; nothing respawns it in these files | mod-host dies with every plugin of the pedalboard | the daemon puts the worker back (its own suite) | unchanged | no | +| web configuration (zynapi) | class methods (`jalv.py:1188-1360`) | `modui.py:821-861` | none | none | no | + +The gaps that matter for a chain of single-processor slots were three: the order of the chain (Path B left it to +autoconnect and said nothing of it), removal healing, and a host bypass for plugins without a bypass port. This branch +closes those three through the strip; the remaining gaps are user features (preset saving, GUIs, custom widgets, +parameter feedback, file parameters) and do not stop a chain from running. From dca9ffe50358ab9014d8e23931f43ce7be05c06e Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Wed, 21 Oct 2026 05:25:28 +0200 Subject: [PATCH 3/4] zynthian_engine_plugind.patch: the plugind strip links a chain's audio slots on plugin-hostd in order, a parallel slot through an explicit mix element zynthian_plugind_strip.py holds the daemon client, the instance numbers and the strip: adjacent plugind stages are joined with mod-host connect in slot order, re-linked on add at a position, move and remove (stale links disconnected before the instance goes), and a parallel slot is a fan-out into its members and a mix element (a mixer instance declared by PLUGIND_MIX, unity gain per branch as Zynthian's implicit sum) whose output feeds what follows. Refusals by name: PARALLEL_SLOT_FOREIGN, MIX_ELEMENT_ABSENT, MIX_BRANCHES_EXCEEDED, MIX_ADD_REFUSED; a nudge that would form a slot the strip cannot realise answers False and leaves the chain as it was. An effect with no bypass port gets the host's :bypass, sent as mod-host bypass. realise_links is the marked seam for plugin-hostd#101's chain. --- docs/zynthian/zynthian_engine_plugind.patch | 608 ++++++++++++++++---- 1 file changed, 511 insertions(+), 97 deletions(-) diff --git a/docs/zynthian/zynthian_engine_plugind.patch b/docs/zynthian/zynthian_engine_plugind.patch index fb31005b..5e934ec5 100644 --- a/docs/zynthian/zynthian_engine_plugind.patch +++ b/docs/zynthian/zynthian_engine_plugind.patch @@ -1,4 +1,5 @@ -zynthian_engine_plugind: plugin-hostd as a Zynthian engine (FreeMixer/plugin-hostd#124 spike) +zynthian_engine_plugind: plugin-hostd as a Zynthian engine (FreeMixer/plugin-hostd#124 spike), and the plugind strip +that links a chain's audio slots on plugin-hostd in order (docs/zynthian/chain-profile.md) Against zynthian/zynthian-ui vangelis f46b0189b6163472db39aa607369f397ed23b65a (2026-10-19). Apply: git -C zynthian-ui apply zynthian_engine_plugind.patch @@ -24,6 +25,49 @@ index 5dd29a59..61349483 100644 from zyngine.zynthian_engine_sfizz import * from zyngine.zynthian_engine_alsa_mixer import * from zyngine.zynthian_engine_tempo import * +diff --git a/zyngine/zynthian_chain.py b/zyngine/zynthian_chain.py +index 77f3d97e..36953bf5 100644 +--- a/zyngine/zynthian_chain.py ++++ b/zyngine/zynthian_chain.py +@@ -29,6 +29,7 @@ import logging + # Zynthian specific modules + import zynautoconnect + from zyngine.zynthian_processor import zynthian_processor ++from zyngine import zynthian_plugind_strip + from zyncoder.zyncore import lib_zyncore + + +@@ -359,6 +360,10 @@ class zynthian_chain: + for output in self.get_audio_out(): + self.audio_routes[output] = prev_slot_sources.copy() + ++ # plugin-hostd links the plugind processors of adjacent slots itself (mod-host connect), and a parallel slot of ++ # them through an explicit mix element: those routes are taken from autoconnect ++ self.audio_routes = zynthian_plugind_strip.realise_chain(self, self.audio_routes) ++ + zynautoconnect.release_lock() + zynautoconnect.request_audio_connect() + +@@ -758,6 +763,7 @@ class zynthian_chain: + def nudge_processor(self, processor, up): + try: + slots = self.get_slots_by_type(processor.type) ++ before = [list(slot) for slot in slots] + cur_slot = self.get_slot(processor) + parallel = len(slots[cur_slot]) > 1 + is_mixer_strip = processor.eng_code in ("MI", "MR") +@@ -798,6 +804,11 @@ class zynthian_chain: + else: + slots[cur_slot].append(processor) + ++ # a slot plugin-hostd could not realise (a parallel one with no mix element) is never formed ++ if slots is self.audio_slots and not zynthian_plugind_strip.accepts(self, slots): ++ slots[:] = before ++ return False ++ + self.rebuild_graph() + except: + logging.error("Failed to move processor") diff --git a/zyngine/zynthian_chain_manager.py b/zyngine/zynthian_chain_manager.py index 39b2505e..73ad2974 100644 --- a/zyngine/zynthian_chain_manager.py @@ -54,10 +98,10 @@ index 39b2505e..73ad2974 100644 # ---------------------------------------------------------------------------- diff --git a/zyngine/zynthian_engine_plugind.py b/zyngine/zynthian_engine_plugind.py new file mode 100644 -index 00000000..9d938f51 +index 00000000..a1fbfb17 --- /dev/null +++ b/zyngine/zynthian_engine_plugind.py -@@ -0,0 +1,247 @@ +@@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +# ******************************************************************** +# ZYNTHIAN PROJECT: Zynthian Engine (zynthian_engine_plugind) @@ -83,89 +127,15 @@ index 00000000..9d938f51 +# +# ******************************************************************** + -+import os +import json -+import socket -+import logging -+import threading +import subprocess -+from time import sleep + +from . import zynthian_lv2 ++from . import zynthian_plugind_strip as strip +from .zynthian_controller import zynthian_controller +from .zynthian_engine_jalv import zynthian_engine_jalv_base + +# ------------------------------------------------------------------------------ -+# One connection to plugin-hostd, shared by every plugind engine -+# ------------------------------------------------------------------------------ -+ -+ -+class plugind_client: -+ """Commands on PLUGIND_PORT (mod-host's 5555), feedback on the port after it; starts the daemon if none answers.""" -+ -+ def __init__(self, port=None): -+ self.port = int(port or os.environ.get("PLUGIND_PORT", 5555)) -+ self.lock = threading.Lock() -+ self.output_handlers = {} -+ self.proc = None -+ if not self.connectable(): -+ cmd = [os.environ.get("PLUGIND_BIN", "plugin-hostd"), "-n", "-p", str(self.port)] -+ logging.info(f"Starting plugin-hostd => {cmd}") -+ self.proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) -+ for _ in range(50): -+ if self.connectable(): -+ break -+ sleep(0.1) -+ # mod-host's protocol: a controller opens the feedback port before its first command -+ self.fb = socket.create_connection(("127.0.0.1", self.port + 1)) -+ self.sock = socket.create_connection(("127.0.0.1", self.port)) -+ threading.Thread(target=self.feedback_task, daemon=True).start() -+ -+ def connectable(self): -+ try: -+ socket.create_connection(("127.0.0.1", self.port), timeout=0.2).close() -+ return True -+ except OSError: -+ return False -+ -+ def cmd(self, line): -+ with self.lock: -+ self.sock.sendall(line.encode() + b"\0") -+ buf = b"" -+ while not buf.endswith(b"\0"): -+ data = self.sock.recv(65536) -+ if not data: -+ break -+ buf += data -+ reply = buf.rstrip(b"\0").decode("utf-8", "replace") -+ logging.debug(f"plugind: {line} => {reply}") -+ return reply -+ -+ def feedback_task(self): -+ buf = b"" -+ while True: -+ data = self.fb.recv(65536) -+ if not data: -+ return -+ buf += data -+ *msgs, buf = buf.split(b"\0") -+ for msg in msgs: -+ words = msg.decode("utf-8", "replace").split() -+ # output_set : a monitored output (meter) moved -+ if len(words) == 4 and words[0] == "output_set": -+ handler = self.output_handlers.get(int(words[1])) -+ if handler: -+ handler(words[2], float(words[3])) -+ # the meters of one cycle are out: ask for the next, as mod-ui does (mod/host.py send_output_data_ready) -+ elif words and words[0] == "data_finish": -+ threading.Thread(target=self.cmd, args=("output_data_ready",), daemon=True).start() -+ -+ -+def resp_code(reply): -+ words = reply.split() -+ return int(words[1]) if len(words) > 1 and words[0] == "resp" else None -+ -+# ------------------------------------------------------------------------------ +# plugind Engine Class +# ------------------------------------------------------------------------------ + @@ -175,14 +145,12 @@ index 00000000..9d938f51 + omx-clap-scan) run by plugin-hostd as instance , JACK client effect_. MIDI channels stay with Zynthian's router: + the instance's MIDI input is wired like a jalv client's.""" + -+ client = None -+ instances = set() ++ # the strip (zynthian_plugind_strip) links a chain's plugind processors to each other by this mark ++ plugind = True + + def __init__(self, eng_code, state_manager, jackname=None): -+ if zynthian_engine_plugind.client is None: -+ zynthian_engine_plugind.client = plugind_client() -+ self.instance = min(set(range(len(self.instances) + 1)) - self.instances) -+ self.instances.add(self.instance) ++ self.client = strip.client() ++ self.instance = strip.alloc_instance() + self.monitors = {} + # the base class builds the controllers, then calls start() and reads "JACK Name:" from what it returns + super().__init__(eng_code, state_manager, jackname) @@ -195,8 +163,8 @@ index 00000000..9d938f51 + # --------------------------------------------------------------------------- + + def start(self): -+ reply = self.client.cmd(f"add {self.plugin_url} {self.instance}") -+ if resp_code(reply) is None or resp_code(reply) < 0: ++ reply = self.client.cmd(strip.add_line(self.plugin_url, self.instance)) ++ if strip.resp_code(reply) is None or strip.resp_code(reply) < 0: + raise Exception(f"plugin-hostd refused '{self.plugin_url}' => {reply}") + self.client.output_handlers[self.instance] = self.on_output + for symbol in self.monitor_symbols(): @@ -206,17 +174,12 @@ index 00000000..9d938f51 + def stop(self): + self.client.output_handlers.pop(self.instance, None) + self.client.cmd(f"remove {self.instance}") -+ self.instances.discard(self.instance) ++ strip.free_instance(self.instance) + + def proc_cmd(self, cmd): + """The jalv command lines the base class writes, said in the daemon's protocol.""" -+ if cmd.startswith("preset "): -+ return self.client.cmd(f"preset_load {self.instance} {cmd[7:]}") -+ if "=" in cmd and " " not in cmd: -+ symbol, value = cmd.split("=", 1) -+ return self.client.cmd(f"param_set {self.instance} {symbol} {value}") -+ # "monitors": the meters come as output_set feedback, nothing to ask -+ return "" ++ line = strip.jalv_line(self.instance, cmd) ++ return self.client.cmd(line) if line else "" + + def get_param(self, symbol): + reply = self.client.cmd(f"param_get {self.instance} {symbol}").split() @@ -236,8 +199,18 @@ index 00000000..9d938f51 + return {"params": []} + + def get_lv2_controllers_dict(self): -+ if not self.is_clap(): -+ return super().get_lv2_controllers_dict() ++ zctrls = super().get_lv2_controllers_dict() if not self.is_clap() else self.clap_controllers_dict() ++ # an effect with no bypass port of its own gets the host's, mod-host's bypass verb, as mod-ui gives every plugin ++ # (zynthian_engine_modui.py:629-643) ++ if self.type == "Audio Effect" and not any(z.is_bypass for z in zctrls.values()): ++ zctrls[":bypass"] = zynthian_controller(self, ":bypass", { ++ "name": "bypass", "short_name": "bypass", "value": 0, "value_default": 0, "value_min": 0, ++ "value_max": 1, "labels": ["inline", "bypass"], "ticks": [0, 1], "is_toggle": True, "is_integer": True, ++ "is_bypass": True, "bypass_value": 1}) ++ self.bypass_zctrl = zctrls[":bypass"] ++ return zctrls ++ ++ def clap_controllers_dict(self): + zctrls = {} + for p in self.clap_scan()["params"]: + stepped = "stepped" in p.get("flags", []) @@ -298,10 +271,451 @@ index 00000000..9d938f51 + return sorted(presets, key=lambda p: p[2]) + + def set_preset(self, processor, preset, preload=False): -+ code = resp_code(self.client.cmd(f"preset_load {self.instance} {preset[0]}")) ++ code = strip.resp_code(self.client.cmd(f"preset_load {self.instance} {preset[0]}")) + return code is not None and code >= 0 + + def cmp_presets(self, preset1, preset2): + return preset1[0] == preset2[0] + +# ****************************************************************************** +diff --git a/zyngine/zynthian_plugind_strip.py b/zyngine/zynthian_plugind_strip.py +new file mode 100644 +index 00000000..d3fd51b9 +--- /dev/null ++++ b/zyngine/zynthian_plugind_strip.py +@@ -0,0 +1,435 @@ ++# -*- coding: utf-8 -*- ++# ******************************************************************** ++# ZYNTHIAN PROJECT: plugin-hostd strip (zynthian_plugind_strip) ++# ++# A chain's audio slots realised on plugin-hostd: the connection to the ++# daemon, the instance numbers, and the links between instances, made ++# with mod-host's connect and disconnect in slot order. ++# ++# Copyright (C) 2026 Pau Aliagas ++# ++# ******************************************************************** ++# ++# This program is free software; you can redistribute it and/or ++# modify it under the terms of the GNU General Public License as ++# published by the Free Software Foundation; either version 3 of ++# the License, or any later version. ++# ++# This program is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# For a full copy of the GNU General Public License see the LICENSE.txt file. ++# ++# ******************************************************************** ++ ++import os ++import json ++import socket ++import logging ++import threading ++import subprocess ++from time import sleep ++ ++# The refusals of the strip, by name. None is a protocol code: they are said here, before any line reaches the daemon. ++PARALLEL_SLOT_FOREIGN = "PARALLEL_SLOT_FOREIGN" # a parallel slot holds a processor that plugin-hostd does not run ++MIX_ELEMENT_ABSENT = "MIX_ELEMENT_ABSENT" # a parallel slot needs a mixer plugin and PLUGIND_MIX declares none ++MIX_BRANCHES_EXCEEDED = "MIX_BRANCHES_EXCEEDED" # a parallel slot has more members than the mixer has branches ++MIX_ADD_REFUSED = "MIX_ADD_REFUSED" # the daemon refused the add of the mixer instance ++ ++ ++class plugind_strip_refused(Exception): ++ def __init__(self, code, slot, detail=""): ++ super().__init__(f"{code} (audio slot {slot}){': ' + detail if detail else ''}") ++ self.code = code ++ self.slot = slot ++ ++# ------------------------------------------------------------------------------ ++# One connection to plugin-hostd, shared by every plugind engine and strip ++# ------------------------------------------------------------------------------ ++ ++ ++class plugind_client: ++ """Commands on PLUGIND_PORT (mod-host's 5555), feedback on the port after it; starts the daemon if none answers.""" ++ ++ def __init__(self, port=None): ++ self.port = int(port or os.environ.get("PLUGIND_PORT", 5555)) ++ self.lock = threading.Lock() ++ self.output_handlers = {} ++ self.proc = None ++ if not self.connectable(): ++ cmd = [os.environ.get("PLUGIND_BIN", "plugin-hostd"), "-n", "-p", str(self.port)] ++ logging.info(f"Starting plugin-hostd => {cmd}") ++ self.proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) ++ for _ in range(50): ++ if self.connectable(): ++ break ++ sleep(0.1) ++ # mod-host's protocol: a controller opens the feedback port before its first command ++ self.fb = socket.create_connection(("127.0.0.1", self.port + 1)) ++ self.sock = socket.create_connection(("127.0.0.1", self.port)) ++ threading.Thread(target=self.feedback_task, daemon=True).start() ++ ++ def connectable(self): ++ try: ++ socket.create_connection(("127.0.0.1", self.port), timeout=0.2).close() ++ return True ++ except OSError: ++ return False ++ ++ def cmd(self, line): ++ with self.lock: ++ self.sock.sendall(line.encode() + b"\0") ++ buf = b"" ++ while not buf.endswith(b"\0"): ++ data = self.sock.recv(65536) ++ if not data: ++ break ++ buf += data ++ reply = buf.rstrip(b"\0").decode("utf-8", "replace") ++ logging.debug(f"plugind: {line} => {reply}") ++ return reply ++ ++ def feedback_task(self): ++ buf = b"" ++ while True: ++ data = self.fb.recv(65536) ++ if not data: ++ return ++ buf += data ++ *msgs, buf = buf.split(b"\0") ++ for msg in msgs: ++ words = msg.decode("utf-8", "replace").split() ++ # output_set : a monitored output (meter) moved ++ if len(words) == 4 and words[0] == "output_set": ++ handler = self.output_handlers.get(int(words[1])) ++ if handler: ++ handler(words[2], float(words[3])) ++ # the meters of one cycle are out: ask for the next, as mod-ui does (mod/host.py send_output_data_ready) ++ elif words and words[0] == "data_finish": ++ threading.Thread(target=self.cmd, args=("output_data_ready",), daemon=True).start() ++ ++ ++def resp_code(reply): ++ words = reply.split() ++ try: ++ return int(words[1]) if len(words) > 1 and words[0] == "resp" else None ++ except ValueError: ++ return None ++ ++ ++_client = None ++ ++ ++def client(): ++ global _client ++ if _client is None: ++ _client = plugind_client() ++ return _client ++ ++ ++def set_client(c): ++ """A test, or a host with its own connection, hands the strip the client to use.""" ++ global _client ++ _client = c ++ ++# ------------------------------------------------------------------------------ ++# Instance numbers: one table for the engines' instances and the strips' mixers ++# ------------------------------------------------------------------------------ ++ ++ ++instances = set() ++ ++ ++def alloc_instance(): ++ n = min(set(range(len(instances) + 1)) - instances) ++ instances.add(n) ++ return n ++ ++ ++def free_instance(n): ++ instances.discard(n) ++ ++ ++def add_line(uri, instance): ++ """The add line; PLUGIND_PLACEMENT (own, pool:, ...) is the daemon's own placement word, empty by default.""" ++ placement = os.environ.get("PLUGIND_PLACEMENT", "") ++ return f"add {uri} {instance}" + (f" {placement}" if placement else "") ++ ++ ++def jalv_line(instance, cmd): ++ """A jalv command line the jalv base class writes, said in the daemon's protocol; None for one with nothing to say ++ ("monitors": the meters come as output_set feedback).""" ++ if cmd.startswith("preset "): ++ return f"preset_load {instance} {cmd[7:]}" ++ if "=" in cmd and " " not in cmd: ++ symbol, value = cmd.split("=", 1) ++ if symbol == ":bypass": ++ # the host's bypass, mod-host's verb: the plugin has no bypass port of its own ++ return f"bypass {instance} {1 if float(value) >= 0.5 else 0}" ++ return f"param_set {instance} {symbol} {value}" ++ return None ++ ++# ------------------------------------------------------------------------------ ++# The mix element of a parallel slot ++# ------------------------------------------------------------------------------ ++ ++ ++def mix_profile(): ++ """The N-input mixer plugin plugin-hostd runs for a parallel slot, declared in PLUGIND_MIX as JSON: ++ {"uri": ..., "branches": N, "in": "in_{branch}_{ch}", "out": "out_{ch}", "channels": 2, "gain": "gain_{branch}"} ++ (branch and ch count from 1). None when nothing is declared.""" ++ text = os.environ.get("PLUGIND_MIX") ++ if not text: ++ return None ++ p = json.loads(text) ++ p.setdefault("channels", 2) ++ return p ++ ++ ++class mix_element: ++ """role mix: sums the outputs of a parallel slot's members, one branch each, with a declared gain per branch. ++ Zynthian's own parallel slot is an implicit JACK sum at unity per member (zynthian_chain.py:342-347, ++ zynthian_autoconnect.py:1089-1097): the default gain is 1.0, not 1/N, so a slot sounds as it did.""" ++ ++ def __init__(self, slot, members, gains=None): ++ self.slot = slot ++ self.members = list(members) ++ self.gains = list(gains) if gains else [1.0] * len(self.members) ++ self.bypassed = False ++ self.instance = None ++ ++ @property ++ def key(self): ++ return tuple(m.engine.instance for m in self.members) ++ ++ def latency(self, branch_latencies): ++ """the slowest branch; None while any branch's latency is unknown (plugin-hostd's main routes no latency verb)""" ++ if not branch_latencies or any(v is None for v in branch_latencies): ++ return None ++ return max(branch_latencies) ++ ++ def jackname(self): ++ return f"effect_{self.instance}" if self.instance is not None else f"" ++ ++ def in_ports(self, branch, profile): ++ return [f"{self.jackname()}:" + profile["in"].format(branch=branch + 1, ch=c + 1) for c in range(profile["channels"])] ++ ++ def out_ports(self, profile): ++ return [f"{self.jackname()}:" + profile["out"].format(ch=c + 1) for c in range(profile["channels"])] ++ ++# ------------------------------------------------------------------------------ ++# The strip ++# ------------------------------------------------------------------------------ ++ ++ ++def is_plugind(processor): ++ return getattr(processor.engine, "plugind", False) ++ ++ ++def jack_ports(client_name, is_output): ++ """The audio ports of a JACK client, in the order autoconnect reads them.""" ++ import zynautoconnect ++ return [p.name for p in zynautoconnect.jclient.get_ports(f"^{client_name}:", is_audio=True, is_output=is_output, ++ is_input=not is_output)] ++ ++ ++def pair_ports(src_ports, dst_ports): ++ """autoconnect's mono/stereo rule (zynthian_autoconnect.py:1094-1097): at most two links, the last port reused""" ++ links = [] ++ if src_ports and dst_ports: ++ for i in range(min(2, max(len(src_ports), len(dst_ports)))): ++ links.append((src_ports[min(i, len(src_ports) - 1)], dst_ports[min(i, len(dst_ports) - 1)])) ++ return links ++ ++ ++class stage: ++ """One step of the strip: a single processor, or a parallel slot's members and their mix element.""" ++ ++ def __init__(self, slot, members, mix=None): ++ self.slot = slot ++ self.members = members ++ self.mix = mix ++ ++ def owned(self): ++ """plugin-hostd runs every node of this stage""" ++ return self.mix is not None or is_plugind(self.members[0]) ++ ++ ++class plugind_strip: ++ """The links of one chain's audio slots that plugin-hostd's instances make between them.""" ++ ++ def __init__(self, ports=jack_ports): ++ self.ports = ports ++ self.links = [] # (source port, destination port), in the order they were made ++ self.mixes = {} # member instances -> mix_element with its instance ++ ++ # --------------------------------------------------------------------------- ++ # The plan: what the slots ask for, with nothing sent ++ # --------------------------------------------------------------------------- ++ ++ def plan(self, audio_slots, profile=None): ++ """The stages of the audio slots; raises plugind_strip_refused for a parallel slot this strip cannot realise.""" ++ profile = profile if profile is not None else mix_profile() ++ stages = [] ++ for i, slot in enumerate(audio_slots): ++ if len(slot) == 1: ++ stages.append(stage(i, list(slot))) ++ continue ++ if not all(is_plugind(p) for p in slot): ++ raise plugind_strip_refused(PARALLEL_SLOT_FOREIGN, i, ", ".join(p.get_jackname() for p in slot)) ++ mix = mix_element(i, slot) ++ known = self.mixes.get(mix.key) ++ stages.append(stage(i, list(slot), known or mix)) ++ return stages ++ ++ def check(self, audio_slots): ++ """Raise before a parallel slot forms that could not be realised: no mixer declared, too few branches.""" ++ profile = mix_profile() ++ for st in self.plan(audio_slots, profile): ++ if st.mix is None: ++ continue ++ if profile is None: ++ raise plugind_strip_refused(MIX_ELEMENT_ABSENT, st.slot, "PLUGIND_MIX declares no mixer plugin") ++ if len(st.members) > int(profile["branches"]): ++ raise plugind_strip_refused(MIX_BRANCHES_EXCEEDED, st.slot, ++ f"{len(st.members)} members, {profile['branches']} branches") ++ ++ def link_plan(self, stages, profile): ++ """The links the strip owns, in chain order: between adjacent stages plugin-hostd runs both of, the fan-out into ++ a parallel slot, and each member into its branch of the mix.""" ++ def outs(st): ++ return st.mix.out_ports(profile) if st.mix else self.ports(st.members[0].get_jackname(), True) ++ ++ links = [] ++ for k, st in enumerate(stages): ++ prev = stages[k - 1] if k else None ++ if prev is not None and prev.owned() and st.owned(): ++ for m in st.members: ++ links += pair_ports(outs(prev), self.ports(m.get_jackname(), False)) ++ if st.mix: ++ for b, m in enumerate(st.members): ++ links += pair_ports(self.ports(m.get_jackname(), True), st.mix.in_ports(b, profile)) ++ return links ++ ++ def routes(self, stages, audio_routes): ++ """The chain's routes with what the strip owns taken out, and a parallel slot's members replaced by its mix as ++ the source of whatever follows: no input is ever fed by more than one member.""" ++ routes = dict(audio_routes) ++ for k, st in enumerate(stages): ++ prev = stages[k - 1] if k else None ++ if prev is not None and prev.owned() and st.owned(): ++ for m in st.members: ++ routes.pop(m.get_jackname(), None) ++ if st.mix: ++ members = sorted(m.get_jackname() for m in st.members) ++ for dst, sources in routes.items(): ++ if sorted(sources) == members: ++ routes[dst] = [st.mix.jackname()] ++ return routes ++ ++ # --------------------------------------------------------------------------- ++ # Realisation ++ # --------------------------------------------------------------------------- ++ ++ def realise(self, audio_slots, audio_routes): ++ """Bring the daemon's links to the slots' order; returns the routes left to autoconnect.""" ++ if not any(is_plugind(p) for slot in audio_slots for p in slot) and not self.links and not self.mixes: ++ return audio_routes ++ self.check(audio_slots) ++ profile = mix_profile() ++ stages = self.plan(audio_slots, profile) ++ wanted_mixes = {st.mix.key: st.mix for st in stages if st.mix} ++ for key, mix in wanted_mixes.items(): ++ if mix.instance is None: ++ self.add_mix(mix, profile) ++ self.realise_links(self.link_plan(stages, profile)) ++ for key in [k for k in self.mixes if k not in wanted_mixes]: ++ self.remove_mix(self.mixes.pop(key)) ++ return self.routes(stages, audio_routes) ++ ++ # ---- SEAM: plugin-hostd#101 ------------------------------------------------------------------------------------- ++ # This method is the one place the strip's order reaches the daemon, as connect and disconnect links between ++ # instances (the mod-host strip profile). plugin-hostd#101 (branch cluster/plugin-hostd-101-r2-cluster at 3fea0e6, ++ # not merged; base wave/contract-one-pass) adds `chain ...`: a run of single-slot ++ # stages whose workers run chains in one client (clap, lv2-clap) becomes one `chain` line here and its inner links ++ # go; a mod-host worker is never sent that line (its description there), so an LV2 run through mod-host keeps ++ # these links. The fan-out into and the branches out of a mix element stay links either way. ++ def realise_links(self, wanted): ++ for src, dst in [l for l in self.links if l not in wanted]: ++ reply = client().cmd(f"disconnect {src} {dst}") ++ if resp_code(reply) is None or resp_code(reply) < 0: ++ logging.error(f"plugind strip: disconnect {src} {dst} => {reply}") ++ self.links.remove((src, dst)) ++ for src, dst in wanted: ++ if (src, dst) in self.links: ++ continue ++ reply = client().cmd(f"connect {src} {dst}") ++ if resp_code(reply) is None or resp_code(reply) < 0: ++ logging.error(f"plugind strip: connect {src} {dst} => {reply}") ++ continue ++ self.links.append((src, dst)) ++ # ---- end of SEAM ------------------------------------------------------------------------------------------------ ++ ++ def add_mix(self, mix, profile): ++ n = alloc_instance() ++ reply = client().cmd(add_line(profile["uri"], n)) ++ if resp_code(reply) is None or resp_code(reply) < 0: ++ free_instance(n) ++ raise plugind_strip_refused(MIX_ADD_REFUSED, mix.slot, reply) ++ mix.instance = n ++ for b, g in enumerate(mix.gains): ++ client().cmd(f"param_set {n} {profile['gain'].format(branch=b + 1)} {g:.6f}") ++ self.mixes[mix.key] = mix ++ ++ def remove_mix(self, mix): ++ client().cmd(f"remove {mix.instance}") ++ free_instance(mix.instance) ++ mix.instance = None ++ ++ def set_branch_gain(self, mix, branch, gain): ++ mix.gains[branch] = gain ++ return client().cmd(f"param_set {mix.instance} {mix_profile()['gain'].format(branch=branch + 1)} {gain:.6f}") ++ ++ def set_mix_bypass(self, mix, bypassed): ++ mix.bypassed = bypassed ++ return client().cmd(f"bypass {mix.instance} {1 if bypassed else 0}") ++ ++ ++strips = {} ++ ++ ++def strip_of(chain): ++ if chain.chain_id not in strips: ++ strips[chain.chain_id] = plugind_strip() ++ return strips[chain.chain_id] ++ ++ ++def realise_chain(chain, audio_routes): ++ """zynthian_chain.rebuild_audio_graph's hook: the routes autoconnect is left with. A refusal says its code and ++ leaves the chain's routes, less every destination a parallel slot's members would feed together: JACK never sums ++ them at one input. The guard before a slot forms (accepts) keeps that from happening.""" ++ try: ++ return strip_of(chain).realise(chain.audio_slots, audio_routes) ++ except plugind_strip_refused as e: ++ logging.error(f"plugind strip of chain {chain.chain_id}: {e}") ++ routes = dict(audio_routes) ++ for slot in chain.audio_slots: ++ if len(slot) > 1: ++ members = sorted(p.get_jackname() for p in slot) ++ for dst in [d for d, sources in routes.items() if sorted(sources) == members]: ++ routes.pop(dst) ++ return routes ++ ++ ++def accepts(chain, audio_slots): ++ """zynthian_chain.nudge_processor's guard: False, with the code logged, for slots the strip could not realise.""" ++ if not any(is_plugind(p) for slot in audio_slots for p in slot): ++ return True ++ try: ++ strip_of(chain).check(audio_slots) ++ return True ++ except plugind_strip_refused as e: ++ logging.error(f"plugind strip of chain {chain.chain_id}: {e}") ++ return False From 556b02acd50be124fbb4395543eee4bc2884429a Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Wed, 21 Oct 2026 05:25:28 +0200 Subject: [PATCH 4/4] tools/zynthian: test-strip.py, the strip against plugin-hostd and the fake host, every guard sabotaged once Seven scenarios on the real zynthian_chain read the daemon's graph and values back: series order and add at a position, removal healing (and no stale link replayed after a worker respawn), a move through Zynthian's parallel step with the mix element, the three refusals, host bypass and branch gain. Nine sabotages each turn one red. strip.sh applies the patch at the pinned commit and runs it; one run in evidence/strip. chain-profile.md: a reorder passes through a parallel slot, and what is not tested. --- docs/zynthian/chain-profile.md | 17 + docs/zynthian/evidence/strip/test.log | 20 ++ tools/zynthian/strip.sh | 22 ++ tools/zynthian/test-strip.py | 497 ++++++++++++++++++++++++++ 4 files changed, 556 insertions(+) create mode 100644 docs/zynthian/evidence/strip/test.log create mode 100755 tools/zynthian/strip.sh create mode 100755 tools/zynthian/test-strip.py diff --git a/docs/zynthian/chain-profile.md b/docs/zynthian/chain-profile.md index e0cf7711..4c49c775 100644 --- a/docs/zynthian/chain-profile.md +++ b/docs/zynthian/chain-profile.md @@ -114,6 +114,13 @@ Open points saved parallel slot does not survive a reload in Zynthian itself. A new mix element starts at the declared gains: a member added to or taken from a slot gives the slot a new mixer instance, and branch gains set by hand are not carried over. +- **A reorder passes through a parallel slot.** Zynthian has no move verb for effects but the nudge, and a nudge of a + single processor merges it into its neighbour's slot (`chain.py:782`, `:799`); only a second nudge takes it out on + the other side (`chain.py:771-777`, `:793-795`). So reordering two effects always forms a parallel slot for one + step. With a mixer declared, the strip realises that step (a mixer instance is added, then removed on the second + nudge, `tools/zynthian/test-strip.py` `move_relinks_through_the_parallel_step`); with none, the first nudge is + refused `MIX_ELEMENT_ABSENT` and a plugind chain cannot be reordered by nudging at all. Whether Zynthian should get a + plain move (a swap of adjacent slots) instead is not decided here. - **Latency of a mix.** Branches of unequal latency are summed as they come; nothing compensates them. - **Who owns a link.** autoconnect disconnects a link only if it once made it (`autoconnect.py:1176-1181`); a link between two plugind instances must never be made by autoconnect, or a later pass may take the strip's link down. The @@ -124,3 +131,13 @@ Open points mapped. - **MIDI to effects.** Every audio processor gets the chain's MIDI (`chain.py:419-424`); in the strip model that is control-domain input reaching chain elements, which the draft spec places with the source. Not mapped. + +Tests +----- + +`tools/zynthian/strip.sh ` applies the patch at the pinned commit and runs +`tools/zynthian/test-strip.py`: the real `zynthian_chain`, `zynthian_processor` and `zynthian_plugind_strip` against +plugin-hostd and `tests/fake-host`, no JACK and no plugin. Seven scenarios read the daemon's graph back +(`list_connections`) and its values (`param_get`), and nine sabotages each turn one red. `evidence/strip/test.log` is +one run. Not tested: the plugind engine itself under the strip (it needs lilv and JACK: `test-path-b.py`), real JACK +ports and their names, autoconnect's pass over the rewritten routes, Zynthian's GUI, any timing, and any hardware. diff --git a/docs/zynthian/evidence/strip/test.log b/docs/zynthian/evidence/strip/test.log new file mode 100644 index 00000000..91b8b3a2 --- /dev/null +++ b/docs/zynthian/evidence/strip/test.log @@ -0,0 +1,20 @@ +ok series_order_add_at_position +ok removal_heals_and_replay_keeps_no_stale_link +ok move_relinks_through_the_parallel_step +ERROR:root:plugind strip of chain 1: MIX_ELEMENT_ABSENT (audio slot 1): PLUGIND_MIX declares no mixer plugin +ok parallel_slot_without_a_mixer_is_refused_before_it_forms +ERROR:root:plugind strip of chain 1: PARALLEL_SLOT_FOREIGN (audio slot 0): effect_0, jalv_x +ok parallel_slot_with_a_foreign_member_is_refused +ok too_many_branches_is_refused +ok bypass_and_branch_gain_are_values_on_the_daemon +ok sabotage red: a stale link is forgotten, not disconnected -> removal_heals_and_replay_keeps_no_stale_link (Fail: a joined to c, nothing to b: [('effect_0:out_1', 'effect_1:in_1'), ('effect_0:out_1', 'effect_2:in_1'), ('effect_0:out_2', 'effect_1:in_2'), ('effect_0:out_2', 'effect_2:in_2'), ('effect_1:out_1', 'effect_2:in_1'), ('effect_1:out_2', 'effect_2:in_2')]) +ok sabotage red: the same, seen on a move -> move_relinks_through_the_parallel_step (Fail: fan-out a>{b,c}, b and c into the mix's branches: [('effect_0:out_1', 'effect_1:in_1'), ('effect_0:out_1', 'effect_2:in_1'), ('effect_0:out_2', 'effect_1:in_2'), ('effect_0:out_2', 'effect_2:in_2'), ('effect_1:out_1', 'effect_2:in_1'), ('effect_1:out_1', 'effect_3:in_1_1'), ('effect_1:out_2', 'effect_2:in_2'), ('effect_1:out_2', 'effect_3:in_1_2'), ('effect_2:out_1', 'effect_3:in_2_1'), ('effect_2:out_2', 'effect_3:in_2_2')]) +ok sabotage red: a parallel slot's members stay the next input's sources -> move_relinks_through_the_parallel_step (Fail: the output fed by the mix only, never by b and c: {'effect_0': [], 'system:playback': ['effect_1', 'effect_2']}) +ERROR:root:plugind strip of chain 1: MIX_ELEMENT_ABSENT (audio slot 1): PLUGIND_MIX declares no mixer plugin +ok sabotage red: the nudge guard accepts every slot -> parallel_slot_without_a_mixer_is_refused_before_it_forms (Fail: the nudge that would form a parallel slot answers False) +ok sabotage red: adjacent instances linked backwards -> series_order_add_at_position (Fail: series graph [('effect_1:out_1', 'effect_0:in_1'), ('effect_1:out_2', 'effect_0:in_2'), ('effect_2:out_1', 'effect_1:in_1'), ('effect_2:out_2', 'effect_1:in_2')]) +ok sabotage red: the host bypass sent as a parameter -> bypass_and_branch_gain_are_values_on_the_daemon (Fail: jalv line 'param_set 1 :bypass 1.000000') +ok sabotage red: branch gains default to 1/N -> move_relinks_through_the_parallel_step (Fail: branch gains at 1.0, Zynthian's unity sum: ['resp 0 0.500000', 'resp 0 0.500000']) +ok sabotage red: a foreign member counted as plugin-hostd's -> parallel_slot_with_a_foreign_member_is_refused (Fail: nothing linked to a processor plugin-hostd does not run: [('effect_0:out_1', 'jalv_x:in_1'), ('effect_0:out_2', 'jalv_x:in_2')]) +ok sabotage red: the mixer's branch count not enforced -> too_many_branches_is_refused (Fail: no refusal) +STRIP PASS: 0 failed diff --git a/tools/zynthian/strip.sh b/tools/zynthian/strip.sh new file mode 100755 index 00000000..816b2bfc --- /dev/null +++ b/tools/zynthian/strip.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# The plugind strip of the Zynthian spike, offline: zynthian_engine_plugind.patch applied to zynthian-ui at the pinned +# commit, and tools/zynthian/test-strip.py run against plugin-hostd and tests/fake-host (no JACK, no plugin). +# +# tools/zynthian/strip.sh +# +# Environment: PLUGIN_HOSTD (./plugin-hostd), FAKE_HOST (tests/fake-host), PYTHON. Build both first: make plugin-hostd +# tests/fake-host. +set -u +zui_src=${1:?zynthian-ui clone} +out=$(mkdir -p "${2:?out dir}" && cd "$2" && pwd) +here=$(cd "$(dirname "$0")" && pwd) +ZUI_SHA=f46b0189b6163472db39aa607369f397ed23b65a +PYTHON=${PYTHON:-python3} +rm -rf "$out/zynthian-ui" +git -C "$zui_src" worktree add -f -q "$out/zynthian-ui" "$ZUI_SHA" || exit 1 +git -C "$out/zynthian-ui" apply "$here/../../docs/zynthian/zynthian_engine_plugind.patch" || exit 1 +timeout 300 "$PYTHON" "$here/test-strip.py" --zui "$out/zynthian-ui" >"$out/test.log" 2>&1 +rc=$? +git -C "$zui_src" worktree remove --force "$out/zynthian-ui" +grep -E '^(ok|FAIL|STRIP)' "$out/test.log" +exit $rc diff --git a/tools/zynthian/test-strip.py b/tools/zynthian/test-strip.py new file mode 100755 index 00000000..efc9096e --- /dev/null +++ b/tools/zynthian/test-strip.py @@ -0,0 +1,497 @@ +#!/usr/bin/env python3 +"""The plugind strip of the Zynthian spike against plugin-hostd and its fake host: no JACK, no plugin, no Zynthian +hardware. + + test-strip.py --zui + +Environment: PLUGIN_HOSTD (./plugin-hostd), FAKE_HOST (tests/fake-host). The real zynthian_chain, zynthian_processor +and zynthian_plugind_strip are imported; zyncoder, pyliblo3 and zynautoconnect are stubbed as test-path-b.py stubs them. +The engines are stand-ins that add one fake instance each (the plugind engine itself needs lilv and JACK: +test-path-b.py covers it); each JACK client has two audio ins and two outs, named as the CLAP worker names them. +Every instance goes to one pool worker (PLUGIND_PLACEMENT=pool:zyn), so the fake's list_connections is the whole graph +the daemon made. A parallel slot's mixer is the fake plugin fake:mix, declared in PLUGIND_MIX. + +Each scenario is run as is and must pass, then once under each sabotage that breaks the guard it holds, and must fail. +Exit 0 when every scenario passes and every sabotage turns its scenario red. +""" +import argparse +import os +import signal +import socket +import subprocess +import sys +import tempfile +import time +import types + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +EXE = os.path.abspath(os.environ.get("PLUGIN_HOSTD", os.path.join(ROOT, "plugin-hostd"))) +FAKE = os.path.abspath(os.environ.get("FAKE_HOST", os.path.join(ROOT, "tests", "fake-host"))) +MIX = '{"uri": "fake:mix", "branches": 4, "in": "in_{branch}_{ch}", "out": "out_{ch}", "channels": 2, "gain": "gain_{branch}"}' + + +class Fail(Exception): + pass + + +def check(cond, what): + if not cond: + raise Fail(what) + + +def stubs(root): + os.makedirs(os.path.join(root, "zyncoder")) + open(os.path.join(root, "zyncoder", "__init__.py"), "w").close() + open(os.path.join(root, "zyncoder", "zyncore.py"), "w").write( + "class _Z:\n def __getattr__(self, name):\n return lambda *a, **k: 0\nlib_zyncore = _Z()\n") + open(os.path.join(root, "pyliblo3.py"), "w").write( + "UDP = 1\nTCP = 2\nclass AddressError(Exception): pass\nclass ServerError(Exception): pass\n" + "class Address:\n def __init__(self, *a, **k): pass\n" + "class ServerThread:\n def __init__(self, *a, **k): pass\n") + open(os.path.join(root, "zynautoconnect.py"), "w").write( + "def _noop(*a, **k): pass\nadd_sidechain_ports = remove_sidechain_ports = request_audio_connect = " + "request_midi_connect = release_lock = _noop\n" + "def acquire_lock(*a, **k): return True\ndef get_audio_capture_ports(): return []\n") + + +def free_pair(): + """a command port whose next port is free too: the engine's client opens the feedback port at port + 1""" + for _ in range(50): + s = socket.socket() + s.bind(("127.0.0.1", 0)) + p = s.getsockname()[1] + s.close() + try: + t = socket.socket() + t.bind(("127.0.0.1", p + 1)) + t.close() + return p + except OSError: + continue + raise Fail("no free pair of ports") + + +class daemon: + def __init__(self): + self.tmp = tempfile.mkdtemp(prefix="zyn-strip.") + conf = os.path.join(self.tmp, "plugin-hostd.conf") + with open(conf, "w") as f: + for k, v in {"mod_host": FAKE, "clap_host": FAKE, "state_root": self.tmp, "require_pins": 0, + "pins_dir": "", "backoff_base_ms": 20, "backoff_max_ms": 80, "ready_timeout_ms": 4000, + "rpc_timeout_ms": 1500, "idle_ms": 10, "checkpoint_ms": 600000}.items(): + f.write("%s %s\n" % (k, v)) + self.port = free_pair() + self.log = open(os.path.join(self.tmp, "daemon.out"), "w+") + self.proc = subprocess.Popen([EXE, "-n", "-p", str(self.port), "-f", str(self.port + 1), "-c", conf], + stdout=self.log, stderr=subprocess.STDOUT) + end = time.monotonic() + 10 + while time.monotonic() < end: + try: + socket.create_connection(("127.0.0.1", self.port), timeout=0.2).close() + return + except OSError: + time.sleep(0.05) + raise Fail("the daemon never listened") + + def close(self): + self.proc.terminate() + try: + self.proc.wait(5) + except subprocess.TimeoutExpired: + self.proc.kill() + + +def setup(zui): + root = tempfile.mkdtemp(prefix="zyn-strip-stubs-") + stubs(root) + sys.path[:0] = [root, zui] + pkg = types.ModuleType("zyngine") + pkg.__path__ = [os.path.join(zui, "zyngine")] + sys.modules["zyngine"] = pkg + from zyngine import zynthian_plugind_strip, zynthian_chain, zynthian_processor + return zynthian_plugind_strip, zynthian_chain.zynthian_chain, zynthian_processor.zynthian_processor + + +S = CHAIN = PROC = None + + +class fake_engine: + """what the strip asks of a plugind engine: the mark, an instance the daemon holds, its JACK client""" + plugind = True + + def __init__(self, name): + self.instance = S.alloc_instance() + reply = S.client().cmd(S.add_line("fake:" + name, self.instance)) + check(S.resp_code(reply) == self.instance, "add fake:%s -> %r" % (name, reply)) + self.jackname = "effect_%d" % self.instance + self.processors = [] + + def get_jackname(self): + return self.jackname + + def add_processor(self, p): + self.processors.append(p) + + def remove_processor(self, p): + self.processors.remove(p) + + def set_midi_chan(self, p): + pass + + def stop(self): + S.client().cmd("remove %d" % self.instance) + S.free_instance(self.instance) + + +class foreign_engine(fake_engine): + """a processor plugin-hostd does not run (a jalv one): its links are autoconnect's""" + plugind = False + + def __init__(self, name): + self.instance = None + self.jackname = name + self.processors = [] + + +def ports(client_name, is_output): + return ["%s:%s_%d" % (client_name, "out" if is_output else "in", c) for c in (1, 2)] + + +def graph(): + words = S.client().cmd("list_connections").split() + check(words[:2] == ["resp", "0"], "list_connections -> %r" % words) + return sorted(tuple(w.split(">")) for w in words[2:]) + + +def links(*pairs): + """effect clients joined in order, both channels: links("effect_0", "effect_1") is out_1>in_1, out_2>in_2""" + out = [] + for a, b in pairs: + out += [("%s:out_%d" % (a, c), "%s:in_%d" % (b, c)) for c in (1, 2)] + return sorted(out) + + +def branch_links(members, mix): + out = [] + for k, m in enumerate(members): + out += [("%s:out_%d" % (m, c), "%s:in_%d_%d" % (mix, k + 1, c)) for c in (1, 2)] + return out + + +class rig: + """a chain of the real zynthian_chain, its strip on the fake ports, a daemon behind it""" + + def __init__(self, mix=True): + os.environ["PLUGIND_PLACEMENT"] = "pool:zyn" + if mix: + os.environ["PLUGIND_MIX"] = MIX + else: + os.environ.pop("PLUGIND_MIX", None) + S.instances.clear() + S.strips.clear() + self.d = daemon() + S.set_client(S.plugind_client(port=self.d.port)) + self.chain = CHAIN(1, None, False, True) + S.strips[1] = S.plugind_strip(ports=ports) + self.chain.audio_out = ["system:playback"] + + def add(self, name, slot=None, engine=fake_engine): + """as zynthian_chain_manager.add_processor: insert, then start the engine, then rebuild the graph""" + p = PROC("PL/" + name, {"TYPE": "Audio Effect", "NAME": name}, len(self.chain.get_processors()) + 10) + self.chain.insert_processor(p, slot) + p.set_engine(engine(name)) + self.chain.rebuild_graph() + return p + + def remove(self, p): + """as zynthian_chain_manager.remove_processor: out of the chain (rebuilt), then the unused engine stops""" + check(self.chain.remove_processor(p), "remove_processor") + p.engine.stop() + + def order(self): + return [[p.get_jackname() for p in slot] for slot in self.chain.audio_slots] + + def close(self): + self.d.close() + S.set_client(None) + +# ------------------------------------------------------------------------------ +# Scenarios +# ------------------------------------------------------------------------------ + + +def series_order_add_at_position(): + r = rig() + try: + a, b, c = r.add("a"), r.add("b"), r.add("c") + check(r.order() == [["effect_0"], ["effect_1"], ["effect_2"]], "slots %s" % r.order()) + check(graph() == links(("effect_0", "effect_1"), ("effect_1", "effect_2")), "series graph %s" % graph()) + d = r.add("d", slot=1) + check(r.order() == [["effect_0"], ["effect_3"], ["effect_1"], ["effect_2"]], "slots after add at 1: %s" % r.order()) + want = links(("effect_0", "effect_3"), ("effect_3", "effect_1"), ("effect_1", "effect_2")) + check(graph() == want, "add at slot 1 re-links a>d>b>c: %s" % graph()) + # what the strip owns is no longer autoconnect's; the chain's ends still are + routes = r.chain.audio_routes + check("effect_3" not in routes and "effect_1" not in routes and "effect_2" not in routes, + "strip-owned destinations left to autoconnect: %s" % sorted(routes)) + check(routes.get("system:playback") == ["effect_2"], "the chain's output fed by the last slot: %s" % routes) + finally: + r.close() + + +def removal_heals_and_replay_keeps_no_stale_link(): + r = rig() + try: + a, b, c = r.add("a"), r.add("b"), r.add("c") + r.remove(b) + check(r.order() == [["effect_0"], ["effect_2"]], "slots %s" % r.order()) + check(graph() == links(("effect_0", "effect_2")), "a joined to c, nothing to b: %s" % graph()) + check(r.chain.audio_routes.get("system:playback") == ["effect_2"], "output %s" % r.chain.audio_routes) + # the daemon's ledger replays a's links when its worker comes back: a link to the removed b must not be there + info = S.client().cmd("instance_info 0").split() + os.kill(int(info[3]), signal.SIGKILL) + end = time.monotonic() + 10 + while time.monotonic() < end and S.client().cmd("instance_info 0").split()[4:5] != ["up"]: + time.sleep(0.05) + time.sleep(0.3) + check(graph() == links(("effect_0", "effect_2")), "after the worker's respawn, replayed: %s" % graph()) + finally: + r.close() + + +def move_relinks_through_the_parallel_step(): + """Zynthian moves a processor by nudges: up into the neighbour's slot (parallel), then up out of it""" + r = rig() + try: + a, b, c = r.add("a"), r.add("b"), r.add("c") + check(r.chain.nudge_processor(c, True), "first nudge of c accepted with a mixer declared") + check(r.order() == [["effect_0"], ["effect_1", "effect_2"]], "slots %s" % r.order()) + mix = "effect_3" + want = sorted(links(("effect_0", "effect_1"), ("effect_0", "effect_2")) + branch_links(["effect_1", "effect_2"], mix)) + check(graph() == want, "fan-out a>{b,c}, b and c into the mix's branches: %s" % graph()) + routes = r.chain.audio_routes + check(routes.get("system:playback") == [mix], "the output fed by the mix only, never by b and c: %s" % routes) + check(all(len([s for s in v if s in ("effect_1", "effect_2")]) < 2 for v in routes.values()), + "no input fed by two members: %s" % routes) + gains = [S.client().cmd("param_get 3 gain_%d" % k) for k in (1, 2)] + check(gains == ["resp 0 1.000000"] * 2, "branch gains at 1.0, Zynthian's unity sum: %s" % gains) + check(r.chain.nudge_processor(c, True), "second nudge of c") + check(r.order() == [["effect_0"], ["effect_2"], ["effect_1"]], "slots %s" % r.order()) + check(graph() == links(("effect_0", "effect_2"), ("effect_2", "effect_1")), "a>c>b, the mix gone: %s" % graph()) + check(S.client().cmd("instance_info 3") == "resp -3", "the mixer instance removed: %s" % S.client().cmd("instance_info 3")) + check(r.chain.audio_routes.get("system:playback") == ["effect_1"], "output %s" % r.chain.audio_routes) + finally: + r.close() + + +def parallel_slot_without_a_mixer_is_refused_before_it_forms(): + r = rig(mix=False) + try: + a, b, c = r.add("a"), r.add("b"), r.add("c") + before = graph() + check(r.chain.nudge_processor(c, True) is False, "the nudge that would form a parallel slot answers False") + check(r.order() == [["effect_0"], ["effect_1"], ["effect_2"]], "slots unchanged: %s" % r.order()) + check(graph() == before, "graph unchanged: %s" % graph()) + try: + S.strips[1].check([[a], [b, c]]) + check(False, "check of a parallel slot with no mixer raised nothing") + except S.plugind_strip_refused as e: + check(e.code == "MIX_ELEMENT_ABSENT" and e.slot == 1, "code %s slot %s" % (e.code, e.slot)) + finally: + r.close() + + +def parallel_slot_with_a_foreign_member_is_refused(): + r = rig() + try: + a = r.add("a") + j = r.add("jalv_x", engine=foreign_engine) + check(r.order() == [["effect_0"], ["jalv_x"]], "slots %s" % r.order()) + check(graph() == [], "nothing linked to a processor plugin-hostd does not run: %s" % graph()) + check(r.chain.audio_routes.get("jalv_x") == ["effect_0"], "the boundary left to autoconnect: %s" % r.chain.audio_routes) + check(r.chain.nudge_processor(j, True) is False, "a nudge making [a, jalv_x] is refused") + try: + S.strips[1].check([[a, j]]) + check(False, "no refusal") + except S.plugind_strip_refused as e: + check(e.code == "PARALLEL_SLOT_FOREIGN", "code %s" % e.code) + finally: + r.close() + + +def too_many_branches_is_refused(): + r = rig() + try: + ps = [r.add(n) for n in "abcde"] + try: + S.strips[1].check([ps]) + check(False, "no refusal") + except S.plugind_strip_refused as e: + check(e.code == "MIX_BRANCHES_EXCEEDED", "code %s" % e.code) + finally: + r.close() + + +def bypass_and_branch_gain_are_values_on_the_daemon(): + r = rig() + try: + a, b = r.add("a"), r.add("b") + check(S.jalv_line(1, ":bypass=1.000000") == "bypass 1 1", "jalv line %r" % S.jalv_line(1, ":bypass=1.000000")) + S.client().cmd(S.jalv_line(1, ":bypass=1.000000")) + check(S.client().cmd("param_get 1 :bypass") == "resp 0 1", "b bypassed: %s" % S.client().cmd("param_get 1 :bypass")) + S.client().cmd(S.jalv_line(1, ":bypass=0.000000")) + check(S.client().cmd("param_get 1 :bypass") == "resp 0 0", "b inline: %s" % S.client().cmd("param_get 1 :bypass")) + check(S.jalv_line(1, "cutoff=2000.000000") == "param_set 1 cutoff 2000.000000", "param line") + c = r.add("c") + check(r.chain.nudge_processor(c, True), "parallel [b, c]") + mix = list(S.strips[1].mixes.values())[0] + S.strips[1].set_branch_gain(mix, 1, 0.5) + check(S.client().cmd("param_get %d gain_2" % mix.instance) == "resp 0 0.500000", "branch 2 at 0.5") + S.strips[1].set_mix_bypass(mix, True) + check(S.client().cmd("param_get %d :bypass" % mix.instance) == "resp 0 1", "mix bypassed") + check(mix.latency([0, 64]) == 64 and mix.latency([0, None]) is None, "latency is the slowest branch, unknown if any is") + finally: + r.close() + + +SCENARIOS = [series_order_add_at_position, removal_heals_and_replay_keeps_no_stale_link, + move_relinks_through_the_parallel_step, parallel_slot_without_a_mixer_is_refused_before_it_forms, + parallel_slot_with_a_foreign_member_is_refused, too_many_branches_is_refused, + bypass_and_branch_gain_are_values_on_the_daemon] + +# ------------------------------------------------------------------------------ +# Sabotage: each breaks one guard of the strip; the named scenario must go red +# ------------------------------------------------------------------------------ + + +def sab_no_disconnect(): + orig = S.plugind_strip.realise_links + + def realise_links(self, wanted): + for l in [l for l in self.links if l not in wanted]: + self.links.remove(l) # forgets the link without taking it down + for src, dst in wanted: + if (src, dst) not in self.links: + S.client().cmd("connect %s %s" % (src, dst)) + self.links.append((src, dst)) + S.plugind_strip.realise_links = realise_links + return lambda: setattr(S.plugind_strip, "realise_links", orig) + + +def sab_no_mix_source(): + orig = S.plugind_strip.routes + + def routes(self, stages, audio_routes): + mixes = [st.mix for st in stages] + for st in stages: + st.mix = None + try: + return orig(self, stages, audio_routes) + finally: + for st, m in zip(stages, mixes): + st.mix = m + S.plugind_strip.routes = routes + return lambda: setattr(S.plugind_strip, "routes", orig) + + +def sab_accept_all(): + orig = S.accepts + S.accepts = lambda chain, slots: True + return lambda: setattr(S, "accepts", orig) + + +def sab_reverse_adjacent(): + orig = S.plugind_strip.link_plan + + def link_plan(self, stages, profile): + return [(d.replace(":in", ":out"), s.replace(":out", ":in")) for s, d in orig(self, stages, profile)] + S.plugind_strip.link_plan = link_plan + return lambda: setattr(S.plugind_strip, "link_plan", orig) + + +def sab_bypass_as_param(): + orig = S.jalv_line + + def jalv_line(instance, cmd): + if "=" in cmd and " " not in cmd: + symbol, value = cmd.split("=", 1) + return "param_set %s %s %s" % (instance, symbol, value) + return orig(instance, cmd) + S.jalv_line = jalv_line + return lambda: setattr(S, "jalv_line", orig) + + +def sab_gain_one_over_n(): + orig = S.mix_element.__init__ + + def init(self, slot, members, gains=None): + orig(self, slot, members, gains or [1.0 / len(members)] * len(members)) + S.mix_element.__init__ = init + return lambda: setattr(S.mix_element, "__init__", orig) + + +def sab_foreign_allowed(): + orig = S.is_plugind + S.is_plugind = lambda p: True if p.engine is not None and p.engine.instance is None else orig(p) + return lambda: setattr(S, "is_plugind", orig) + + +def sab_no_branch_bound(): + orig = S.mix_profile + + def mix_profile(): + p = orig() + if p: + p["branches"] = 99 + return p + S.mix_profile = mix_profile + return lambda: setattr(S, "mix_profile", orig) + + +SABOTAGE = [ + ("a stale link is forgotten, not disconnected", sab_no_disconnect, removal_heals_and_replay_keeps_no_stale_link), + ("the same, seen on a move", sab_no_disconnect, move_relinks_through_the_parallel_step), + ("a parallel slot's members stay the next input's sources", sab_no_mix_source, move_relinks_through_the_parallel_step), + ("the nudge guard accepts every slot", sab_accept_all, parallel_slot_without_a_mixer_is_refused_before_it_forms), + ("adjacent instances linked backwards", sab_reverse_adjacent, series_order_add_at_position), + ("the host bypass sent as a parameter", sab_bypass_as_param, bypass_and_branch_gain_are_values_on_the_daemon), + ("branch gains default to 1/N", sab_gain_one_over_n, move_relinks_through_the_parallel_step), + ("a foreign member counted as plugin-hostd's", sab_foreign_allowed, parallel_slot_with_a_foreign_member_is_refused), + ("the mixer's branch count not enforced", sab_no_branch_bound, too_many_branches_is_refused), +] + + +def run(fn): + try: + fn() + return None + except Exception as e: + return "%s: %s" % (type(e).__name__, e) + + +def main(): + global S, CHAIN, PROC + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--zui", required=True) + a = ap.parse_args() + S, CHAIN, PROC = setup(a.zui) + failed = 0 + for fn in SCENARIOS: + err = run(fn) + print(("ok " if err is None else "FAIL ") + fn.__name__ + ("" if err is None else ": " + err), flush=True) + failed += err is not None + for what, sab, fn in SABOTAGE: + restore = sab() + try: + err = run(fn) + finally: + restore() + print(("ok sabotage red: " if err else "FAIL sabotage stayed green: ") + "%s -> %s%s" % ( + what, fn.__name__, (" (" + err + ")") if err else ""), flush=True) + failed += err is None + print("STRIP %s: %d failed" % ("PASS" if not failed else "FAIL", failed)) + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main()