Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ clean:

# the daemon against workers that are not plugin hosts at all (tests/fake-host speaks the same protocol and can be
# made to die on cue): placement, forwarding, ledger, replay, attribution, the storm bound; no jack, no plugin
test: test-pin test-transforms test-format-coverage test-describe test-relay test-alloc test-format test-daemon check-generated check-schema test-probe-schema test-vocabulary test-probe test-contract test-consumer test-perturbation check-readme check-own-host-page check-no-pins check-ci-guards test-mod-host-json test-socket check-verb-table
test: test-pin test-transforms test-format-coverage test-describe test-relay test-alloc test-format test-daemon check-generated check-schema test-probe-schema test-vocabulary test-probe test-contract test-consumer test-perturbation check-readme check-own-host-page check-no-pins check-ci-guards test-mod-host-json test-socket check-verb-table check-preset-schema
MOD_HOST_DIR=$(MOD_HOST_DIR) python3 tests/verbs_contract.py

# the same without the verb table, which is read from a mod-host checkout's README
Expand Down Expand Up @@ -314,6 +314,10 @@ check-no-pins:
python3 tests/no_pins.py

# a workflow step the runner may be unable to run fails where it cannot run it (#107)
# the org.openmixer.preset file (docs/presets.md) holds to protocol/preset.schema.json, and a broken one does not
check-preset-schema:
python3 tests/preset_format.py

check-ci-guards:
python3 tests/ci_guards.py

Expand Down
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,9 @@ The ledger keeps the latest `track_info` of an instance and replays it after the
What each host profile owes every verb a controller sends: mod-host's commands, the two that Zynthian's mod-ui still
names, and the verbs the daemon adds. A verb has a row here before the daemon serves it. The daemon relays a worker's
verb, an instance verb to the worker that holds the instance and any other to every worker, and passes its answer
back: a required verb (presets, bundles, MIDI learn and CV addressing for the CLAP worker and Zynthian, by the ruling of
2026-10-22) answers -902 only while the worker does.
back: a required verb (presets and bundles for the CLAP worker and Zynthian, by the rulings of 2026-10-22) answers -902
only while the worker does. MIDI learn and CV addressing are policy of the surfaces: the host answers them only as the
mechanism, and they stay optional. The preset verbs and the preset file are [docs/presets.md](docs/presets.md).

