@openmixer/omx-contract 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,141 +2,61 @@
2
2
  <!-- Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com> -->
3
3
  # omx-contract
4
4
 
5
- The FreeMixer declarations as data: what a value may be (a travel, a default, a kernel constant, a
6
- list), as JSON validated by a JSON Schema, rendered by one CLI into
7
- every language that reads it. Governed by openmixer's
8
- `docs/design/specs/2026-10-05-omx-contract.md`; 1.0.0 carries the phase-1 items of its §4.1, what
9
- omx-dsp needs, 1.1.0 adds the flanger, de-esser, phaser, rotary, tremolo and graphic EQ kernels, and
10
- 1.2.0 the chorus, drive, EQ, reverb and pan travels omx-dsp and omx-plugins still kept locally, and
11
- 1.3.0 everything else more than one repository reads: the feedback detector, ring correction and
12
- RTA constants, the EQ band budget, the choice enums (as the `set` kind), the EQ band counts and the
13
- one rule that gives a fresh EQ its bands, the measurement rate sets and the bus input cap. What is
14
- declared here is declared nowhere else: the EQ band budget that 1.2.0 left in openmixer is here too.
15
- 2.0.0 changes how a kernel file is written, not what it declares: every render is the same.
16
- 2.1.0 adds each kernel's ordered controls to the JSON render, and a control's `rearms` flag.
17
- 2.2.0 declares the controls an instance has that were missing: the delay's ping-pong, the drive's auto-gain, stereo
18
- link and HF roll-off, the compressor's kind and detector oversampling, and an EQ instance's switches, slopes and
19
- per-band controls (a control's `count`, a use, `of` and `when`, and a default by reference).
20
- 2.3.0 declares the input trim as a kernel, `trim`, whose one control is the console's TRIM_RANGE.
5
+ The FreeMixer declarations as data: what a value may be (a travel, a default, a kernel constant, a list),
6
+ written once as JSON, checked by a JSON Schema and rendered into the languages that read it. The mixer
7
+ engine, the DSP library and the plugins all take their ranges, defaults and constants from here, so a knob
8
+ means the same thing on the console, in a plugin and in a test.
9
+
10
+ - One declaration per value, one file per group (`data/`), validated against a closed schema.
11
+ - Renders for C (a header), TypeScript and resolved JSON, each held equal to the data by CI.
12
+ - Semantic versioning enforced by a check: a change that narrows or removes a declaration needs a major bump.
13
+
14
+ ## Install
15
+
16
+ Fedora 44:
21
17
 
22
18
  ```
23
- data/ the declaration, one file per group, edited by hand
24
- rates.json primitives.json kernels/<kernel>.json
25
- schema/omx-contract.schema.json JSON Schema 2020-12: five kinds (scalar, travels, sheet, list, set), closed units, refs, closed derivations
26
- lib/eq-defaults.mjs the one default rule for a fresh EQ's bands; the ts and c renders carry it
27
- bin/omx-contract.mjs the CLI
28
- render/ c.mjs (openmixer's renderer, moved unchanged, under c/), ts.mjs, json.mjs
29
- include/omxcontract/ the committed c render (what omx-contract-devel installs)
30
- share/omx-contract/ the committed json render (resolved data)
31
- test/ schema, semantic checks, goldens, perturbation, semver, round trip
32
- tools/proof/round-trip.mjs the spec §4.4 steps 1 and 2 proof against omx-dsp v0.1.3
33
- recipes/kernel.recipe.json what a complete kernel has; recipes/answers/ cites each value in the engine
34
- tools/omx-new-kernel.mjs the kernel wizard and importer; tools/kernel-recipe.mjs is `make completeness`
35
- tools/seed/ the one-time seed from openmixer's built core (provenance; never run by CI)
36
- packaging/ debian/ omx-contract-devel (RPM), libomx-contract-dev (deb)
19
+ sudo dnf config-manager addrepo --from-repofile=https://freemixer.github.io/rpm/freemixer.repo
20
+ sudo dnf install omx-contract-devel
37
21
  ```
38
22
 
39
- ## The kernel file
40
-
41
- A kernel file, `data/kernels/<kernel>.json`, holds four sections and nothing else. A sketch, values abridged
42
- (the tremolo's controls, a gate table, a constant):
43
-
44
- ```json
45
- {
46
- "$schema": "../../schema/omx-contract.schema.json",
47
- "controls": [
48
- { "name": "rateHz", "kind": "travel", "global": "TREMOLO_RATE_RANGE", "doc": "…", "travel": { "min": 0.1, "max": 20, "unit": "Hz", "default": 5 } },
49
- { "name": "depth", "kind": "travel", "doc": "…", "travel": { "min": 0, "max": 1, "unit": "", "default": 0.5 } },
50
- { "name": "mode", "kind": "choice", "doc": "…", "ids": ["tremolo", "pan"], "labels": ["Tremolo", "Pan"] },
51
- { "name": "thresholdDb", "kind": "travel", "table": "GATE_LIMITS", "travel": { "min": -80, "max": 0, "unit": "dB" } }
52
- ],
53
- "tables": { "GATE_LIMITS": { "doc": "…" } },
54
- "aggregates": { "TREMOLO_TRAVELS": { "doc": "…", "fields": ["rateHz", "depth"] } },
55
- "constants": { "CHORUS_BASE_MS": { "kind": "scalar", "doc": "…", "unit": "ms", "value": 10 } }
56
- }
23
+ Debian bookworm and trixie (also Raspberry Pi OS and Zynthian):
24
+
25
+ ```
26
+ curl -fsSL https://freemixer.github.io/deb/freemixer.asc | sudo tee /usr/share/keyrings/freemixer.asc >/dev/null
27
+ echo "deb [signed-by=/usr/share/keyrings/freemixer.asc] https://freemixer.github.io/deb/debian/$(. /etc/os-release; echo $VERSION_CODENAME) ./" | sudo tee /etc/apt/sources.list.d/freemixer.list
28
+ sudo apt update && sudo apt install libomx-contract-dev
57
29
  ```
58
30
 
59
- - `controls`, in order: a `travel` (one travel) or a `choice` (ordered `ids`, an optional `default` and `labels`).
60
- Its item name is `<KERNEL>_<NAME>_RANGE` for a travel and `<KERNEL>_<NAME>S` for a choice (`depth` in
61
- tremolo.json is `TREMOLO_DEPTH_RANGE`, `mode` is `TREMOLO_MODES`); `global` names it when it is anything else.
62
- A control with a `table` is one field of that table. `"rearms": true` marks a control whose change re-arms the
63
- kernel's state, so it is not a smooth parameter (the limiter's `lookaheadMs`: omx-dsp's limiter face re-arms
64
- the state and restarts the gain at unity when the look-ahead moves by a frame). It is a boolean, only on a
65
- control; it is no item, so the C and TypeScript renders do not carry it.
66
- - A USE is a control with a `global` and no `travel` or `ids`: it takes the item another control of the kernel
67
- declares, of its kind, and adds no item (the EQ's `lpfSlope` takes FILTER_SLOPES, which `hpfSlope` declares).
68
- - `count` names the item that counts a control the kernel takes once per band: a positive integer scalar
69
- (`GEQ_BANDS`), or a sheet whose every variant gives its count as `max` (`EQ_BAND_COUNTS.eq8.max`).
70
- - `of` and `when` mark a travel that is no control of its own but the travel the control `of` names reaches while
71
- the choice `when.control` holds `when.is` (the EQ's `notchQ`: the Q's travel while the band is a notch).
72
- - A choice's `default` may be `{ "ref": … }` to the scalar that states it; a boolean names the second id of a
73
- two-id switch when true, the first when false (the delay's `pingpong` comes up at FX_DELAY_PINGPONG_DEFAULT).
74
- - `tables`: a travels table whose fields are the controls that name it, in control order (`GATE_LIMITS`).
75
- - `aggregates`: a travels table whose fields are references to controls declared once above, listed by name.
76
- - `constants`: scalars, lists and sheets, as items.
77
-
78
- `omx-contract validate` refuses any other key, a constant that is a travel or a set, and a kernel file written
79
- as a flat item map (the 1.x shape). `rates.json` and `primitives.json` stay item maps.
80
-
81
- ## The JSON render's kernels
82
-
83
- Beside `items`, `share/omx-contract/omx-contract.json` carries `kernels`: for every kernel file, its controls in
84
- the order the file declares them. A consumer reads control order and kinds here, never from a kernel file.
85
-
86
- ```json
87
- "kernels": {
88
- "limiter": {
89
- "controls": [
90
- { "name": "ceilingDb", "kind": "travel", "global": "LIMITER_LIMITS", "table": "LIMITER_LIMITS" },
91
- { "name": "lookaheadMs", "kind": "travel", "global": "LIMITER_LIMITS", "table": "LIMITER_LIMITS", "rearms": true },
92
- { "name": "releaseMs", "kind": "travel", "global": "LIMITER_LIMITS", "table": "LIMITER_LIMITS" }
93
- ]
94
- },
95
- "tremolo": {
96
- "controls": [
97
- { "name": "rateHz", "kind": "travel", "global": "TREMOLO_RATE_RANGE" },
98
- { "name": "mode", "kind": "choice", "global": "TREMOLO_MODES" }
99
- ]
100
- }
101
- }
31
+ JavaScript and TypeScript projects (the `ts` render is the main export, the CLI is `omx-contract`):
32
+
33
+ ```
34
+ npm install @openmixer/omx-contract
102
35
  ```
103
36
 
104
- - `kind`: `travel` or `choice`.
105
- - `global`: the item the control's value lives in: `items[global].value` for a travel or a choice, and
106
- `items[global].value[name]` for a field of a table, when `table` (the same item) is present.
107
- - `rearms`: present, and `true`, only on a control that declares it.
108
- - `count`: present on a control taken once per band, the item that counts it.
109
- - `when`: on a control another one is `of`, the list `{ control, is, global }`: while `control` holds `is`, the
110
- control's travel is `items[global]` (the EQ's `q` reaches EQ_NOTCH_Q_RANGE on a notch). A control that is `of`
111
- another is not listed on its own. A use is listed like any control, its `global` the item it takes.
37
+ Every GitHub release also carries the `npm pack` tarball, the RPMs and the debs.
112
38
 
113
- A kernel with no controls (fbs, mixmatrix, rta) is listed with an empty `controls`.
39
+ ## Use
114
40
 
115
- ## The CLI
41
+ Include the C header and read the constants by name:
116
42
 
117
- ```
118
- npm ci
119
- node bin/omx-contract.mjs validate # schema + semantic checks
120
- node bin/omx-contract.mjs fmt [--check] # the one formatting
121
- node bin/omx-contract.mjs render --target c|ts|json --out <dir> [--check]
122
- node bin/omx-contract.mjs semver <previous tag> # exit 1 if the version bump is short
43
+ ```c
44
+ #include <omxcontract/omx_contract_limits.h>
123
45
  ```
124
46
 
125
- Exit 0 is a pass, 1 a failure naming file, item and rule, 2 a usage or environment error. A change to
126
- `data/` is followed by `render --target c --out include`, `render --target json --out
127
- share/omx-contract` and `render --target ts --out test/golden/ts`; CI holds all three to the data.
47
+ ```
48
+ pkg-config --cflags omx-contract
49
+ ```
128
50
 
129
- ## Consuming it
51
+ Pin one version and build against its renders instead of keeping a copy: the installed package at exactly
52
+ that version (`pkg-config --exact-version=2.5.0 omx-contract`), else the npm tarball, unpacked, and
53
+ `omx-contract render --target c --out build/omx-contract/include`. A kernel's controls and readouts, in their
54
+ order, are in `share/omx-contract/omx-contract.json`; how a declaration is written is in
55
+ [docs/kernel-files.md](docs/kernel-files.md), and what a readout is in [docs/readouts.md](docs/readouts.md).
130
56
 
131
- Pin one version (`omx-contract 2.3.0` in `.github/pins.txt`) and build against its renders, committing
132
- none: the installed `omx-contract-devel` / `libomx-contract-dev` at exactly that version
133
- (`pkg-config --exact-version=2.3.0 omx-contract`), else the release's npm tarball, unpacked, and
134
- `omx-contract render --target c --out build/omx-contract/include`. Include
135
- `<omxcontract/omx_contract_limits.h>`. A plugin's parameter header is rendered by omx-plugins from its
136
- declaration, by reference to the kernel travels here (since 1.1.0).
57
+ ## Building
137
58
 
138
- The npm package is `@openmixer/omx-contract` on npmjs.org (the `ts` render is its main export); every
139
- GitHub release also carries the `npm pack` tarball, the RPMs and the debs.
59
+ See [BUILDING.md](BUILDING.md).
140
60
 
141
61
  ## License
142
62
 
@@ -122,6 +122,32 @@
122
122
  "Off",
123
123
  "4x"
124
124
  ]
125
+ },
126
+ {
127
+ "name": "detectorTopology",
128
+ "kind": "choice",
129
+ "global": "DETECTOR_TOPOLOGY",
130
+ "doc": "Where the compressor's detector listens: `feedforward` hears the slot's input (or its key), `feedback` hears the slot's own pre-make-up output DYN_FB_LOOP_DELAY_SAMPLES late, through one envelope stage, and a keyed slot stays feed-forward (omx-dsp's OMX_DYN_TOPOLOGY_FEEDFORWARD 0 and _FEEDBACK 1, in that order; the set is named for its control, so C spells them OMX_DETECTOR_TOPOLOGY_*). The console's comp row carries it (`detectorTopology`, openmixer's ssl-bus-compressor spec §3a) and comes up `feedforward`, so a zeroed atom is the compressor every slot runs.",
131
+ "ids": [
132
+ "feedforward",
133
+ "feedback"
134
+ ],
135
+ "default": "feedforward"
136
+ }
137
+ ],
138
+ "readouts": [
139
+ {
140
+ "name": "gainReductionDb",
141
+ "doc": "The compressor's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to COMP_LIMITS.thresholdDb.min: on a signal at or below 0 dBFS the reduction (x - T)(1 - 1/R) never exceeds -T, and T is at least the threshold's minimum.",
142
+ "unit": "dB",
143
+ "min": {
144
+ "ref": "COMP_LIMITS.thresholdDb.min"
145
+ },
146
+ "max": 0,
147
+ "scale": "linear",
148
+ "rateHz": {
149
+ "ref": "READOUT_RATE_HZ"
150
+ }
125
151
  }
126
152
  ],
127
153
  "tables": {
@@ -145,6 +171,12 @@
145
171
  }
146
172
  }
147
173
  }
174
+ },
175
+ "DYN_FB_LOOP_DELAY_SAMPLES": {
176
+ "kind": "scalar",
177
+ "doc": "The feedback detector's loop delay, in samples: the output y[n] depends on the gain at n, so the detector can only have heard y[n - 1]. Not a latency: the audio is never delayed by it. omx-dsp's feedback state holds exactly this many output samples per leg.",
178
+ "unit": "",
179
+ "value": 1
148
180
  }
149
181
  }
150
182
  }
@@ -110,6 +110,21 @@
110
110
  ]
111
111
  }
112
112
  ],
113
+ "readouts": [
114
+ {
115
+ "name": "gainReductionDb",
116
+ "doc": "The de-esser's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to DEESS_RANGE_RANGE.min: the de-esser reduces by its range at most.",
117
+ "unit": "dB",
118
+ "min": {
119
+ "ref": "DEESS_RANGE_RANGE.min"
120
+ },
121
+ "max": 0,
122
+ "scale": "linear",
123
+ "rateHz": {
124
+ "ref": "READOUT_RATE_HZ"
125
+ }
126
+ }
127
+ ],
113
128
  "constants": {
114
129
  "DEESS_KNEE_DB": {
115
130
  "kind": "scalar",
@@ -128,6 +128,21 @@
128
128
  }
129
129
  }
130
130
  ],
131
+ "readouts": [
132
+ {
133
+ "name": "gainReductionDb",
134
+ "doc": "The gate's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to GATE_LIMITS.rangeDb.min: the gate attenuates by its range at most.",
135
+ "unit": "dB",
136
+ "min": {
137
+ "ref": "GATE_LIMITS.rangeDb.min"
138
+ },
139
+ "max": 0,
140
+ "scale": "linear",
141
+ "rateHz": {
142
+ "ref": "READOUT_RATE_HZ"
143
+ }
144
+ }
145
+ ],
131
146
  "tables": {
132
147
  "GATE_LIMITS": {
133
148
  "doc": "The legal range of each gate field (engineering units). The widget clamps drags to these, `normalizeGate` enforces them, the row's codec refuses by them and the travel sheet publishes them — one source of truth. `rangeDb` reaches GATE_RANGE_FLOOR_DB, shown as \"−∞\" in readouts (a gate fully closed is silence)."
@@ -42,6 +42,21 @@
42
42
  }
43
43
  }
44
44
  ],
45
+ "readouts": [
46
+ {
47
+ "name": "gainReductionDb",
48
+ "doc": "The limiter's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to LIMITER_LIMITS.ceilingDb.min: on a signal at or below 0 dBFS the limiter pulls the peak down to its ceiling at most, and the ceiling is at least its minimum.",
49
+ "unit": "dB",
50
+ "min": {
51
+ "ref": "LIMITER_LIMITS.ceilingDb.min"
52
+ },
53
+ "max": 0,
54
+ "scale": "linear",
55
+ "rateHz": {
56
+ "ref": "READOUT_RATE_HZ"
57
+ }
58
+ }
59
+ ],
45
60
  "tables": {
46
61
  "LIMITER_LIMITS": {
47
62
  "doc": "The precision limiter's travels (`2026-09-27-precision-limiter.md` §2), rendered into `omx_contract_limits.h` as `OMX_LIMITER_*` by `harness/contract-limits-gen.mjs`, so `mix_limiter.h` reads them and never restates one (R-094). The ceiling is a TRUE peak in dBFS (the ×4 detector); the look-ahead sets the declared latency; the release is the envelope's time constant on the gain depth."
@@ -154,5 +154,12 @@
154
154
  "scale": "log"
155
155
  }
156
156
  }
157
+ },
158
+ "READOUT_RATE_HZ": {
159
+ "kind": "scalar",
160
+ "since": "2.4.0",
161
+ "doc": "How often a readout's value is published: 50 Hz, one value every 20 ms, the rate a host sends a moving meter at. Every readout reads it as its `rateHz` (docs/readouts.md).",
162
+ "unit": "Hz",
163
+ "value": 50
157
164
  }
158
165
  }
@@ -254,6 +254,12 @@ export declare const COMP_KINDS: readonly ["comp", "limiter"];
254
254
  */
255
255
  export declare const DETECTOR_OVERSAMPLINGS: readonly ["auto", "off", "x4"];
256
256
 
257
+ /**
258
+ * Where the compressor's detector listens: `feedforward` hears the slot's input (or its key), `feedback` hears the slot's own pre-make-up output DYN_FB_LOOP_DELAY_SAMPLES late, through one envelope stage, and a keyed slot stays feed-forward (omx-dsp's OMX_DYN_TOPOLOGY_FEEDFORWARD 0 and _FEEDBACK 1, in that order; the set is named for its control, so C spells them OMX_DETECTOR_TOPOLOGY_*). The console's comp row carries it (`detectorTopology`, openmixer's ssl-bus-compressor spec §3a) and comes up `feedforward`, so a zeroed atom is the compressor every slot runs.
259
+ * @see data/kernels/comp.json
260
+ */
261
+ export declare const DETECTOR_TOPOLOGY: readonly ["feedforward", "feedback"];
262
+
257
263
  /**
258
264
  * The program-dependent release's model constants — the LA-2A's opto cell as DATA (`docs/design/specs/2026-09-26-compressor-models.md` §3b). `chargeMs`/`dischargeMs` are the word's memory constants, shared by every profile; each profile's `fastMs`, `slowMinMs`, `slowMaxMs` and `share` fit the unit's published two-stage recovery. Not operator controls: generated into `omx_contract_limits.h` as `OMX_PROGRAM_RELEASE_*`, which libomxdsp's `omx_env_program_release` reads.
259
265
  * @see data/kernels/comp.json
@@ -271,6 +277,12 @@ export declare const PROGRAM_RELEASE: {
271
277
  };
272
278
  };
273
279
 
280
+ /**
281
+ * The feedback detector's loop delay, in samples: the output y[n] depends on the gain at n, so the detector can only have heard y[n - 1]. Not a latency: the audio is never delayed by it. omx-dsp's feedback state holds exactly this many output samples per leg.
282
+ * @see data/kernels/comp.json
283
+ */
284
+ export declare const DYN_FB_LOOP_DELAY_SAMPLES: 1;
285
+
274
286
  /**
275
287
  * The detector band's CENTRE, in Hz. Sibilance lives at 5–10 kHz on most voices; 7 kHz with the default width covers 4 950–9 900 Hz, which is where an "s" and a "t" sit on a close vocal mic. The travel is wider than the come-up on both sides on purpose: a bright female vocal wants the band up near 9 kHz and a chesty male one down at 5, and a surface narrower than the row removes reach the console genuinely has (the same reasoning core's `REVERB_CUT_RANGE` records).
276
288
  * @see data/kernels/deesser.json
@@ -2020,6 +2032,12 @@ export declare const EQ_CONTINUOUS_TRAVELS: {
2020
2032
  };
2021
2033
  };
2022
2034
 
2035
+ /**
2036
+ * How often a readout's value is published: 50 Hz, one value every 20 ms, the rate a host sends a moving meter at. Every readout reads it as its `rateHz` (docs/readouts.md).
2037
+ * @see data/primitives.json
2038
+ */
2039
+ export declare const READOUT_RATE_HZ: 50;
2040
+
2023
2041
  /**
2024
2042
  * The standard sample rates (Hz) the desk recognises — the pro-audio superset. One source of truth for the clock controller's `clock.allowed-rates` seed, the force row's validation ceiling and the device-rate probe fallback. What the operator is OFFERED is always the DEVICE's own `supportedRates` (`2026-07-15-clock-rate-honest-force-rate.md`) — this is the ceiling a write is refused by, never a menu.
2025
2043
  * @see data/rates.json
@@ -2053,6 +2071,50 @@ export declare const ORACLE_FLOOR_RATES: readonly [44100, 48000, 96000, 192000];
2053
2071
  */
2054
2072
  export declare const RME_RATES: readonly [32000, 44100, 48000, 64000, 88200, 96000, 128000, 176400, 192000];
2055
2073
 
2074
+ /**
2075
+ * Every kernel's readouts, the meters a host draws, each kernel's in its declared order: the position is the
2076
+ * readout's id. Every reference is resolved.
2077
+ * @see docs/readouts.md
2078
+ */
2079
+ export declare const READOUTS: {
2080
+ readonly comp: readonly [{
2081
+ readonly name: "gainReductionDb";
2082
+ readonly doc: "The compressor's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to COMP_LIMITS.thresholdDb.min: on a signal at or below 0 dBFS the reduction (x - T)(1 - 1/R) never exceeds -T, and T is at least the threshold's minimum.";
2083
+ readonly unit: "dB";
2084
+ readonly min: -60;
2085
+ readonly max: 0;
2086
+ readonly scale: "linear";
2087
+ readonly rateHz: 50;
2088
+ }];
2089
+ readonly deesser: readonly [{
2090
+ readonly name: "gainReductionDb";
2091
+ readonly doc: "The de-esser's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to DEESS_RANGE_RANGE.min: the de-esser reduces by its range at most.";
2092
+ readonly unit: "dB";
2093
+ readonly min: -24;
2094
+ readonly max: 0;
2095
+ readonly scale: "linear";
2096
+ readonly rateHz: 50;
2097
+ }];
2098
+ readonly gate: readonly [{
2099
+ readonly name: "gainReductionDb";
2100
+ readonly doc: "The gate's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to GATE_LIMITS.rangeDb.min: the gate attenuates by its range at most.";
2101
+ readonly unit: "dB";
2102
+ readonly min: -90;
2103
+ readonly max: 0;
2104
+ readonly scale: "linear";
2105
+ readonly rateHz: 50;
2106
+ }];
2107
+ readonly limiter: readonly [{
2108
+ readonly name: "gainReductionDb";
2109
+ readonly doc: "The limiter's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to LIMITER_LIMITS.ceilingDb.min: on a signal at or below 0 dBFS the limiter pulls the peak down to its ceiling at most, and the ceiling is at least its minimum.";
2110
+ readonly unit: "dB";
2111
+ readonly min: -12;
2112
+ readonly max: 0;
2113
+ readonly scale: "linear";
2114
+ readonly rateHz: 50;
2115
+ }];
2116
+ };
2117
+
2056
2118
  /** The default band centres (Hz) of an EQ with `count` bands: THE one rule, for every strip, host and count. */
2057
2119
  export declare function eqDefaultCentres(count: number): number[];
2058
2120
  /** The default band types, by EQ_BAND_TYPES id: a low shelf, bells, a high shelf (a single band is a bell). */
@@ -264,6 +264,15 @@ export const DETECTOR_OVERSAMPLINGS = [
264
264
  "x4"
265
265
  ];
266
266
 
267
+ /**
268
+ * Where the compressor's detector listens: `feedforward` hears the slot's input (or its key), `feedback` hears the slot's own pre-make-up output DYN_FB_LOOP_DELAY_SAMPLES late, through one envelope stage, and a keyed slot stays feed-forward (omx-dsp's OMX_DYN_TOPOLOGY_FEEDFORWARD 0 and _FEEDBACK 1, in that order; the set is named for its control, so C spells them OMX_DETECTOR_TOPOLOGY_*). The console's comp row carries it (`detectorTopology`, openmixer's ssl-bus-compressor spec §3a) and comes up `feedforward`, so a zeroed atom is the compressor every slot runs.
269
+ * @see data/kernels/comp.json
270
+ */
271
+ export const DETECTOR_TOPOLOGY = [
272
+ "feedforward",
273
+ "feedback"
274
+ ];
275
+
267
276
  /**
268
277
  * The program-dependent release's model constants — the LA-2A's opto cell as DATA (`docs/design/specs/2026-09-26-compressor-models.md` §3b). `chargeMs`/`dischargeMs` are the word's memory constants, shared by every profile; each profile's `fastMs`, `slowMinMs`, `slowMaxMs` and `share` fit the unit's published two-stage recovery. Not operator controls: generated into `omx_contract_limits.h` as `OMX_PROGRAM_RELEASE_*`, which libomxdsp's `omx_env_program_release` reads.
269
278
  * @see data/kernels/comp.json
@@ -281,6 +290,12 @@ export const PROGRAM_RELEASE = {
281
290
  }
282
291
  };
283
292
 
293
+ /**
294
+ * The feedback detector's loop delay, in samples: the output y[n] depends on the gain at n, so the detector can only have heard y[n - 1]. Not a latency: the audio is never delayed by it. omx-dsp's feedback state holds exactly this many output samples per leg.
295
+ * @see data/kernels/comp.json
296
+ */
297
+ export const DYN_FB_LOOP_DELAY_SAMPLES = 1;
298
+
284
299
  /**
285
300
  * The detector band's CENTRE, in Hz. Sibilance lives at 5–10 kHz on most voices; 7 kHz with the default width covers 4 950–9 900 Hz, which is where an "s" and a "t" sit on a close vocal mic. The travel is wider than the come-up on both sides on purpose: a bright female vocal wants the band up near 9 kHz and a chesty male one down at 5, and a surface narrower than the row removes reach the console genuinely has (the same reasoning core's `REVERB_CUT_RANGE` records).
286
301
  * @see data/kernels/deesser.json
@@ -2201,6 +2216,12 @@ export const EQ_CONTINUOUS_TRAVELS = {
2201
2216
  }
2202
2217
  };
2203
2218
 
2219
+ /**
2220
+ * How often a readout's value is published: 50 Hz, one value every 20 ms, the rate a host sends a moving meter at. Every readout reads it as its `rateHz` (docs/readouts.md).
2221
+ * @see data/primitives.json
2222
+ */
2223
+ export const READOUT_RATE_HZ = 50;
2224
+
2204
2225
  /**
2205
2226
  * The standard sample rates (Hz) the desk recognises — the pro-audio superset. One source of truth for the clock controller's `clock.allowed-rates` seed, the force row's validation ceiling and the device-rate probe fallback. What the operator is OFFERED is always the DEVICE's own `supportedRates` (`2026-07-15-clock-rate-honest-force-rate.md`) — this is the ceiling a write is refused by, never a menu.
2206
2227
  * @see data/rates.json
@@ -2256,6 +2277,58 @@ export const RME_RATES = [
2256
2277
  192000
2257
2278
  ];
2258
2279
 
2280
+ /**
2281
+ * Every kernel's readouts, the meters a host draws, each kernel's in its declared order: the position is the
2282
+ * readout's id. Every reference is resolved.
2283
+ * @see docs/readouts.md
2284
+ */
2285
+ export const READOUTS = {
2286
+ "comp": [
2287
+ {
2288
+ "name": "gainReductionDb",
2289
+ "doc": "The compressor's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to COMP_LIMITS.thresholdDb.min: on a signal at or below 0 dBFS the reduction (x - T)(1 - 1/R) never exceeds -T, and T is at least the threshold's minimum.",
2290
+ "unit": "dB",
2291
+ "min": -60,
2292
+ "max": 0,
2293
+ "scale": "linear",
2294
+ "rateHz": 50
2295
+ }
2296
+ ],
2297
+ "deesser": [
2298
+ {
2299
+ "name": "gainReductionDb",
2300
+ "doc": "The de-esser's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to DEESS_RANGE_RANGE.min: the de-esser reduces by its range at most.",
2301
+ "unit": "dB",
2302
+ "min": -24,
2303
+ "max": 0,
2304
+ "scale": "linear",
2305
+ "rateHz": 50
2306
+ }
2307
+ ],
2308
+ "gate": [
2309
+ {
2310
+ "name": "gainReductionDb",
2311
+ "doc": "The gate's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to GATE_LIMITS.rangeDb.min: the gate attenuates by its range at most.",
2312
+ "unit": "dB",
2313
+ "min": -90,
2314
+ "max": 0,
2315
+ "scale": "linear",
2316
+ "rateHz": 50
2317
+ }
2318
+ ],
2319
+ "limiter": [
2320
+ {
2321
+ "name": "gainReductionDb",
2322
+ "doc": "The limiter's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to LIMITER_LIMITS.ceilingDb.min: on a signal at or below 0 dBFS the limiter pulls the peak down to its ceiling at most, and the ceiling is at least its minimum.",
2323
+ "unit": "dB",
2324
+ "min": -12,
2325
+ "max": 0,
2326
+ "scale": "linear",
2327
+ "rateHz": 50
2328
+ }
2329
+ ]
2330
+ };
2331
+
2259
2332
  /** The declared parameters of the default EQ rule: the four-band set, the preferred series, the band frequency travel, the band gain and Q. */
2260
2333
  const EQ_DEFAULT_RULE = {
2261
2334
  "four": [
@@ -279,6 +279,7 @@
279
279
  #define OMX_DRIVE_TRIM_RANGE_STEP 0.1f
280
280
  #define OMX_DRIVE_TRIM_RANGE_STEP_DOUBLE 0.1
281
281
  #define OMX_DSP_KNOB_REFERENCE_RATE 96000
282
+ #define OMX_DYN_FB_LOOP_DELAY_SAMPLES 1
282
283
  #define OMX_EQ_BAND_COUNTS_CHANNEL_DEFAULT 4
283
284
  #define OMX_EQ_BAND_COUNTS_CHANNEL_MAX 24
284
285
  #define OMX_EQ_BAND_COUNTS_EQ16_DEFAULT 16
@@ -450,6 +451,7 @@
450
451
  #define OMX_PROGRAM_RELEASE_PROFILES_OPTO_SLOW_MAX_MS 1100
451
452
  #define OMX_PROGRAM_RELEASE_PROFILES_OPTO_SLOW_MIN_MS 110
452
453
  #define OMX_PSHR_MIN_DB 10
454
+ #define OMX_READOUT_RATE_HZ 50
453
455
  #define OMX_REVERB_DAMPING_RANGE_DEFAULT 0.5f
454
456
  #define OMX_REVERB_DAMPING_RANGE_DEFAULT_DOUBLE 0.5
455
457
  #define OMX_REVERB_DAMPING_RANGE_MAX 1
@@ -651,6 +653,12 @@ enum omx_detector_oversamplings {
651
653
  };
652
654
  #define OMX_DETECTOR_OVERSAMPLINGS_COUNT 3u
653
655
  #define OMX_DETECTOR_OVERSAMPLINGS_DEFAULT OMX_DETECTOR_OVERSAMPLINGS_AUTO
656
+ enum omx_detector_topology {
657
+ OMX_DETECTOR_TOPOLOGY_FEEDFORWARD = 0,
658
+ OMX_DETECTOR_TOPOLOGY_FEEDBACK = 1,
659
+ };
660
+ #define OMX_DETECTOR_TOPOLOGY_COUNT 2u
661
+ #define OMX_DETECTOR_TOPOLOGY_DEFAULT OMX_DETECTOR_TOPOLOGY_FEEDFORWARD
654
662
  enum omx_drive_auto_gains {
655
663
  OMX_DRIVE_AUTO_GAINS_OFF = 0,
656
664
  OMX_DRIVE_AUTO_GAINS_ON = 1,
@@ -750,6 +758,46 @@ enum omx_tremolo_modes {
750
758
  #define OMX_TREMOLO_MODES_COUNT 2u
751
759
  #define OMX_TREMOLO_MODES_DEFAULT OMX_TREMOLO_MODES_TREMOLO
752
760
 
761
+ /* Every kernel's readouts, the meters a host draws (docs/readouts.md): an enum of the readouts at their position,
762
+ * `OMX_<KERNEL>_READOUT_COUNT`, and per readout its display range (_MIN, _MAX), _RATE_HZ, _SCALE (an OMX_PARAM_SCALES_*
763
+ * member) and _UNIT. */
764
+ enum omx_comp_readouts {
765
+ OMX_COMP_READOUT_GAIN_REDUCTION_DB = 0,
766
+ };
767
+ #define OMX_COMP_READOUT_COUNT 1u
768
+ #define OMX_COMP_READOUT_GAIN_REDUCTION_DB_MIN -60.0f
769
+ #define OMX_COMP_READOUT_GAIN_REDUCTION_DB_MAX 0.0f
770
+ #define OMX_COMP_READOUT_GAIN_REDUCTION_DB_RATE_HZ 50
771
+ #define OMX_COMP_READOUT_GAIN_REDUCTION_DB_SCALE OMX_PARAM_SCALES_LINEAR
772
+ #define OMX_COMP_READOUT_GAIN_REDUCTION_DB_UNIT "dB"
773
+ enum omx_deesser_readouts {
774
+ OMX_DEESSER_READOUT_GAIN_REDUCTION_DB = 0,
775
+ };
776
+ #define OMX_DEESSER_READOUT_COUNT 1u
777
+ #define OMX_DEESSER_READOUT_GAIN_REDUCTION_DB_MIN -24.0f
778
+ #define OMX_DEESSER_READOUT_GAIN_REDUCTION_DB_MAX 0.0f
779
+ #define OMX_DEESSER_READOUT_GAIN_REDUCTION_DB_RATE_HZ 50
780
+ #define OMX_DEESSER_READOUT_GAIN_REDUCTION_DB_SCALE OMX_PARAM_SCALES_LINEAR
781
+ #define OMX_DEESSER_READOUT_GAIN_REDUCTION_DB_UNIT "dB"
782
+ enum omx_gate_readouts {
783
+ OMX_GATE_READOUT_GAIN_REDUCTION_DB = 0,
784
+ };
785
+ #define OMX_GATE_READOUT_COUNT 1u
786
+ #define OMX_GATE_READOUT_GAIN_REDUCTION_DB_MIN -90.0f
787
+ #define OMX_GATE_READOUT_GAIN_REDUCTION_DB_MAX 0.0f
788
+ #define OMX_GATE_READOUT_GAIN_REDUCTION_DB_RATE_HZ 50
789
+ #define OMX_GATE_READOUT_GAIN_REDUCTION_DB_SCALE OMX_PARAM_SCALES_LINEAR
790
+ #define OMX_GATE_READOUT_GAIN_REDUCTION_DB_UNIT "dB"
791
+ enum omx_limiter_readouts {
792
+ OMX_LIMITER_READOUT_GAIN_REDUCTION_DB = 0,
793
+ };
794
+ #define OMX_LIMITER_READOUT_COUNT 1u
795
+ #define OMX_LIMITER_READOUT_GAIN_REDUCTION_DB_MIN -12.0f
796
+ #define OMX_LIMITER_READOUT_GAIN_REDUCTION_DB_MAX 0.0f
797
+ #define OMX_LIMITER_READOUT_GAIN_REDUCTION_DB_RATE_HZ 50
798
+ #define OMX_LIMITER_READOUT_GAIN_REDUCTION_DB_SCALE OMX_PARAM_SCALES_LINEAR
799
+ #define OMX_LIMITER_READOUT_GAIN_REDUCTION_DB_UNIT "dB"
800
+
753
801
  /* The default bands of an EQ with `count` bands: THE one rule, for every strip, host and count (omx-contract's
754
802
  * lib/eq-defaults.mjs; the ts render exports the same as eqDefaultCentres and eqDefaultTypes). Needs -lm. */
755
803
  static inline unsigned omx_eq_default_centres(unsigned count, float *out) {
package/lib/data.mjs CHANGED
@@ -45,9 +45,12 @@ export function loadData(dir, texts) {
45
45
  const snakeOf = (n) => n.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
46
46
  const camelOf = (u) => u.toLowerCase().replace(/_([a-z0-9])/g, (_, x) => x.toUpperCase());
47
47
 
48
- /** The four sections of a kernel file, in their order. */
48
+ /** The four sections of a kernel file that declare items, in their order. */
49
49
  export const KERNEL_SECTIONS = ['controls', 'tables', 'aggregates', 'constants'];
50
50
 
51
+ /** Every top-level key a kernel file may hold, in their order: the four sections and `readouts`, which declares no item. */
52
+ export const KERNEL_KEYS = ['controls', 'readouts', 'tables', 'aggregates', 'constants'];
53
+
51
54
  /** Whether a data file is a kernel file (`kernels/<kernel>.json`), which holds the four sections, never an item map. */
52
55
  export const isKernelFile = (rel) => /(^|\/)kernels\/[^/]+\.json$/.test(rel);
53
56
 
@@ -261,7 +264,8 @@ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
261
264
  /**
262
265
  * Resolve every item: `{ name, rel, kind, doc, why?, unit?, c?, shape?, default?, labels?, value }` (default and labels: a set's), where `value` has
263
266
  * every `{ref}` and `{derive}` evaluated. The returned map also carries `kernels`, every kernel's ordered controls
264
- * (kernelControls), which the json render publishes beside the items.
267
+ * (kernelControls) and, for a kernel that declares them, its ordered `readouts` `{ name, doc, unit, min, max, scale,
268
+ * rateHz }` with every reference resolved, which the json render publishes beside the items.
265
269
  * @throws naming the item: a reference that does not resolve, a cycle, an unknown derivation.
266
270
  */
267
271
  export function resolveData(data) {
@@ -311,6 +315,13 @@ export function resolveData(data) {
311
315
  const out = new Map();
312
316
  for (const name of data.items.keys()) out.set(name, resolveItem(name));
313
317
  out.kernels = kernelControls(data);
318
+ for (const f of data.files) {
319
+ if (!isKernelFile(f.rel) || !Array.isArray(f.doc.readouts)) continue;
320
+ out.kernels[kernelName(f.rel)].readouts = f.doc.readouts.map((ro) => {
321
+ const from = `${kernelName(f.rel)}.readouts.${ro.name}`;
322
+ return { name: ro.name, doc: ro.doc, unit: ro.unit, min: val(ro.min, from), max: val(ro.max, from), scale: ro.scale, rateHz: val(ro.rateHz, from) };
323
+ });
324
+ }
314
325
  return out;
315
326
  }
316
327
 
package/lib/fmt.mjs CHANGED
@@ -4,12 +4,14 @@
4
4
  * The one formatting of a data file (spec §3.2): two-space indent, one key per line, keys in schema
5
5
  * order, a trailing newline. Names (items, fields, sheet keys) keep the order the author gave them:
6
6
  * a travels table's field order is its C block order. A kernel file's sections come in their order (controls,
7
- * tables, aggregates, constants), its controls in the author's order, each control's keys in schema order.
7
+ * readouts, tables, aggregates, constants), its controls and readouts in the author's order, each one's keys in
8
+ * schema order.
8
9
  */
9
10
 
10
11
  const ORDER = {
11
12
  control: ['name', 'kind', 'global', 'table', 'rearms', 'since', 'doc', 'why', 'unit', 'ids', 'default', 'labels', 'travel', 'c'],
12
- sections: ['controls', 'tables', 'aggregates', 'constants'],
13
+ sections: ['controls', 'readouts', 'tables', 'aggregates', 'constants'],
14
+ readout: ['name', 'doc', 'unit', 'min', 'max', 'scale', 'rateHz'],
13
15
  item: ['kind', 'since', 'doc', 'why', 'unit', 'value', 'values', 'ids', 'default', 'labels', 'fields', 'travel', 'c'],
14
16
  travel: ['min', 'max', 'step', 'unit', 'default', 'defaultFrom', 'scale', 'byKind', 'doc', 'why'],
15
17
  c: ['alias', 'render'],
@@ -63,6 +65,13 @@ function control(c) {
63
65
  return o;
64
66
  }
65
67
 
68
+ function readout(r) {
69
+ if (!isObj(r)) return r;
70
+ const o = ordered(r, ORDER.readout);
71
+ for (const k of ['min', 'max', 'rateHz']) if (k in o) o[k] = number(o[k]);
72
+ return o;
73
+ }
74
+
66
75
  const items = (m) => (isObj(m) ? Object.fromEntries(Object.entries(m).map(([k, v]) => [k, item(v)])) : m);
67
76
 
68
77
  /** The canonical text of a parsed data file. */
@@ -72,7 +81,9 @@ export function formatDoc(doc) {
72
81
  if (ORDER.sections.some((k) => k in doc)) {
73
82
  for (const [k, v] of Object.entries(ordered(doc, ORDER.sections))) {
74
83
  if (k === '$schema') continue;
75
- out[k] = k === 'controls' && Array.isArray(v) ? v.map(control) : ORDER.sections.includes(k) ? items(v) : item(v);
84
+ if (k === 'controls' && Array.isArray(v)) out[k] = v.map(control);
85
+ else if (k === 'readouts' && Array.isArray(v)) out[k] = v.map(readout);
86
+ else out[k] = ORDER.sections.includes(k) ? items(v) : item(v);
76
87
  }
77
88
  } else {
78
89
  for (const [k, v] of Object.entries(doc)) if (k !== '$schema') out[k] = item(v);
package/lib/semver.mjs CHANGED
@@ -11,6 +11,10 @@
11
11
  * minor an item, field, sheet leaf, list member or set id added (appended); a travel widened;
12
12
  * a default moved inside its travel; a step changed
13
13
  * patch doc and why text
14
+ *
15
+ * A kernel's readouts (docs/readouts.md): one removed, renamed or moved, or its unit changed, is major (a removal is
16
+ * minor in the development phase, as an item's is); one appended, or its range, scale or rate changed, is minor; its
17
+ * doc is patch.
14
18
  */
15
19
 
16
20
  const RANK = { none: 0, patch: 1, minor: 2, major: 3 };
@@ -55,6 +59,29 @@ function leafChanges(at, a, b, say) {
55
59
  }
56
60
  }
57
61
 
62
+ function readoutChanges(prev, next, developmentPhase, say) {
63
+ for (const k of new Set([...Object.keys(prev), ...Object.keys(next)])) {
64
+ const a = prev[k]?.readouts ?? [];
65
+ const b = next[k]?.readouts ?? [];
66
+ a.forEach((x, i) => {
67
+ const at = `${k}.readouts.${x.name}`;
68
+ const j = b.findIndex((y) => y.name === x.name);
69
+ if (j < 0) {
70
+ say(developmentPhase ? 'minor' : 'major', at, `readout removed or renamed${developmentPhase ? ' (MINOR in the development phase)' : ''}`);
71
+ return;
72
+ }
73
+ const y = b[j];
74
+ if (j !== i) say('major', at, `readout moved (id ${i} -> ${j})`);
75
+ if (x.unit !== y.unit) say('major', at, `unit ${JSON.stringify(x.unit)} -> ${JSON.stringify(y.unit)}`);
76
+ if (x.min !== y.min || x.max !== y.max) say('minor', at, `range [${x.min}, ${x.max}] -> [${y.min}, ${y.max}]`);
77
+ if (x.scale !== y.scale) say('minor', at, `scale ${x.scale} -> ${y.scale}`);
78
+ if (x.rateHz !== y.rateHz) say('minor', at, `rateHz ${x.rateHz} -> ${y.rateHz}`);
79
+ if (x.doc !== y.doc) say('patch', at, 'doc');
80
+ });
81
+ for (const y of b) if (!a.some((x) => x.name === y.name)) say('minor', `${k}.readouts.${y.name}`, 'readout added');
82
+ }
83
+ }
84
+
58
85
  /** `{ level, changes: [{ level, item, what }] }` from `prev` to `next` (Maps name -> resolved item). */
59
86
  export function classify(prev, next, { developmentPhase = DEVELOPMENT_PHASE } = {}) {
60
87
  const changes = [];
@@ -107,6 +134,7 @@ export function classify(prev, next, { developmentPhase = DEVELOPMENT_PHASE } =
107
134
  }
108
135
  }
109
136
  for (const name of next.keys()) if (!prev.has(name)) changes.push({ level: 'minor', item: name, what: 'item added' });
137
+ readoutChanges(prev.kernels ?? {}, next.kernels ?? {}, developmentPhase, (level, item, what) => changes.push({ level, item, what }));
110
138
  const level = changes.reduce((m, c) => (RANK[c.level] > RANK[m] ? c.level : m), 'none');
111
139
  return { level, changes };
112
140
  }
package/lib/validate.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  import { readFileSync } from 'node:fs';
9
9
  import { posix } from 'node:path';
10
10
  import Ajv2020 from 'ajv/dist/2020.js';
11
- import { KERNEL_SECTIONS, controlGlobal, entriesOf, isKernelFile, isUse, resolveData } from './data.mjs';
11
+ import { KERNEL_KEYS, KERNEL_SECTIONS, controlGlobal, entriesOf, isKernelFile, isUse, resolveData } from './data.mjs';
12
12
  import { eqDefaultBands, eqDefaultParams } from './eq-defaults.mjs';
13
13
  import { renderC } from '../render/c.mjs';
14
14
  import { renderTs } from '../render/ts.mjs';
@@ -21,18 +21,28 @@ let compiled;
21
21
  * `table` declared under `tables`; `rearms` a boolean, and only on a control; a use (a control with a `global` and no
22
22
  * travel or ids) naming an item another control of the kernel declares, of its kind; `count` an item name; `of` naming
23
23
  * another control of its kind with `when` a { control, is } of a choice of the kernel; an aggregate's fields naming
24
- * controls; a constant a scalar, a list or a sheet.
24
+ * controls; a constant a scalar, a list or a sheet; `readouts` a list of readouts, unique by name.
25
25
  * A kernel file written as a flat item map (the 1.x shape) is refused as a whole. Any other data file returns [].
26
26
  */
27
27
  export function shapeProblems(rel, doc) {
28
28
  if (!isKernelFile(rel)) return [];
29
29
  const sections = KERNEL_SECTIONS;
30
30
  const keys = Object.keys(doc).filter((k) => k !== '$schema');
31
- if (keys.length && !keys.some((k) => sections.includes(k))) {
31
+ if (keys.length && !keys.some((k) => KERNEL_KEYS.includes(k))) {
32
32
  return [`${rel} is a flat item map (the 1.x shape): a kernel file declares its items under ${sections.join(', ')}`];
33
33
  }
34
34
  const out = [];
35
- for (const k of keys) if (!sections.includes(k)) out.push(`unknown top-level key ${k}: a kernel file holds ${sections.join(', ')}`);
35
+ for (const k of keys) if (!KERNEL_KEYS.includes(k)) out.push(`unknown top-level key ${k}: a kernel file holds ${KERNEL_KEYS.join(', ')}`);
36
+ if ('readouts' in doc) {
37
+ if (!Array.isArray(doc.readouts)) out.push('readouts must be a list');
38
+ else {
39
+ const names = new Set();
40
+ for (const [i, ro] of doc.readouts.entries()) {
41
+ if (names.has(ro?.name)) out.push(`readouts[${i}]: name ${ro.name} declared twice`);
42
+ names.add(ro?.name);
43
+ }
44
+ }
45
+ }
36
46
  const controls = doc.controls ?? [];
37
47
  if (!Array.isArray(controls)) return [...out, 'controls must be a list'];
38
48
  const seen = new Set();
@@ -73,12 +83,22 @@ export function shapeProblems(rel, doc) {
73
83
  return out;
74
84
  }
75
85
 
86
+ let ajv;
76
87
  /** The compiled schema validator. */
77
88
  export function schemaValidator() {
78
- if (!compiled) compiled = new Ajv2020({ allErrors: true, strict: true, strictRequired: false, allowUnionTypes: true }).compile(SCHEMA);
89
+ if (!compiled) {
90
+ ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false, allowUnionTypes: true });
91
+ compiled = ajv.compile(SCHEMA);
92
+ }
79
93
  return compiled;
80
94
  }
81
95
 
96
+ /** The validator of one readout, the schema's `$defs/readout`. */
97
+ export function readoutValidator() {
98
+ schemaValidator();
99
+ return ajv.getSchema(`${SCHEMA.$id}#/$defs/readout`);
100
+ }
101
+
82
102
  const constCase = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
