From 6d0138af7a28557c62cfd1fdc77d061470793a30 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Thu, 22 Oct 2026 21:25:55 +0000 Subject: [PATCH 1/3] doc: the org.openmixer.preset file, its schema, and the preset verbs and refusals (#131) --- docs/presets.md | 90 ++++++++++++++++++++++++++++++++ protocol/preset.schema.json | 100 ++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 docs/presets.md create mode 100644 protocol/preset.schema.json diff --git a/docs/presets.md b/docs/presets.md new file mode 100644 index 0000000..4e7ae72 --- /dev/null +++ b/docs/presets.md @@ -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` | `` | `resp `, then `preset_get ` one row each | +| `preset_load` | ` ` | `resp 0` | +| `preset_save` | ` ` | `resp 0`: the worker wrote the file | +| `preset_show` | ` ` | `resp 0 ` | + +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:`; 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. diff --git a/protocol/preset.schema.json b/protocol/preset.schema.json new file mode 100644 index 0000000..f0faf83 --- /dev/null +++ b/protocol/preset.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://freemixer.org/schemas/org.openmixer.preset.schema.json", + "title": "org.openmixer.preset", + "description": "A user preset file, as a worker writes it for preset_save and reads it for preset_load and preset_show (docs/presets.md). /1 is params-only and stays valid; /2 adds the state body, additively. Unknown fields are allowed and a writer keeps them; an unknown format version is refused, never guessed.", + "type": "object", + "required": ["format", "plugin", "id", "name"], + "properties": { + "format": {"enum": ["org.openmixer.preset/1", "org.openmixer.preset/2"]}, + "plugin": { + "type": "object", + "required": ["id"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "version": {"type": "string", "minLength": 1}, + "fingerprint": {"$ref": "#/$defs/fingerprint"} + } + }, + "id": {"type": "string", "minLength": 1}, + "name": {"type": "string"}, + "alias": {"type": "string", "minLength": 1, "maxLength": 12}, + "bank": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "description": {"type": "string"}, + "createdAt": {"$ref": "#/$defs/utc"}, + "modifiedAt": {"$ref": "#/$defs/utc"}, + "carries": {"enum": ["params", "params+state", "state"]}, + "params": { + "type": "array", + "items": { + "type": "object", + "required": ["symbol", "value"], + "properties": { + "symbol": {"type": "string", "minLength": 1}, + "unit": {"type": "string"}, + "value": {"type": "number"} + } + } + }, + "state": { + "type": "object", + "required": ["encoding", "context", "bytes"], + "properties": { + "encoding": {"const": "base64"}, + "context": {"enum": ["preset", "project", "lv2"]}, + "bytes": {"$ref": "#/$defs/base64"}, + "files": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "encoding", "bytes"], + "properties": { + "name": {"type": "string", "minLength": 1, "pattern": "^[^/]+$"}, + "encoding": {"const": "base64"}, + "bytes": {"$ref": "#/$defs/base64"} + } + } + } + } + } + }, + "allOf": [ + { + "if": {"properties": {"format": {"const": "org.openmixer.preset/1"}}}, + "then": { + "required": ["params"], + "properties": {"carries": {"const": "params"}}, + "not": {"required": ["state"]} + } + }, + { + "if": {"properties": {"format": {"const": "org.openmixer.preset/2"}}}, + "then": { + "required": ["carries"], + "properties": {"plugin": {"required": ["id", "version", "fingerprint"]}} + } + }, + { + "if": {"required": ["carries"], "properties": {"carries": {"const": "params"}}}, + "then": {"required": ["params"], "not": {"required": ["state"]}} + }, + { + "if": {"required": ["carries"], "properties": {"carries": {"const": "params+state"}}}, + "then": {"required": ["params", "state"]} + }, + { + "if": {"required": ["carries"], "properties": {"carries": {"const": "state"}}}, + "then": {"required": ["state"]} + } + ], + "$defs": { + "fingerprint": { + "description": "the plugin's descriptor fingerprint: its layout pin, : of a scheme this version knows", + "type": "string", + "pattern": "^omx-layout/1:[0-9a-f]{64}$" + }, + "utc": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$"}, + "base64": {"type": "string", "pattern": "^[A-Za-z0-9+/]*={0,2}$"} + } +} From 450a43f91a9a067c7d99208ad0012e55325bdb08 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Thu, 22 Oct 2026 21:30:38 +0000 Subject: [PATCH 2/3] tests: the preset schema takes the examples and refuses an unknown version or fingerprint scheme (#131) --- Makefile | 6 ++- tests/fixtures/presets/v1-params.json | 2 + tests/fixtures/presets/v2-params.json | 4 ++ tests/fixtures/presets/v2-state.json | 3 ++ tests/preset_format.py | 73 +++++++++++++++++++++++++++ tests/sabotage.py | 21 +++++++- 6 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/presets/v1-params.json create mode 100644 tests/fixtures/presets/v2-params.json create mode 100644 tests/fixtures/presets/v2-state.json create mode 100644 tests/preset_format.py diff --git a/Makefile b/Makefile index 7dad4a4..bcd0173 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 diff --git a/tests/fixtures/presets/v1-params.json b/tests/fixtures/presets/v1-params.json new file mode 100644 index 0000000..799917c --- /dev/null +++ b/tests/fixtures/presets/v1-params.json @@ -0,0 +1,2 @@ +{"format": "org.openmixer.preset/1", "plugin": {"id": "org.freemixer.comp"}, "id": "warm", "name": "Warm", + "params": [{"symbol": "gain", "unit": "dB", "value": -3.25}]} diff --git a/tests/fixtures/presets/v2-params.json b/tests/fixtures/presets/v2-params.json new file mode 100644 index 0000000..92e6761 --- /dev/null +++ b/tests/fixtures/presets/v2-params.json @@ -0,0 +1,4 @@ +{"format": "org.openmixer.preset/2", "plugin": {"id": "org.freemixer.comp", "version": "1.2.0", "fingerprint": "omx-layout/1:0000000000000000000000000000000000000000000000000000000000000000"}, + "id": "warm", "name": "Warm Pad", "alias": "WarmPad", "bank": "User", "tags": ["pad"], "description": "soft", + "createdAt": "2026-10-22T21:00:00Z", "modifiedAt": "2026-10-22T21:00:00.5Z", "carries": "params", + "params": [{"symbol": "gain", "unit": "dB", "value": -3.25}], "x-console": {"kept": true}} diff --git a/tests/fixtures/presets/v2-state.json b/tests/fixtures/presets/v2-state.json new file mode 100644 index 0000000..080db81 --- /dev/null +++ b/tests/fixtures/presets/v2-state.json @@ -0,0 +1,3 @@ +{"format": "org.openmixer.preset/2", "plugin": {"id": "urn:fake:lv2", "version": "0.3", "fingerprint": "omx-layout/1:0000000000000000000000000000000000000000000000000000000000000000"}, + "id": "lead", "name": "Lead", "carries": "params+state", "params": [{"symbol": "cutoff", "value": 0.1}], + "state": {"encoding": "base64", "context": "lv2", "bytes": "AAEC", "files": [{"name": "sample.wav", "encoding": "base64", "bytes": "UklGRg=="}]}} diff --git a/tests/preset_format.py b/tests/preset_format.py new file mode 100644 index 0000000..93bf790 --- /dev/null +++ b/tests/preset_format.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# The org.openmixer.preset file (docs/presets.md) against its schema, protocol/preset.schema.json: the files of +# tests/fixtures/presets hold, and each broken copy of them is refused: an unknown format version, a fingerprint of a +# scheme this version does not know, a /2 without its plugin's version or fingerprint, a carries the body does not +# agree with, an alias longer than 12, a /1 with a state body. +# +# python3 tests/preset_format.py [] the tree whose protocol/ is read (default: this one) +import copy, json, os, sys + +try: + import jsonschema +except ImportError: + sys.exit("check-preset-schema needs python3-jsonschema (dnf install python3-jsonschema, apt install python3-jsonschema)") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TREE = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else ROOT +FIXTURES = os.path.join(ROOT, "tests", "fixtures", "presets") + +with open(os.path.join(TREE, "protocol", "preset.schema.json")) as f: + schema = json.load(f) +jsonschema.Draft202012Validator.check_schema(schema) +validator = jsonschema.Draft202012Validator(schema) +good = {} +for name in sorted(os.listdir(FIXTURES)): + with open(os.path.join(FIXTURES, name)) as f: + good[name] = json.load(f) + + +def broken(name, change): + doc = copy.deepcopy(good[name]) + change(doc) + return doc + + +def drop(key): + return lambda d: d.pop(key) + + +REFUSED = [ + ("an unknown format version", "v2-params.json", lambda d: d.update(format="org.openmixer.preset/3")), + ("no format at all", "v1-params.json", drop("format")), + ("a fingerprint of an unknown scheme", "v2-params.json", + lambda d: d["plugin"].update(fingerprint="omx-layout/9:" + "0" * 64)), + ("a fingerprint that is no sha256", "v2-params.json", lambda d: d["plugin"].update(fingerprint="omx-layout/1:abc")), + ("a /2 without the plugin's fingerprint", "v2-params.json", lambda d: d["plugin"].pop("fingerprint")), + ("a /2 without the plugin's version", "v2-params.json", lambda d: d["plugin"].pop("version")), + ("a /2 without carries", "v2-params.json", drop("carries")), + ("params+state with the state body dropped", "v2-state.json", drop("state")), + ("carries params with a state body", "v2-state.json", lambda d: d.update(carries="params")), + ("carries params with no params", "v2-params.json", drop("params")), + ("a /1 with a state body", "v1-params.json", lambda d: d.update(state=good["v2-state.json"]["state"])), + ("an alias of 13 characters", "v2-params.json", lambda d: d.update(alias="A" * 13)), + ("a value that is a string", "v2-params.json", lambda d: d["params"][0].update(value="-3.25")), + ("a state of another encoding", "v2-state.json", lambda d: d["state"].update(encoding="hex")), + ("a state file with a path in its name", "v2-state.json", lambda d: d["state"]["files"][0].update(name="../x")), + ("a time that is not UTC", "v2-params.json", lambda d: d.update(createdAt="2026-10-22T21:00:00+02:00")), +] + +failed = 0 +for name, doc in good.items(): + errors = list(validator.iter_errors(doc)) + if errors: + failed += 1 + print("FAIL %s is refused: %s" % (name, errors[0].message)) + else: + print("ok %s holds" % name) +for what, name, change in REFUSED: + if validator.is_valid(broken(name, change)): + failed += 1 + print("FAIL %s is taken" % what) + else: + print("ok %s is refused" % what) +sys.exit(1 if failed else 0) diff --git a/tests/sabotage.py b/tests/sabotage.py index 526797c..3ea074a 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -15,7 +15,8 @@ # tests/readme_build.py, with no daemon to build; "probeschema" breaks protocol/probe-output.schema.json and runs # tests/probe_schema.py; "relay" breaks src/relay.c and runs tests/relay_test.c; "alloc" breaks src/relay.c or src/lv2.c and runs make test-alloc; "contract" breaks the probe runner or the generator and runs tests/contract_derived.py; "consumer" breaks the header and compiles tests/consumer.c against it; "probe" breaks src/probe.c and runs tests/probe_test.py; "nopins" breaks the packaging or a workflow and runs tests/no_pins.py; "ciguards" breaks a workflow and runs tests/ci_guards.py; # "vocabulary" breaks the probe schema, its template or a copy of omx-contract's omx-vocabulary.schema.json and runs -# tests/vocabulary_derived.py; "verbtable" breaks the header and runs tests/verb_table.py +# tests/vocabulary_derived.py; "verbtable" breaks the header and runs tests/verb_table.py; "presetschema" breaks +# protocol/preset.schema.json and runs tests/preset_format.py SABOTAGE = [ ("a runner that refuses unshare passes build-and-test with a warning", ".github/workflows/ci.yml", ' echo "::error::test-jack did not run: this runner refuses a private user and pid namespace with its own /proc"\n exit 1\n', @@ -660,6 +661,15 @@ 'chain_line(w, msg, sizeof(msg)) && replay_line(w, msg) != RPC_OK)', 'chain_line(w, msg, sizeof(msg)) && (replay_line_code(w, msg, &code) != RPC_OK || code < 0))', "a_worker_that_runs_no_chain_refuses_the_line_and_the_members_still_share_it"), + ("the preset schema takes a format version it does not know", "protocol/preset.schema.json", + '"enum": ["org.openmixer.preset/1", "org.openmixer.preset/2"]', '"type": "string"', + "an unknown format version is refused", "presetschema"), + ("the preset schema takes a fingerprint of any scheme", "protocol/preset.schema.json", + '"pattern": "^omx-layout/1:[0-9a-f]{64}$"', '"pattern": "^[^:]+:[0-9a-f]{64}$"', + "a fingerprint of an unknown scheme is refused", "presetschema"), + ("the preset schema takes params+state with no state body", "protocol/preset.schema.json", + '"then": {"required": ["params", "state"]}', '"then": {"required": ["params"]}', + "params+state with the state body dropped is refused", "presetschema"), ] @@ -763,6 +773,10 @@ def run_test(tree, name, jack=False): p = subprocess.run([sys.executable, os.path.join(ROOT, "tests", "verb_table.py"), tree], capture_output=True, text=True, timeout=60) return p.returncode, p.stdout + if jack == "presetschema": + p = subprocess.run([sys.executable, os.path.join(ROOT, "tests", "preset_format.py"), tree], capture_output=True, + text=True, timeout=60) + return p.returncode, p.stdout if jack in ("nopins", "ciguards"): script = {"nopins": "no_pins.py", "ciguards": "ci_guards.py"}[jack] p = subprocess.run([sys.executable, os.path.join(ROOT, "tests", script), tree], capture_output=True, @@ -815,6 +829,9 @@ def run_test(tree, name, jack=False): shutil.copytree(os.path.join(ROOT, "packaging"), os.path.join(tree, "packaging")) shutil.copy(os.path.join(ROOT, "Makefile"), tree) build_probe(tree) + elif jack == "presetschema": + os.makedirs(os.path.join(tree, "protocol")) + shutil.copy(os.path.join(ROOT, "protocol", "preset.schema.json"), os.path.join(tree, "protocol")) elif jack == "probeschema": os.makedirs(os.path.join(tree, "protocol")) shutil.copy(os.path.join(ROOT, "protocol", "probe-output.schema.json"), os.path.join(tree, "protocol")) @@ -858,7 +875,7 @@ def run_test(tree, name, jack=False): open(os.path.join(tree, path), "w").write(src.replace(text, repl)) if jack == "probe": build_probe(tree) - elif jack not in ("readme", "nopins", "ciguards", "format", "relay", "contract", "consumer", "probeschema", "vocabulary", "alloc", + elif jack not in ("readme", "nopins", "ciguards", "format", "relay", "contract", "consumer", "probeschema", "presetschema", "vocabulary", "alloc", "verbtable"): for f in os.listdir(os.path.join(tree, "src")): if f.endswith(".o"): From b09a77f2e6d31fbb09abba173c3f7695aa897f2e Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Thu, 22 Oct 2026 21:31:27 +0000 Subject: [PATCH 3/3] protocol: the preset refusals by code, the preset file formats, MIDI learn optional again (#131) --- README.md | 20 +++++++++++------ include/plugin-hostd/protocol.h | 40 ++++++++++++++++++++++++++------- protocol/plugin-hostd.json | 17 +++++++++----- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9d6a8c2..426134f 100644 --- a/README.md +++ b/README.md @@ -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). | verb | sent by | lv2-worker | clap-worker | console | zynthian | @@ -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 | @@ -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 | A worker's own refusal is returned as it said it. An `add` that killed its worker answers mod-host's `-102`. diff --git a/include/plugin-hostd/protocol.h b/include/plugin-hostd/protocol.h index 28c60e8..d12f650 100644 --- a/include/plugin-hostd/protocol.h +++ b/include/plugin-hostd/protocol.h @@ -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) \ @@ -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 */ @@ -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 @@ -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) \ @@ -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) \ diff --git a/protocol/plugin-hostd.json b/protocol/plugin-hostd.json index 3fff294..b9e4cea 100644 --- a/protocol/plugin-hostd.json +++ b/protocol/plugin-hostd.json @@ -47,7 +47,12 @@ {"name": "CHAIN_DUPLICATE", "macro": "PHD_ERR_CHAIN_DUPLICATE", "code": -519, "meaning": "an instance is named twice in one chain: the chain is not declared"}, {"name": "CHAIN_INSTANCE_BUSY", "macro": "PHD_ERR_CHAIN_INSTANCE_BUSY", "code": -520, "meaning": "a member of chain is already in another chain: the chain is not declared; chain_clear of the other chain frees it"}, {"name": "CHAIN_MIXED_FORMAT", "macro": "PHD_ERR_CHAIN_MIXED_FORMAT", "code": -521, "meaning": "the members of chain are not all of one format of worker_list, so no one worker runs them: the chain is not declared"}, - {"name": "NO_SUCH_CHAIN", "macro": "PHD_ERR_NO_SUCH_CHAIN", "code": -522, "meaning": "chain_clear of a chain no chain declared"} + {"name": "NO_SUCH_CHAIN", "macro": "PHD_ERR_NO_SUCH_CHAIN", "code": -522, "meaning": "chain_clear of a chain no chain declared"}, + {"name": "PRESET_NOT_OFFERED", "macro": "PHD_ERR_PRESET_NOT_OFFERED", "code": -523, "meaning": "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"}, + {"name": "PRESET_INVALID", "macro": "PHD_ERR_PRESET_INVALID", "code": -524, "meaning": "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"}, + {"name": "PRESET_NEEDS_STATE", "macro": "PHD_ERR_PRESET_NEEDS_STATE", "code": -525, "meaning": "the worker: a preset file that carries state for a worker that cannot take state yet"}, + {"name": "PRESET_PLUGIN_CHANGED", "macro": "PHD_ERR_PRESET_PLUGIN_CHANGED", "code": -526, "meaning": "the worker: the preset file's plugin id, version or fingerprint is not the instance's; nothing of it is applied"}, + {"name": "LOAD_REFUSED", "macro": "PHD_ERR_LOAD_REFUSED", "code": -527, "meaning": "the worker: the plugin refused the preset or the state its file carries"} ], "verbs": [ {"name": "add", "arguments": " [own | pool: | default] [client_name]", "reply": "resp ", "meaning": "mod-host's add with a placement; a token after the instance that is not a placement is the jack client name"}, @@ -103,11 +108,11 @@ {"name": "transport", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, {"name": "transport_sync", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, {"name": "cpu_load", "source": "mod-ui", "levels": {"lv2-worker": "required", "clap-worker": "required", "console": "required", "zynthian": "required"}}, - {"name": "midi_learn", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "required", "console": "optional", "zynthian": "required"}}, - {"name": "midi_map", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "required", "console": "optional", "zynthian": "required"}}, - {"name": "midi_unmap", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "required", "console": "optional", "zynthian": "required"}}, - {"name": "cv_map", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "required", "console": "optional", "zynthian": "required"}}, - {"name": "cv_unmap", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "required", "console": "optional", "zynthian": "required"}}, + {"name": "midi_learn", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "midi_map", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "midi_unmap", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "cv_map", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, + {"name": "cv_unmap", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, {"name": "monitor_midi_program", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, {"name": "monitor_output", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}}, {"name": "output_data_ready", "source": "mod-ui", "levels": {"lv2-worker": "optional", "clap-worker": "optional", "console": "optional", "zynthian": "optional"}},