<!-- BEGIN GENERATED protocol:host-verbs -->
| verb | sent by | lv2-worker | clap-worker | console | zynthian |
Expand All @@ -248,11 +249,11 @@ back: a required verb (presets, bundles, MIDI learn and CV addressing for the CL
| `transport` | mod-ui | required | required | required | required |
| `transport_sync` | mod-ui | required | required | required | required |
| `cpu_load` | mod-ui | required | required | required | required |
| `midi_learn` | mod-ui | optional | required | optional | required |
| `midi_map` | mod-ui | optional | required | optional | required |
| `midi_unmap` | mod-ui | optional | required | optional | required |
| `cv_map` | mod-ui | optional | required | optional | required |
| `cv_unmap` | mod-ui | optional | required | optional | required |
| `midi_learn` | mod-ui | optional | optional | optional | optional |
| `midi_map` | mod-ui | optional | optional | optional | optional |
| `midi_unmap` | mod-ui | optional | optional | optional | optional |
| `cv_map` | mod-ui | optional | optional | optional | optional |
| `cv_unmap` | mod-ui | optional | optional | optional | optional |
| `monitor_midi_program` | mod-ui | optional | optional | optional | optional |
| `monitor_output` | mod-ui | optional | optional | optional | optional |
| `output_data_ready` | mod-ui | optional | optional | optional | optional |
Expand Down Expand Up @@ -331,6 +332,11 @@ Error codes the daemon adds to mod-host's:
| `-520` | `PHD_ERR_CHAIN_INSTANCE_BUSY` | a member of chain is already in another chain: the chain is not declared; chain_clear of the other chain frees it |
| `-521` | `PHD_ERR_CHAIN_MIXED_FORMAT` | the members of chain are not all of one format of worker_list, so no one worker runs them: the chain is not declared |
| `-522` | `PHD_ERR_NO_SUCH_CHAIN` | chain_clear of a chain no chain declared |
| `-523` | `PHD_ERR_PRESET_NOT_OFFERED` | the worker: preset_load or preset_show of a stable name the plugin does not offer, or of a plugin with no preset system and no state |
| `-524` | `PHD_ERR_PRESET_INVALID` | preset_save with a file path that is not absolute or with no name, or preset_load or preset_show of a file that cannot be read, is no JSON, is of a format version other than org.openmixer.preset/1 and org.openmixer.preset/2, or breaks the format (docs/presets.md): the daemon answers it and no worker sees the file |
| `-525` | `PHD_ERR_PRESET_NEEDS_STATE` | the worker: a preset file that carries state for a worker that cannot take state yet |
| `-526` | `PHD_ERR_PRESET_PLUGIN_CHANGED` | the worker: the preset file's plugin id, version or fingerprint is not the instance's; nothing of it is applied |
| `-527` | `PHD_ERR_LOAD_REFUSED` | the worker: the plugin refused the preset or the state its file carries |
<!-- END GENERATED protocol:errors -->

A worker's own refusal is returned as it said it. An `add` that killed its worker answers mod-host's `-102`.
Expand Down
90 changes: 90 additions & 0 deletions docs/presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Presets in the host

The ruling "PRESETS IN THE HOST" of 2026-10-22 on plugin-hostd#131, as this tree builds it. The schema is
[protocol/preset.schema.json](../protocol/preset.schema.json); `tests/preset_format.py` holds it to the examples below.

## Who owns what

- The **worker** (omx-clap-host for CLAP, its LV2 adapter for LV2) holds the instance, so it owns the primitives: list
the plugin's own presets, load one, write the instance to a file, load a file.
- The **daemon** relays the verbs to the worker that holds the instance and passes its answer back; it answers -902
only while the worker does. Before a file reaches a worker it reads it and refuses one that is not a preset file of
a version it knows (`PRESET_INVALID`), so no worker guesses at a format.
- The **consumer** (the console, the Zynthian engine) owns the library: where files live, the user's presets, rename,
delete, banks. The host never decides a path or a name: both arrive as arguments.

MIDI learn is policy of the surfaces: the host answers `midi_learn`, `midi_map`, `midi_unmap`, `cv_map` and `cv_unmap`
only as the mechanism, and the host verb table keeps them optional for every profile.

## Verbs

| verb | arguments | reply |
| --- | --- | --- |
| `preset_list` | `<instance>` | `resp <count>`, then `preset_get <instance> <index>` one row each |
| `preset_load` | `<instance> <stable name \| file path>` | `resp 0` |
| `preset_save` | `<instance> <file path> <name>` | `resp 0`: the worker wrote the file |
| `preset_show` | `<instance> <stable name \| file path>` | `resp 0 <carries> <name>` |

A file path is absolute (it starts with `/`); any other word is a stable name of the plugin's own presets, the
`load_key` of `preset_get`. The name of `preset_save` is the rest of the line, quoted when it holds a space. A
`preset_show` whose first argument is no instance number is mod-host's form and goes to every worker as before.
`bundle_add` and `bundle_remove` stay as declared.

### Refusals

| code | name | who | when |
| --- | --- | --- | --- |
| -523 | `PRESET_NOT_OFFERED` | worker | a stable name the plugin does not offer, or a plugin with no preset system and no state |
| -524 | `PRESET_INVALID` | daemon, worker | a path that is not absolute, a `preset_save` with no name, a file that cannot be read, is no JSON, is of an unknown format version, or breaks the schema |
| -525 | `PRESET_NEEDS_STATE` | worker | a file that carries state for a worker that cannot take state yet; it disappears when every worker takes state |
| -526 | `PRESET_PLUGIN_CHANGED` | worker | the file's plugin id, version or fingerprint is not the instance's |
| -527 | `LOAD_REFUSED` | worker | the plugin refused the state or the preset (its load callback failed) |

## The file: org.openmixer.preset

The console's format, extended additively. `/1` stays valid and is params-only: our own plugins and their factory
presets never change. A file with a state body is `/2`.

| field | | meaning |
| --- | --- | --- |
| `format` | required | `org.openmixer.preset/1` or `org.openmixer.preset/2`; any other is refused, never guessed |
| `plugin.id` | required | the plugin's id: the CLAP id, or the LV2 uri |
| `plugin.version` | required in /2 | the plugin's version as its descriptor says |
| `plugin.fingerprint` | required in /2 | the descriptor fingerprint: the plugin's layout pin, `omx-layout/1:<sha256>`; a fingerprint of a scheme this version does not know is refused |
| `id` | required | stable, minted by the consumer; a worker writing a file names it after the file (its name without `.json`) |
| `name` | required | display, free text, editable on user presets only |
| `alias` | optional | a short label, at most 12 characters, for small displays (a scribble strip); absent means derive from `name` |
| `bank`, `tags`, `description` | optional | the consumer's |
| `createdAt`, `modifiedAt` | optional | ISO 8601 UTC, `YYYY-MM-DDTHH:MM:SSZ` |
| `carries` | required in /2 | `params`, `params+state` or `state` |
| `params` | | `[{symbol, unit, value}]`: plain values, written as the plugin gave them, never a float widened |
| `state` | | `{encoding: "base64", context, bytes, files?}`; `context` is `preset` or `project` for CLAP (the clap.state context), `lv2` for LV2; `files` `[{name, encoding, bytes}]` are LV2 state paths, a file name each |

The writer picks `carries`: `params` when the plugin has only declared parameters, otherwise `state` (`params+state`
when both are read). `carries` and the body agree: `params` has `params` and no `state`, `params+state` both, `state`
a `state`. Unknown fields are kept on rewrite.

A params-only file of /2:

```json
{"format": "org.openmixer.preset/2",
"plugin": {"id": "org.freemixer.comp", "version": "1.2.0",
"fingerprint": "omx-layout/1:0000000000000000000000000000000000000000000000000000000000000000"},
"id": "warm", "name": "Warm Pad", "createdAt": "2026-10-22T21:00:00Z", "modifiedAt": "2026-10-22T21:00:00Z",
"carries": "params", "params": [{"symbol": "gain", "unit": "dB", "value": -3.25}]}
```

A state file adds `"carries": "params+state"` and
`"state": {"encoding": "base64", "context": "preset", "bytes": "AAEC"}`.

## Loading a file

1. The daemon reads the file and checks it against the format above (`PRESET_INVALID` when it does not hold); no
worker is asked.
2. The worker compares `plugin` with the instance: another id, version or fingerprint answers `PRESET_PLUGIN_CHANGED`
and nothing is applied.
3. The worker applies the state body when the file carries one, then the parameter values, each clamped to its
declared range, an unknown symbol ignored, as clap.state's load does.

Parameter feedback (the `param_set` event a worker writes for a value the plugin moved by itself, a preset load
included) is the declared event of protocol/plugin-hostd.json; a controller's own writes produce none.
40 changes: 32 additions & 8 deletions include/plugin-hostd/protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,11 @@ static inline int phd_pool_name_valid(const char *name)
#define PHD_ERR_CHAIN_INSTANCE_BUSY (-520)
#define PHD_ERR_CHAIN_MIXED_FORMAT (-521)
#define PHD_ERR_NO_SUCH_CHAIN (-522)
#define PHD_ERR_PRESET_NOT_OFFERED (-523)
#define PHD_ERR_PRESET_INVALID (-524)
#define PHD_ERR_PRESET_NEEDS_STATE (-525)
#define PHD_ERR_PRESET_PLUGIN_CHANGED (-526)
#define PHD_ERR_LOAD_REFUSED (-527)

/* X(id, meaning) */
#define PHD_ERRORS(X) \
Expand Down Expand Up @@ -324,7 +329,17 @@ static inline int phd_pool_name_valid(const char *name)
PHD_VERB_CHAIN_CLEAR " of the other chain frees it") \
X(CHAIN_MIXED_FORMAT, "the members of " PHD_VERB_CHAIN " are not all of one format of " PHD_VERB_WORKER_LIST \
", so no one worker runs them: the chain is not declared") \
X(NO_SUCH_CHAIN, PHD_VERB_CHAIN_CLEAR " of a chain no " PHD_VERB_CHAIN " declared")
X(NO_SUCH_CHAIN, PHD_VERB_CHAIN_CLEAR " of a chain no " PHD_VERB_CHAIN " declared") \
X(PRESET_NOT_OFFERED, "the worker: preset_load or preset_show of a stable name the plugin does not offer, or of a " \
"plugin with no preset system and no state") \
X(PRESET_INVALID, "preset_save with a file path that is not absolute or with no name, or preset_load or " \
"preset_show of a file that cannot be read, is no JSON, is of a format version other than " \
PHD_PRESET_FORMAT_1 " and " PHD_PRESET_FORMAT_2 ", or breaks the format (docs/presets.md): the " \
"daemon answers it and no worker sees the file") \
X(PRESET_NEEDS_STATE, "the worker: a preset file that carries state for a worker that cannot take state yet") \
X(PRESET_PLUGIN_CHANGED, "the worker: the preset file's plugin id, version or fingerprint is not the instance's; " \
"nothing of it is applied") \
X(LOAD_REFUSED, "the worker: the plugin refused the preset or the state its file carries")