83
103
 
84
104
  /**
@@ -99,6 +119,21 @@ function semanticRules(resolved, say) {
99
119
  }
100
120
  }
101
121
 
122
+ // A readout's numbers are finite, its range runs upward, its rate is positive and its scale is a PARAM_SCALES id.
123
+ for (const [k, { readouts }] of Object.entries(resolved.kernels ?? {})) {
124
+ for (const ro of readouts ?? []) {
125
+ const rel = `data/kernels/${k}.json`;
126
+ const at = `${k}.readouts.${ro.name}`;
127
+ for (const f of ['min', 'max', 'rateHz']) if (typeof ro[f] !== 'number' || !Number.isFinite(ro[f])) say(rel, at, 'finite', `${at}.${f} resolves to ${JSON.stringify(ro[f])}`);
128
+ if (!(ro.min < ro.max)) say(rel, at, 'readout-range', `${at}: min ${ro.min} is not below max ${ro.max}`);
129
+ if (!(ro.rateHz > 0)) say(rel, at, 'readout-rate', `${at}: rateHz ${ro.rateHz} is not positive`);
130
+ if (!(val('PARAM_SCALES') ?? []).includes(ro.scale)) say(rel, at, 'scale', `${at}: scale ${JSON.stringify(ro.scale)} is not an id of PARAM_SCALES`);
131
+ }
132
+ }
133
+ if (resolved.has('READOUTS') && Object.values(resolved.kernels ?? {}).some((k) => k.readouts)) {
134
+ say(resolved.get('READOUTS').rel, 'READOUTS', 'readouts-name', 'READOUTS is the ts render\'s export of the readouts: no item may take the name');
135
+ }
136
+
102
137
  // A per-band control's count: a positive integer scalar, or a sheet whose every variant has a positive integer max.
103
138
  for (const [k, { controls }] of Object.entries(resolved.kernels ?? {})) {
104
139
  for (const c of controls) {
@@ -162,6 +197,13 @@ export function validateData(data) {
162
197
  for (const m of shapeProblems(f.rel, f.doc)) say(f.rel, '-', 'shape', m);
163
198
  // The schema reads items: a kernel file is checked as the items it expands to, so no rule is lost.
164
199
  const items = { $schema: f.doc.$schema, ...Object.fromEntries(entriesOf(f.rel, f.doc)) };
200
+ if (isKernelFile(f.rel) && Array.isArray(f.doc.readouts)) {
201
+ const readout = readoutValidator();
202
+ for (const [i, ro] of f.doc.readouts.entries()) {
203
+ if (readout(ro)) continue;
204
+ for (const e of readout.errors) say(f.rel, `readouts[${i}]`, 'schema', `/readouts/${i}${e.instancePath} ${e.message}${e.params?.allowedValues ? ` (${e.params.allowedValues.join(', ')})` : ''}`);
205
+ }
206
+ }
165
207
  if (!validate(items)) {
166
208
  for (const e of validate.errors) {
167
209
  const item = e.instancePath.split('/')[1] || '-';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmixer/omx-contract",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "The FreeMixer declarations as data: a JSON Schema, the data, and one CLI that renders them into C, TypeScript and JSON",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
package/render/c.mjs CHANGED
@@ -7,7 +7,8 @@
7
7
  * declaration, by reference to the items here.
8
8
  */