/* ---------------------------------------------------------------- verbs */

Expand Down Expand Up @@ -496,6 +511,13 @@ static inline int phd_pool_name_valid(const char *name)

/* the longest string word, in bytes: CLAP_NAME_SIZE - 1 */
#define PHD_STRING_MAX 255

/* the user-preset file a worker writes for preset_save and reads for preset_load and preset_show (docs/presets.md,
* protocol/preset.schema.json): /1 is params-only, /2 adds the state body */
#define PHD_PRESET_FORMAT_1 "org.openmixer.preset/1"
#define PHD_PRESET_FORMAT_2 "org.openmixer.preset/2"
#define PHD_PRESET_ALIAS_MAX 12
#define PHD_PRESET_FILE_MAX (64 * 1024 * 1024)
/* the parameters on one remote page, and the word of an empty slot */
#define PHD_REMOTE_PAGE_SLOTS 8
#define PHD_EMPTY_SLOT PHD_WORD_NONE
Expand Down Expand Up @@ -697,8 +719,10 @@ static inline int phd_is_note_verb(const char *verb)
#define PHD_HOST_VERB_SOURCE_DAEMON "daemon"

/* X(name, source, lv2-worker, clap-worker, console, zynthian). The mod-ui rows are the operator's ruling of
* 2026-10-21 (FreeMixer/plugin-hostd#131), read from Zynthian's verbs.tsv, with the ruling of 2026-10-22: presets,
* bundles, MIDI learn and CV addressing are required of the CLAP worker and of Zynthian. The others are this tree's.
* 2026-10-21 (FreeMixer/plugin-hostd#131), read from Zynthian's verbs.tsv, with the rulings of 2026-10-22: presets
* and bundles are required of the CLAP worker and of Zynthian; MIDI learn and CV addressing are policy of the surfaces,
* so the host answers them only as the mechanism and they stay optional for every profile ("PRESETS IN THE HOST",
* docs/presets.md). The others are this tree's.
* The daemon relays a worker's verb (an instance verb to the worker that holds the instance, any other to every worker)
* and passes its answer back, so a required verb answers -902 only while the worker does. */
#define PHD_HOST_VERBS(X) \
Expand All @@ -719,11 +743,11 @@ static inline int phd_is_note_verb(const char *verb)
X("transport", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \
X("transport_sync", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \
X("cpu_load", MOD_UI, REQUIRED, REQUIRED, REQUIRED, REQUIRED) \
X("midi_learn", MOD_UI, OPTIONAL, REQUIRED, OPTIONAL, REQUIRED) \
X("midi_map", MOD_UI, OPTIONAL, REQUIRED, OPTIONAL, REQUIRED) \
X("midi_unmap", MOD_UI, OPTIONAL, REQUIRED, OPTIONAL, REQUIRED) \
X("cv_map", MOD_UI, OPTIONAL, REQUIRED, OPTIONAL, REQUIRED) \
X("cv_unmap", MOD_UI, OPTIONAL, REQUIRED, OPTIONAL, REQUIRED) \
X("midi_learn", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \
X("midi_map", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \
X("midi_unmap", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \
X("cv_map", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \
X("cv_unmap", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \
X("monitor_midi_program", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \
X("monitor_output", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \
X("output_data_ready", MOD_UI, OPTIONAL, OPTIONAL, OPTIONAL, OPTIONAL) \
Expand Down
Loading
Loading