9
9
  import {
10
- genCoreLimitBlock, genDeclaredListsBlock, genDeclaredRates, genDeclaredScalarsBlock, genLimitBlock, renderContractLimits,
10
+ cStringLiteral, genCoreLimitBlock, genDeclaredListsBlock, genDeclaredRates, genDeclaredScalarsBlock, genLimitBlock, renderContractLimits,
11
+ scalarLiteral,
11
12
  } from './c/contract-limits-render.mjs';
12
13
  import { doors } from './doors.mjs';
13
14
 
@@ -55,6 +56,29 @@ function genDeclaredSetsEnums(sets) {
55
56
  }).join('\n');
56
57
  }
57
58
 
59
+ /** A display bound: a float literal, a whole number with its decimal point. */
60
+ const floatLiteral = (n) => `${Number.isInteger(n) ? `${n}.0` : n}f`;
61
+
62
+ /**
63
+ * Every kernel's readouts (docs/readouts.md), kernels in file order: an enum of the readouts, each at its position,
64
+ * `OMX_<KERNEL>_READOUT_COUNT`, and per readout `_MIN`, `_MAX`, `_RATE_HZ`, `_SCALE` and `_UNIT`.
65
+ */
66
+ function genReadouts(kernels) {
67
+ return Object.entries(kernels ?? {}).filter(([, k]) => k.readouts?.length).map(([kernel, { readouts }]) => {
68
+ const K = kernel.toUpperCase();
69
+ const names = readouts.map((ro) => `OMX_${K}_READOUT_${constCase(ro.name)}`);
70
+ const lines = [`enum omx_${kernel}_readouts {`, ...names.map((n, i) => ` ${n} = ${i},`), '};', `#define OMX_${K}_READOUT_COUNT ${readouts.length}u`];
71
+ readouts.forEach((ro, i) => lines.push(
72
+ `#define ${names[i]}_MIN ${floatLiteral(ro.min)}`,
73
+ `#define ${names[i]}_MAX ${floatLiteral(ro.max)}`,
74
+ `#define ${names[i]}_RATE_HZ ${scalarLiteral(ro.rateHz)}`,
75
+ `#define ${names[i]}_SCALE OMX_PARAM_SCALES_${constCase(ro.scale)}`,
76
+ `#define ${names[i]}_UNIT ${cStringLiteral(ro.unit)}`,
77
+ ));
78
+ return lines.join('\n');
79
+ }).join('\n');
80
+ }
81
+
58
82
  /**
59
83
  * The default band centres of an EQ (lib/eq-defaults.mjs is the rule; this is its C spelling, held
60
84
  * equal to it for every count by test/eq-defaults.test.mjs). Writes \`count\` centres to \`out\` and returns
@@ -110,6 +134,7 @@ export function renderLimitsHeader(resolved) {
110
134
  const scalars = d.DECLARED_SCALARS.length ? genDeclaredScalarsBlock(d.DECLARED_SCALARS, above) : '';
111
135
  const lists = d.DECLARED_LISTS.length ? genDeclaredListsBlock(d.DECLARED_LISTS, defined([tables, aliases, scalars].join('\n'))) : '';
112
136
  const sets = d.DECLARED_SET_ITEMS.length ? genDeclaredSetsEnums(d.DECLARED_SET_ITEMS) : '';
137
+ const readouts = genReadouts(resolved.kernels);
113
138
  const centres = resolved.has('EQ_DEFAULT_CENTRES_FOUR_BAND_HZ') ? eqCentresFunction() : '';
114
139
  const sections = [
115
140
  [tables, `/* Per-parameter numeric travel, one block per \`travels\` table named <PREFIX>_LIMITS:
@@ -121,6 +146,9 @@ export function renderLimitsHeader(resolved) {
121
146
  [lists, `/* Every list rendered to C, sorted by list: OMX_<LIST>_COUNT and OMX_<LIST>_INIT { a, b, … }. */`],
122
147
  [sets, `/* Every set rendered to C, sorted by set: an enum of the ids (a named id's position, or a numeric id's number), \`OMX_<SET>_COUNT\` and,
123
148
  * where the set names a default, \`OMX_<SET>_DEFAULT\`. */`],
149
+ [readouts, `/* Every kernel's readouts, the meters a host draws (docs/readouts.md): an enum of the readouts at their position,
150
+ * \`OMX_<KERNEL>_READOUT_COUNT\`, and per readout its display range (_MIN, _MAX), _RATE_HZ, _SCALE (an OMX_PARAM_SCALES_*
151
+ * member) and _UNIT. */`],
124
152
  [centres, `/* The default bands of an EQ with \`count\` bands: THE one rule, for every strip, host and count (omx-contract's
125
153
  * lib/eq-defaults.mjs; the ts render exports the same as eqDefaultCentres and eqDefaultTypes). Needs -lm. */`],
126
154
  ].filter(([body]) => body);
package/render/json.mjs CHANGED
@@ -4,7 +4,8 @@
4
4
  * The `json` target: the resolved data (every reference and derivation evaluated) for readers in
5
5
  * other languages, which need no resolver of their own. Beside the items, `kernels` lists each
6
6
  * kernel's controls in their declared order (name, kind, the global each resolves to, the table of a
7
- * table field, `rearms` when changing it re-arms the kernel's state), so a reader needs no kernel file.
7
+ * table field, `rearms` when changing it re-arms the kernel's state) and, where it declares any, its readouts, so a
8
+ * reader needs no kernel file.
8
9
  */
9
10
 
10
11
  /** Every file of the `json` target: `[{ path, text }]`. */
package/render/ts.mjs CHANGED
@@ -69,11 +69,26 @@ export declare function eqDefaultBands(count: number): { type: string; freqHz: n
69
69
  return { js, dts };
70
70
  }
71
71
 
72
+ /**
73
+ * Every kernel's readouts as one export, READOUTS: `{ <kernel>: [{ name, doc, unit, min, max, scale, rateHz }] }`,
74
+ * kernels in file order, absent when no kernel declares one.
75
+ */
76
+ function readoutsExport(resolved) {
77
+ const value = Object.fromEntries(Object.entries(resolved.kernels ?? {}).filter(([, k]) => k.readouts?.length).map(([n, k]) => [n, k.readouts]));
78
+ if (!Object.keys(value).length) return { js: '', dts: '' };
79
+ const doc = '/**\n * Every kernel\'s readouts, the meters a host draws, each kernel\'s in its declared order: the position is the\n * readout\'s id. Every reference is resolved.\n * @see docs/readouts.md\n */';
80
+ return {
81
+ js: `\n${doc}\nexport const READOUTS = ${JSON.stringify(value, null, 2)};\n`,
82
+ dts: `\n${doc}\nexport declare const READOUTS: ${tsType(value)};\n`,
83
+ };
84
+ }
85
+
72
86
  /** Every file of the `ts` target: `[{ path, text }]`. */
73
87
  export function renderTs(resolved) {
74
88
  const items = [...resolved.values()];
75
89
  const rule = eqRule(resolved);
76
- const js = BANNER + items.map((r) => `\n${docComment(r)}\nexport const ${r.name} = ${JSON.stringify(tsValue(r), null, 2)};\n`).join('') + rule.js;
77
- const dts = BANNER + items.map((r) => `\n${docComment(r)}\nexport declare const ${r.name}: ${tsType(tsValue(r))};\n`).join('') + rule.dts;
90
+ const readouts = readoutsExport(resolved);
91
+ const js = BANNER + items.map((r) => `\n${docComment(r)}\nexport const ${r.name} = ${JSON.stringify(tsValue(r), null, 2)};\n`).join('') + readouts.js + rule.js;
92
+ const dts = BANNER + items.map((r) => `\n${docComment(r)}\nexport declare const ${r.name}: ${tsType(tsValue(r))};\n`).join('') + readouts.dts + rule.dts;
78
93
  return [{ path: 'omx-contract.js', text: js }, { path: 'omx-contract.d.ts', text: dts }];
79
94
  }
@@ -81,6 +81,21 @@
81
81
  "required": ["min", "max"],
82
82
  "additionalProperties": false
83
83
  },
84
+ "readout": {
85
+ "type": "object",
86
+ "description": "A value a kernel reports while it runs and a host draws: a meter. Declared in a kernel file's `readouts`, in order; its position is its id (docs/readouts.md).",
87
+ "properties": {
88
+ "name": { "type": "string", "pattern": "^[a-z][A-Za-z0-9]*$" },
89
+ "doc": { "$ref": "#/$defs/text" },
90
+ "unit": { "$ref": "#/$defs/unit" },
91
+ "min": { "$ref": "#/$defs/number" },
92
+ "max": { "$ref": "#/$defs/number" },
93
+ "scale": { "type": "string", "pattern": "^[a-z]+$", "description": "How a host draws the readout: an id of PARAM_SCALES." },
94
+ "rateHz": { "$ref": "#/$defs/number" }
95
+ },
96
+ "required": ["name", "doc", "unit", "min", "max", "scale", "rateHz"],
97
+ "additionalProperties": false
98
+ },
84
99
  "sheetValue": {
85
100
  "type": "object",
86
101
  "propertyNames": { "pattern": "^[a-z][A-Za-z0-9]*$" },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmixer/omx-contract",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "items": {
5
5
  "PAN_RANGE": {
6
6
  "rel": "data/kernels/balance.json",
@@ -299,6 +299,16 @@
299
299
  "x4"
300
300
  ]
301
301
  },
302
+ "DETECTOR_TOPOLOGY": {
303
+ "rel": "data/kernels/comp.json",
304
+ "kind": "set",
305
+ "doc": "Where the compressor's detector listens: `feedforward` hears the slot's input (or its key), `feedback` hears the slot's own pre-make-up output DYN_FB_LOOP_DELAY_SAMPLES late, through one envelope stage, and a keyed slot stays feed-forward (omx-dsp's OMX_DYN_TOPOLOGY_FEEDFORWARD 0 and _FEEDBACK 1, in that order; the set is named for its control, so C spells them OMX_DETECTOR_TOPOLOGY_*). The console's comp row carries it (`detectorTopology`, openmixer's ssl-bus-compressor spec §3a) and comes up `feedforward`, so a zeroed atom is the compressor every slot runs.",
306
+ "default": "feedforward",
307
+ "value": [
308
+ "feedforward",
309
+ "feedback"
310
+ ]
311
+ },
302
312
  "PROGRAM_RELEASE": {
303
313
  "rel": "data/kernels/comp.json",
304
314
  "kind": "sheet",
@@ -316,6 +326,13 @@
316
326
  }
317
327
  }
318
328
  },
329
+ "DYN_FB_LOOP_DELAY_SAMPLES": {
330
+ "rel": "data/kernels/comp.json",
331
+ "kind": "scalar",
332
+ "doc": "The feedback detector's loop delay, in samples: the output y[n] depends on the gain at n, so the detector can only have heard y[n - 1]. Not a latency: the audio is never delayed by it. omx-dsp's feedback state holds exactly this many output samples per leg.",
333
+ "unit": "",
334
+ "value": 1
335
+ },
319
336
  "DEESS_FREQ_RANGE": {
320
337
  "rel": "data/kernels/deesser.json",
321
338
  "kind": "travels",
@@ -2453,6 +2470,13 @@
2453
2470
  }
2454
2471
  }
2455
2472
  },
2473
+ "READOUT_RATE_HZ": {
2474
+ "rel": "data/primitives.json",
2475
+ "kind": "scalar",
2476
+ "doc": "How often a readout's value is published: 50 Hz, one value every 20 ms, the rate a host sends a moving meter at. Every readout reads it as its `rateHz` (docs/readouts.md).",
2477
+ "unit": "Hz",
2478
+ "value": 50
2479
+ },
2456
2480
  "STANDARD_SAMPLE_RATES": {
2457
2481
  "rel": "data/rates.json",
2458
2482
  "kind": "list",
@@ -2639,6 +2663,22 @@
2639
2663
  "name": "detectorOversampling",
2640
2664
  "kind": "choice",
2641
2665
  "global": "DETECTOR_OVERSAMPLINGS"
2666
+ },
2667
+ {
2668
+ "name": "detectorTopology",
2669
+ "kind": "choice",
2670
+ "global": "DETECTOR_TOPOLOGY"
2671
+ }
2672
+ ],
2673
+ "readouts": [
2674
+ {
2675
+ "name": "gainReductionDb",
2676
+ "doc": "The compressor's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to COMP_LIMITS.thresholdDb.min: on a signal at or below 0 dBFS the reduction (x - T)(1 - 1/R) never exceeds -T, and T is at least the threshold's minimum.",
2677
+ "unit": "dB",
2678
+ "min": -60,
2679
+ "max": 0,
2680
+ "scale": "linear",
2681
+ "rateHz": 50
2642
2682
  }
2643
2683
  ]
2644
2684
  },
@@ -2684,6 +2724,17 @@
2684
2724
  "kind": "choice",
2685
2725
  "global": "DEESS_MODES"
2686
2726
  }
2727
+ ],
2728
+ "readouts": [
2729
+ {
2730
+ "name": "gainReductionDb",
2731
+ "doc": "The de-esser's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to DEESS_RANGE_RANGE.min: the de-esser reduces by its range at most.",
2732
+ "unit": "dB",
2733
+ "min": -24,
2734
+ "max": 0,
2735
+ "scale": "linear",
2736
+ "rateHz": 50
2737
+ }
2687
2738
  ]
2688
2739
  },
2689
2740
  "delay": {
@@ -2928,6 +2979,17 @@
2928
2979
  "global": "GATE_LIMITS",
2929
2980
  "table": "GATE_LIMITS"
2930
2981
  }
2982
+ ],
2983
+ "readouts": [
2984
+ {
2985
+ "name": "gainReductionDb",
2986
+ "doc": "The gate's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to GATE_LIMITS.rangeDb.min: the gate attenuates by its range at most.",
2987
+ "unit": "dB",
2988
+ "min": -90,
2989
+ "max": 0,
2990
+ "scale": "linear",
2991
+ "rateHz": 50
2992
+ }
2931
2993
  ]
2932
2994
  },
2933
2995
  "geq": {
@@ -2970,6 +3032,17 @@
2970
3032
  "global": "LIMITER_LIMITS",
2971
3033
  "table": "LIMITER_LIMITS"
2972
3034
  }
3035
+ ],
3036
+ "readouts": [
3037
+ {
3038
+ "name": "gainReductionDb",
3039
+ "doc": "The limiter's gain reduction, in dB with CLAP's sign: 0 while it reduces nothing, negative the reduction it applies. The display range runs down to LIMITER_LIMITS.ceilingDb.min: on a signal at or below 0 dBFS the limiter pulls the peak down to its ceiling at most, and the ceiling is at least its minimum.",
3040
+ "unit": "dB",
3041
+ "min": -12,
3042
+ "max": 0,
3043
+ "scale": "linear",
3044
+ "rateHz": 50
3045
+ }
2973
3046
  ]
2974
3047
  },
2975
3048
  "mixmatrix": {