@openmixer/omx-contract 2.2.0 → 2.4.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,140 +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).
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:
20
17
 
21
18
  ```
22
- data/ the declaration, one file per group, edited by hand
23
- rates.json primitives.json kernels/<kernel>.json
24
- schema/omx-contract.schema.json JSON Schema 2020-12: five kinds (scalar, travels, sheet, list, set), closed units, refs, closed derivations
25
- lib/eq-defaults.mjs the one default rule for a fresh EQ's bands; the ts and c renders carry it
26
- bin/omx-contract.mjs the CLI
27
- render/ c.mjs (openmixer's renderer, moved unchanged, under c/), ts.mjs, json.mjs
28
- include/omxcontract/ the committed c render (what omx-contract-devel installs)
29
- share/omx-contract/ the committed json render (resolved data)
30
- test/ schema, semantic checks, goldens, perturbation, semver, round trip
31
- tools/proof/round-trip.mjs the spec §4.4 steps 1 and 2 proof against omx-dsp v0.1.3
32
- recipes/kernel.recipe.json what a complete kernel has; recipes/answers/ cites each value in the engine
33
- tools/omx-new-kernel.mjs the kernel wizard and importer; tools/kernel-recipe.mjs is `make completeness`
34
- tools/seed/ the one-time seed from openmixer's built core (provenance; never run by CI)
35
- 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
36
21
  ```
37
22
 
38
- ## The kernel file
39
-
40
- A kernel file, `data/kernels/<kernel>.json`, holds four sections and nothing else. A sketch, values abridged
41
- (the tremolo's controls, a gate table, a constant):
42
-
43
- ```json
44
- {
45
- "$schema": "../../schema/omx-contract.schema.json",
46
- "controls": [
47
- { "name": "rateHz", "kind": "travel", "global": "TREMOLO_RATE_RANGE", "doc": "…", "travel": { "min": 0.1, "max": 20, "unit": "Hz", "default": 5 } },
48
- { "name": "depth", "kind": "travel", "doc": "…", "travel": { "min": 0, "max": 1, "unit": "", "default": 0.5 } },
49
- { "name": "mode", "kind": "choice", "doc": "…", "ids": ["tremolo", "pan"], "labels": ["Tremolo", "Pan"] },
50
- { "name": "thresholdDb", "kind": "travel", "table": "GATE_LIMITS", "travel": { "min": -80, "max": 0, "unit": "dB" } }
51
- ],
52
- "tables": { "GATE_LIMITS": { "doc": "…" } },
53
- "aggregates": { "TREMOLO_TRAVELS": { "doc": "…", "fields": ["rateHz", "depth"] } },
54
- "constants": { "CHORUS_BASE_MS": { "kind": "scalar", "doc": "…", "unit": "ms", "value": 10 } }
55
- }
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
56
29
  ```
57
30
 
58
- - `controls`, in order: a `travel` (one travel) or a `choice` (ordered `ids`, an optional `default` and `labels`).
59
- Its item name is `<KERNEL>_<NAME>_RANGE` for a travel and `<KERNEL>_<NAME>S` for a choice (`depth` in
60
- tremolo.json is `TREMOLO_DEPTH_RANGE`, `mode` is `TREMOLO_MODES`); `global` names it when it is anything else.
61
- A control with a `table` is one field of that table. `"rearms": true` marks a control whose change re-arms the
62
- kernel's state, so it is not a smooth parameter (the limiter's `lookaheadMs`: omx-dsp's limiter face re-arms
63
- the state and restarts the gain at unity when the look-ahead moves by a frame). It is a boolean, only on a
64
- control; it is no item, so the C and TypeScript renders do not carry it.
65
- - A USE is a control with a `global` and no `travel` or `ids`: it takes the item another control of the kernel
66
- declares, of its kind, and adds no item (the EQ's `lpfSlope` takes FILTER_SLOPES, which `hpfSlope` declares).
67
- - `count` names the item that counts a control the kernel takes once per band: a positive integer scalar
68
- (`GEQ_BANDS`), or a sheet whose every variant gives its count as `max` (`EQ_BAND_COUNTS.eq8.max`).
69
- - `of` and `when` mark a travel that is no control of its own but the travel the control `of` names reaches while
70
- the choice `when.control` holds `when.is` (the EQ's `notchQ`: the Q's travel while the band is a notch).
71
- - A choice's `default` may be `{ "ref": … }` to the scalar that states it; a boolean names the second id of a
72
- two-id switch when true, the first when false (the delay's `pingpong` comes up at FX_DELAY_PINGPONG_DEFAULT).
73
- - `tables`: a travels table whose fields are the controls that name it, in control order (`GATE_LIMITS`).
74
- - `aggregates`: a travels table whose fields are references to controls declared once above, listed by name.
75
- - `constants`: scalars, lists and sheets, as items.
76
-
77
- `omx-contract validate` refuses any other key, a constant that is a travel or a set, and a kernel file written
78
- as a flat item map (the 1.x shape). `rates.json` and `primitives.json` stay item maps.
79
-
80
- ## The JSON render's kernels
81
-
82
- Beside `items`, `share/omx-contract/omx-contract.json` carries `kernels`: for every kernel file, its controls in
83
- the order the file declares them. A consumer reads control order and kinds here, never from a kernel file.
84
-
85
- ```json
86
- "kernels": {
87
- "limiter": {
88
- "controls": [
89
- { "name": "ceilingDb", "kind": "travel", "global": "LIMITER_LIMITS", "table": "LIMITER_LIMITS" },
90
- { "name": "lookaheadMs", "kind": "travel", "global": "LIMITER_LIMITS", "table": "LIMITER_LIMITS", "rearms": true },
91
- { "name": "releaseMs", "kind": "travel", "global": "LIMITER_LIMITS", "table": "LIMITER_LIMITS" }
92
- ]
93
- },
94
- "tremolo": {
95
- "controls": [
96
- { "name": "rateHz", "kind": "travel", "global": "TREMOLO_RATE_RANGE" },
97
- { "name": "mode", "kind": "choice", "global": "TREMOLO_MODES" }
98
- ]
99
- }
100
- }
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
101
35
  ```
102
36
 
103
- - `kind`: `travel` or `choice`.
104
- - `global`: the item the control's value lives in: `items[global].value` for a travel or a choice, and
105
- `items[global].value[name]` for a field of a table, when `table` (the same item) is present.
106
- - `rearms`: present, and `true`, only on a control that declares it.
107
- - `count`: present on a control taken once per band, the item that counts it.
108
- - `when`: on a control another one is `of`, the list `{ control, is, global }`: while `control` holds `is`, the
109
- control's travel is `items[global]` (the EQ's `q` reaches EQ_NOTCH_Q_RANGE on a notch). A control that is `of`
110
- 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.
111
38
 
112
- A kernel with no controls (fbs, mixmatrix, rta) is listed with an empty `controls`.
39
+ ## Use
113
40
 
114
- ## The CLI
41
+ Include the C header and read the constants by name:
115
42
 
116
- ```
117
- npm ci
118
- node bin/omx-contract.mjs validate # schema + semantic checks
119
- node bin/omx-contract.mjs fmt [--check] # the one formatting
120
- node bin/omx-contract.mjs render --target c|ts|json --out <dir> [--check]
121
- 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>
122
45
  ```
123
46
 
124
- Exit 0 is a pass, 1 a failure naming file, item and rule, 2 a usage or environment error. A change to
125
- `data/` is followed by `render --target c --out include`, `render --target json --out
126
- 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
+ ```
127
50
 
128
- ## 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.4.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).
129
56
 
130
- Pin one version (`omx-contract 2.2.0` in `.github/pins.txt`) and build against its renders, committing
131
- none: the installed `omx-contract-devel` / `libomx-contract-dev` at exactly that version
132
- (`pkg-config --exact-version=2.2.0 omx-contract`), else the release's npm tarball, unpacked, and
133
- `omx-contract render --target c --out build/omx-contract/include`. Include
134
- `<omxcontract/omx_contract_limits.h>`. A plugin's parameter header is rendered by omx-plugins from its
135
- declaration, by reference to the kernel travels here (since 1.1.0).
57
+ ## Building
136
58
 
137
- The npm package is `@openmixer/omx-contract` on npmjs.org (the `ts` render is its main export); every
138
- GitHub release also carries the `npm pack` tarball, the RPMs and the debs.
59
+ See [BUILDING.md](BUILDING.md).
139
60
 
140
61
  ## License
141
62
 
@@ -124,6 +124,21 @@
124
124
  ]
125
125
  }
126
126
  ],
127
+ "readouts": [
128
+ {
129
+ "name": "gainReductionDb",
130
+ "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.",
131
+ "unit": "dB",
132
+ "min": {
133
+ "ref": "COMP_LIMITS.thresholdDb.min"
134
+ },
135
+ "max": 0,
136
+ "scale": "linear",
137
+ "rateHz": {
138
+ "ref": "READOUT_RATE_HZ"
139
+ }
140
+ }
141
+ ],
127
142
  "tables": {
128
143
  "COMP_LIMITS": {
129
144
  "doc": "The legal range of each compressor field (engineering units). The widget clamps drags to these, `normalizeComp` enforces them, the row's codec refuses by them and the travel sheet publishes them. `default`/`defaultFrom` are the come-up values `defaultCompState` seeds a fresh strip with, read BACK from here rather than restated there."
@@ -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."
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "../../schema/omx-contract.schema.json",
3
+ "controls": [
4
+ {
5
+ "name": "trimDb",
6
+ "kind": "travel",
7
+ "global": "TRIM_RANGE",
8
+ "doc": "The digital input trim, in dB: the console's fine head gain, applied first in a channel's lane as a click-free ramp from the last block's gain to this one (openmixer's TRIM_RANGE, -24..+24 dB in 0.1 dB steps, spelled minDb/maxDb/stepDb there). It comes up at 0 dB, the trim a channel has when none is set. omx-dsp's trim face takes it as `trim_db`.",
9
+ "travel": {
10
+ "min": -24,
11
+ "max": 24,
12
+ "step": 0.1,
13
+ "unit": "dB",
14
+ "default": 0,
15
+ "defaultFrom": "desk"
16
+ }
17
+ }
18
+ ],
19
+ "aggregates": {
20
+ "TRIM_TRAVELS": {
21
+ "doc": "The trim as a table with the one field `trimDb`, the name the trim row and the omx-strip plugin store; generated from the trim kernel's control, so the travel has one home.",
22
+ "fields": [
23
+ "trimDb"
24
+ ]
25
+ }
26
+ }
27
+ }
@@ -104,20 +104,6 @@
104
104
  }
105
105
  }
106
106
  },
107
- "TRIM_TRAVELS": {
108
- "kind": "travels",
109
- "doc": "The digital input trim as a travel: openmixer's TRIM_RANGE (packages/declarations, -24..+24 dB in 0.1 dB steps, spelled minDb/maxDb/stepDb there and OMX_TRIM_RANGE_*_DB in omx-dsp's header), coming up at 0 dB, the trim a channel has when none is set. The omx-strip plugin's trim reads it. A table with the one field `trimDb`, the name the trim row and the plugin store; not rendered to C, which already spells TRIM_RANGE its own way.",
110
- "fields": {
111
- "trimDb": {
112
- "min": -24,
113
- "max": 24,
114
- "step": 0.1,
115
- "unit": "dB",
116
- "default": 0,
117
- "defaultFrom": "desk"
118
- }
119
- }
120
- },
121
107
  "PARAM_SCALES": {
122
108
  "kind": "set",
123
109
  "since": "1.3.0",
@@ -168,5 +154,12 @@
168
154
  "scale": "log"
169
155
  }
170
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
171
164
  }
172
165
  }
@@ -1860,6 +1860,34 @@ export declare const TREMOLO_TRAVELS: {
1860
1860
  };
1861
1861
  };
1862
1862
 
1863
+ /**
1864
+ * The digital input trim, in dB: the console's fine head gain, applied first in a channel's lane as a click-free ramp from the last block's gain to this one (openmixer's TRIM_RANGE, -24..+24 dB in 0.1 dB steps, spelled minDb/maxDb/stepDb there). It comes up at 0 dB, the trim a channel has when none is set. omx-dsp's trim face takes it as `trim_db`.
1865
+ * @see data/kernels/trim.json
1866
+ */
1867
+ export declare const TRIM_RANGE: {
1868
+ readonly min: -24;
1869
+ readonly max: 24;
1870
+ readonly step: 0.1;
1871
+ readonly unit: "dB";
1872
+ readonly default: 0;
1873
+ readonly defaultFrom: "desk";
1874
+ };
1875
+
1876
+ /**
1877
+ * The trim as a table with the one field `trimDb`, the name the trim row and the omx-strip plugin store; generated from the trim kernel's control, so the travel has one home.
1878
+ * @see data/kernels/trim.json
1879
+ */
1880
+ export declare const TRIM_TRAVELS: {
1881
+ readonly trimDb: {
1882
+ readonly min: -24;
1883
+ readonly max: 24;
1884
+ readonly step: 0.1;
1885
+ readonly unit: "dB";
1886
+ readonly default: 0;
1887
+ readonly defaultFrom: "desk";
1888
+ };
1889
+ };
1890
+
1863
1891
  /**
1864
1892
  * The Q of a second-order Butterworth section, 1/√2 — maximally flat, the one value every Butterworth biquad in the tree designs at: the LR4 crossover section, the pitch pre-filter and the drive's tilt / band / HF sections, in TS and (as `OMX_BUTTERWORTH_Q`) in C (§7, the scalar door).
1865
1893
  * @see data/primitives.json
@@ -1958,21 +1986,6 @@ export declare const COMP_CONTINUOUS_TRAVELS: {
1958
1986
  };
1959
1987
  };
1960
1988
 
1961
- /**
1962
- * The digital input trim as a travel: openmixer's TRIM_RANGE (packages/declarations, -24..+24 dB in 0.1 dB steps, spelled minDb/maxDb/stepDb there and OMX_TRIM_RANGE_*_DB in omx-dsp's header), coming up at 0 dB, the trim a channel has when none is set. The omx-strip plugin's trim reads it. A table with the one field `trimDb`, the name the trim row and the plugin store; not rendered to C, which already spells TRIM_RANGE its own way.
1963
- * @see data/primitives.json
1964
- */
1965
- export declare const TRIM_TRAVELS: {
1966
- readonly trimDb: {
1967
- readonly min: -24;
1968
- readonly max: 24;
1969
- readonly step: 0.1;
1970
- readonly unit: "dB";
1971
- readonly default: 0;
1972
- readonly defaultFrom: "desk";
1973
- };
1974
- };
1975
-
1976
1989
  /**
1977
1990
  * How a host draws a parameter: linear, logarithmic (a frequency, a time) or in discrete steps. The words omx-plugins' declarations and plugin-hostd's protocol use; a travel names its own with `scale`.
1978
1991
  * @see data/primitives.json
@@ -2007,6 +2020,12 @@ export declare const EQ_CONTINUOUS_TRAVELS: {
2007
2020
  };
2008
2021
  };
2009
2022
 
2023
+ /**
2024
+ * 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).
2025
+ * @see data/primitives.json
2026
+ */
2027
+ export declare const READOUT_RATE_HZ: 50;
2028
+
2010
2029
  /**
2011
2030
  * 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.
2012
2031
  * @see data/rates.json
@@ -2040,6 +2059,50 @@ export declare const ORACLE_FLOOR_RATES: readonly [44100, 48000, 96000, 192000];
2040
2059
  */
2041
2060
  export declare const RME_RATES: readonly [32000, 44100, 48000, 64000, 88200, 96000, 128000, 176400, 192000];
2042
2061
 
2062
+ /**
2063
+ * Every kernel's readouts, the meters a host draws, each kernel's in its declared order: the position is the
2064
+ * readout's id. Every reference is resolved.
2065
+ * @see docs/readouts.md
2066
+ */
2067
+ export declare const READOUTS: {
2068
+ readonly comp: readonly [{
2069
+ readonly name: "gainReductionDb";
2070
+ 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.";
2071
+ readonly unit: "dB";
2072
+ readonly min: -60;
2073
+ readonly max: 0;
2074
+ readonly scale: "linear";
2075
+ readonly rateHz: 50;
2076
+ }];
2077
+ readonly deesser: readonly [{
2078
+ readonly name: "gainReductionDb";
2079
+ 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.";
2080
+ readonly unit: "dB";
2081
+ readonly min: -24;
2082
+ readonly max: 0;
2083
+ readonly scale: "linear";
2084
+ readonly rateHz: 50;
2085
+ }];
2086
+ readonly gate: readonly [{
2087
+ readonly name: "gainReductionDb";
2088
+ 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.";
2089
+ readonly unit: "dB";
2090
+ readonly min: -90;
2091
+ readonly max: 0;
2092
+ readonly scale: "linear";
2093
+ readonly rateHz: 50;
2094
+ }];
2095
+ readonly limiter: readonly [{
2096
+ readonly name: "gainReductionDb";
2097
+ 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.";
2098
+ readonly unit: "dB";
2099
+ readonly min: -12;
2100
+ readonly max: 0;
2101
+ readonly scale: "linear";
2102
+ readonly rateHz: 50;
2103
+ }];
2104
+ };
2105
+
2043
2106
  /** The default band centres (Hz) of an EQ with `count` bands: THE one rule, for every strip, host and count. */
2044
2107
  export declare function eqDefaultCentres(count: number): number[];
2045
2108
  /** The default band types, by EQ_BAND_TYPES id: a low shelf, bells, a high shelf (a single band is a bell). */
@@ -2037,6 +2037,34 @@ export const TREMOLO_TRAVELS = {
2037
2037
  }
2038
2038
  };
2039
2039
 
2040
+ /**
2041
+ * The digital input trim, in dB: the console's fine head gain, applied first in a channel's lane as a click-free ramp from the last block's gain to this one (openmixer's TRIM_RANGE, -24..+24 dB in 0.1 dB steps, spelled minDb/maxDb/stepDb there). It comes up at 0 dB, the trim a channel has when none is set. omx-dsp's trim face takes it as `trim_db`.
2042
+ * @see data/kernels/trim.json
2043
+ */
2044
+ export const TRIM_RANGE = {
2045
+ "min": -24,
2046
+ "max": 24,
2047
+ "step": 0.1,
2048
+ "unit": "dB",
2049
+ "default": 0,
2050
+ "defaultFrom": "desk"
2051
+ };
2052
+
2053
+ /**
2054
+ * The trim as a table with the one field `trimDb`, the name the trim row and the omx-strip plugin store; generated from the trim kernel's control, so the travel has one home.
2055
+ * @see data/kernels/trim.json
2056
+ */
2057
+ export const TRIM_TRAVELS = {
2058
+ "trimDb": {
2059
+ "min": -24,
2060
+ "max": 24,
2061
+ "step": 0.1,
2062
+ "unit": "dB",
2063
+ "default": 0,
2064
+ "defaultFrom": "desk"
2065
+ }
2066
+ };
2067
+
2040
2068
  /**
2041
2069
  * The Q of a second-order Butterworth section, 1/√2 — maximally flat, the one value every Butterworth biquad in the tree designs at: the LR4 crossover section, the pitch pre-filter and the drive's tilt / band / HF sections, in TS and (as `OMX_BUTTERWORTH_Q`) in C (§7, the scalar door).
2042
2070
  * @see data/primitives.json
@@ -2135,21 +2163,6 @@ export const COMP_CONTINUOUS_TRAVELS = {
2135
2163
  }
2136
2164
  };
2137
2165
 
2138
- /**
2139
- * The digital input trim as a travel: openmixer's TRIM_RANGE (packages/declarations, -24..+24 dB in 0.1 dB steps, spelled minDb/maxDb/stepDb there and OMX_TRIM_RANGE_*_DB in omx-dsp's header), coming up at 0 dB, the trim a channel has when none is set. The omx-strip plugin's trim reads it. A table with the one field `trimDb`, the name the trim row and the plugin store; not rendered to C, which already spells TRIM_RANGE its own way.
2140
- * @see data/primitives.json
2141
- */
2142
- export const TRIM_TRAVELS = {
2143
- "trimDb": {
2144
- "min": -24,
2145
- "max": 24,
2146
- "step": 0.1,
2147
- "unit": "dB",
2148
- "default": 0,
2149
- "defaultFrom": "desk"
2150
- }
2151
- };
2152
-
2153
2166
  /**
2154
2167
  * How a host draws a parameter: linear, logarithmic (a frequency, a time) or in discrete steps. The words omx-plugins' declarations and plugin-hostd's protocol use; a travel names its own with `scale`.
2155
2168
  * @see data/primitives.json
@@ -2188,6 +2201,12 @@ export const EQ_CONTINUOUS_TRAVELS = {
2188
2201
  }
2189
2202
  };
2190
2203
 
2204
+ /**
2205
+ * 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).
2206
+ * @see data/primitives.json
2207
+ */
2208
+ export const READOUT_RATE_HZ = 50;
2209
+
2191
2210
  /**
2192
2211
  * 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.
2193
2212
  * @see data/rates.json
@@ -2243,6 +2262,58 @@ export const RME_RATES = [
2243
2262
  192000
2244
2263
  ];
2245
2264
 
2265
+ /**
2266
+ * Every kernel's readouts, the meters a host draws, each kernel's in its declared order: the position is the
2267
+ * readout's id. Every reference is resolved.
2268
+ * @see docs/readouts.md
2269
+ */
2270
+ export const READOUTS = {
2271
+ "comp": [
2272
+ {
2273
+ "name": "gainReductionDb",
2274
+ "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.",
2275
+ "unit": "dB",
2276
+ "min": -60,
2277
+ "max": 0,
2278
+ "scale": "linear",
2279
+ "rateHz": 50
2280
+ }
2281
+ ],
2282
+ "deesser": [
2283
+ {
2284
+ "name": "gainReductionDb",
2285
+ "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.",
2286
+ "unit": "dB",
2287
+ "min": -24,
2288
+ "max": 0,
2289
+ "scale": "linear",
2290
+ "rateHz": 50
2291
+ }
2292
+ ],
2293
+ "gate": [
2294
+ {
2295
+ "name": "gainReductionDb",
2296
+ "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.",
2297
+ "unit": "dB",
2298
+ "min": -90,
2299
+ "max": 0,
2300
+ "scale": "linear",
2301
+ "rateHz": 50
2302
+ }
2303
+ ],
2304
+ "limiter": [
2305
+ {
2306
+ "name": "gainReductionDb",
2307
+ "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.",
2308
+ "unit": "dB",
2309
+ "min": -12,
2310
+ "max": 0,
2311
+ "scale": "linear",
2312
+ "rateHz": 50
2313
+ }
2314
+ ]
2315
+ };
2316
+
2246
2317
  /** The declared parameters of the default EQ rule: the four-band set, the preferred series, the band frequency travel, the band gain and Q. */
2247
2318
  const EQ_DEFAULT_RULE = {
2248
2319
  "four": [
@@ -450,6 +450,7 @@
450
450
  #define OMX_PROGRAM_RELEASE_PROFILES_OPTO_SLOW_MAX_MS 1100
451
451
  #define OMX_PROGRAM_RELEASE_PROFILES_OPTO_SLOW_MIN_MS 110
452
452
  #define OMX_PSHR_MIN_DB 10
453
+ #define OMX_READOUT_RATE_HZ 50
453
454
  #define OMX_REVERB_DAMPING_RANGE_DEFAULT 0.5f
454
455
  #define OMX_REVERB_DAMPING_RANGE_DEFAULT_DOUBLE 0.5
455
456
  #define OMX_REVERB_DAMPING_RANGE_MAX 1
@@ -591,6 +592,11 @@
591
592
  #define OMX_TREMOLO_RATE_RANGE_MIN_DOUBLE 0.1
592
593
  #define OMX_TREMOLO_RATE_RANGE_STEP 0.01f
593
594
  #define OMX_TREMOLO_RATE_RANGE_STEP_DOUBLE 0.01
595
+ #define OMX_TRIM_RANGE_DEFAULT 0
596
+ #define OMX_TRIM_RANGE_MAX 24
597
+ #define OMX_TRIM_RANGE_MIN -24
598
+ #define OMX_TRIM_RANGE_STEP 0.1f
599
+ #define OMX_TRIM_RANGE_STEP_DOUBLE 0.1
594
600
  #define OMX_XOVER_LR2_SECTION_Q 0.5f
595
601
  #define OMX_XOVER_LR2_SECTION_Q_DOUBLE 0.5
596
602
  #define OMX_XOVER_LR4_SECTION_Q 0.7071067811865476f
@@ -745,6 +751,46 @@ enum omx_tremolo_modes {
745
751
  #define OMX_TREMOLO_MODES_COUNT 2u
746
752
  #define OMX_TREMOLO_MODES_DEFAULT OMX_TREMOLO_MODES_TREMOLO
747
753
 
754
+ /* Every kernel's readouts, the meters a host draws (docs/readouts.md): an enum of the readouts at their position,
755
+ * `OMX_<KERNEL>_READOUT_COUNT`, and per readout its display range (_MIN, _MAX), _RATE_HZ, _SCALE (an OMX_PARAM_SCALES_*
756
+ * member) and _UNIT. */
757
+ enum omx_comp_readouts {
758
+ OMX_COMP_READOUT_GAIN_REDUCTION_DB = 0,
759
+ };
760
+ #define OMX_COMP_READOUT_COUNT 1u
761
+ #define OMX_COMP_READOUT_GAIN_REDUCTION_DB_MIN -60.0f
762
+ #define OMX_COMP_READOUT_GAIN_REDUCTION_DB_MAX 0.0f
763
+ #define OMX_COMP_READOUT_GAIN_REDUCTION_DB_RATE_HZ 50
764
+ #define OMX_COMP_READOUT_GAIN_REDUCTION_DB_SCALE OMX_PARAM_SCALES_LINEAR
765
+ #define OMX_COMP_READOUT_GAIN_REDUCTION_DB_UNIT "dB"
766
+ enum omx_deesser_readouts {
767
+ OMX_DEESSER_READOUT_GAIN_REDUCTION_DB = 0,
768
+ };
769
+ #define OMX_DEESSER_READOUT_COUNT 1u
770
+ #define OMX_DEESSER_READOUT_GAIN_REDUCTION_DB_MIN -24.0f
771
+ #define OMX_DEESSER_READOUT_GAIN_REDUCTION_DB_MAX 0.0f
772
+ #define OMX_DEESSER_READOUT_GAIN_REDUCTION_DB_RATE_HZ 50
773
+ #define OMX_DEESSER_READOUT_GAIN_REDUCTION_DB_SCALE OMX_PARAM_SCALES_LINEAR
774
+ #define OMX_DEESSER_READOUT_GAIN_REDUCTION_DB_UNIT "dB"
775
+ enum omx_gate_readouts {
776
+ OMX_GATE_READOUT_GAIN_REDUCTION_DB = 0,
777
+ };
778
+ #define OMX_GATE_READOUT_COUNT 1u
779
+ #define OMX_GATE_READOUT_GAIN_REDUCTION_DB_MIN -90.0f
780
+ #define OMX_GATE_READOUT_GAIN_REDUCTION_DB_MAX 0.0f
781
+ #define OMX_GATE_READOUT_GAIN_REDUCTION_DB_RATE_HZ 50
782
+ #define OMX_GATE_READOUT_GAIN_REDUCTION_DB_SCALE OMX_PARAM_SCALES_LINEAR
783
+ #define OMX_GATE_READOUT_GAIN_REDUCTION_DB_UNIT "dB"
784
+ enum omx_limiter_readouts {
785
+ OMX_LIMITER_READOUT_GAIN_REDUCTION_DB = 0,
786
+ };
787
+ #define OMX_LIMITER_READOUT_COUNT 1u
788
+ #define OMX_LIMITER_READOUT_GAIN_REDUCTION_DB_MIN -12.0f
789
+ #define OMX_LIMITER_READOUT_GAIN_REDUCTION_DB_MAX 0.0f
790
+ #define OMX_LIMITER_READOUT_GAIN_REDUCTION_DB_RATE_HZ 50
791
+ #define OMX_LIMITER_READOUT_GAIN_REDUCTION_DB_SCALE OMX_PARAM_SCALES_LINEAR
792
+ #define OMX_LIMITER_READOUT_GAIN_REDUCTION_DB_UNIT "dB"
793
+
748
794
  /* The default bands of an EQ with `count` bands: THE one rule, for every strip, host and count (omx-contract's
749
795
  * lib/eq-defaults.mjs; the ts render exports the same as eqDefaultCentres and eqDefaultTypes). Needs -lm. */
750
796
  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.2.0",
3
+ "version": "2.4.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.2.0",
3
+ "version": "2.4.0",
4
4
  "items": {
5
5
  "PAN_RANGE": {
6
6
  "rel": "data/kernels/balance.json",
@@ -2280,6 +2280,36 @@
2280
2280
  }
2281
2281
  }
2282
2282
  },
2283
+ "TRIM_RANGE": {
2284
+ "rel": "data/kernels/trim.json",
2285
+ "kind": "travels",
2286
+ "doc": "The digital input trim, in dB: the console's fine head gain, applied first in a channel's lane as a click-free ramp from the last block's gain to this one (openmixer's TRIM_RANGE, -24..+24 dB in 0.1 dB steps, spelled minDb/maxDb/stepDb there). It comes up at 0 dB, the trim a channel has when none is set. omx-dsp's trim face takes it as `trim_db`.",
2287
+ "shape": "travel",
2288
+ "value": {
2289
+ "min": -24,
2290
+ "max": 24,
2291
+ "step": 0.1,
2292
+ "unit": "dB",
2293
+ "default": 0,
2294
+ "defaultFrom": "desk"
2295
+ }
2296
+ },
2297
+ "TRIM_TRAVELS": {
2298
+ "rel": "data/kernels/trim.json",
2299
+ "kind": "travels",
2300
+ "doc": "The trim as a table with the one field `trimDb`, the name the trim row and the omx-strip plugin store; generated from the trim kernel's control, so the travel has one home.",
2301
+ "shape": "table",
2302
+ "value": {
2303
+ "trimDb": {
2304
+ "min": -24,
2305
+ "max": 24,
2306
+ "step": 0.1,
2307
+ "unit": "dB",
2308
+ "default": 0,
2309
+ "defaultFrom": "desk"
2310
+ }
2311
+ }
2312
+ },
2283
2313
  "BUTTERWORTH_Q": {
2284
2314
  "rel": "data/primitives.json",
2285
2315
  "kind": "scalar",
@@ -2383,22 +2413,6 @@
2383
2413
  }
2384
2414
  }
2385
2415
  },
2386
- "TRIM_TRAVELS": {
2387
- "rel": "data/primitives.json",
2388
- "kind": "travels",
2389
- "doc": "The digital input trim as a travel: openmixer's TRIM_RANGE (packages/declarations, -24..+24 dB in 0.1 dB steps, spelled minDb/maxDb/stepDb there and OMX_TRIM_RANGE_*_DB in omx-dsp's header), coming up at 0 dB, the trim a channel has when none is set. The omx-strip plugin's trim reads it. A table with the one field `trimDb`, the name the trim row and the plugin store; not rendered to C, which already spells TRIM_RANGE its own way.",
2390
- "shape": "table",
2391
- "value": {
2392
- "trimDb": {
2393
- "min": -24,
2394
- "max": 24,
2395
- "step": 0.1,
2396
- "unit": "dB",
2397
- "default": 0,
2398
- "defaultFrom": "desk"
2399
- }
2400
- }
2401
- },
2402
2416
  "PARAM_SCALES": {
2403
2417
  "rel": "data/primitives.json",
2404
2418
  "kind": "set",
@@ -2439,6 +2453,13 @@
2439
2453
  }
2440
2454
  }
2441
2455
  },
2456
+ "READOUT_RATE_HZ": {
2457
+ "rel": "data/primitives.json",
2458
+ "kind": "scalar",
2459
+ "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).",
2460
+ "unit": "Hz",
2461
+ "value": 50
2462
+ },
2442
2463
  "STANDARD_SAMPLE_RATES": {
2443
2464
  "rel": "data/rates.json",
2444
2465
  "kind": "list",
@@ -2626,6 +2647,17 @@
2626
2647
  "kind": "choice",
2627
2648
  "global": "DETECTOR_OVERSAMPLINGS"
2628
2649
  }
2650
+ ],
2651
+ "readouts": [
2652
+ {
2653
+ "name": "gainReductionDb",
2654
+ "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.",
2655
+ "unit": "dB",
2656
+ "min": -60,
2657
+ "max": 0,
2658
+ "scale": "linear",
2659
+ "rateHz": 50
2660
+ }
2629
2661
  ]
2630
2662
  },
2631
2663
  "deesser": {
@@ -2670,6 +2702,17 @@
2670
2702
  "kind": "choice",
2671
2703
  "global": "DEESS_MODES"
2672
2704
  }
2705
+ ],
2706
+ "readouts": [
2707
+ {
2708
+ "name": "gainReductionDb",
2709
+ "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.",
2710
+ "unit": "dB",
2711
+ "min": -24,
2712
+ "max": 0,
2713
+ "scale": "linear",
2714
+ "rateHz": 50
2715
+ }
2673
2716
  ]
2674
2717
  },
2675
2718
  "delay": {
@@ -2914,6 +2957,17 @@
2914
2957
  "global": "GATE_LIMITS",
2915
2958
  "table": "GATE_LIMITS"
2916
2959
  }
2960
+ ],
2961
+ "readouts": [
2962
+ {
2963
+ "name": "gainReductionDb",
2964
+ "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.",
2965
+ "unit": "dB",
2966
+ "min": -90,
2967
+ "max": 0,
2968
+ "scale": "linear",
2969
+ "rateHz": 50
2970
+ }
2917
2971
  ]
2918
2972
  },
2919
2973
  "geq": {
@@ -2956,6 +3010,17 @@
2956
3010
  "global": "LIMITER_LIMITS",
2957
3011
  "table": "LIMITER_LIMITS"
2958
3012
  }
3013
+ ],
3014
+ "readouts": [
3015
+ {
3016
+ "name": "gainReductionDb",
3017
+ "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.",
3018
+ "unit": "dB",
3019
+ "min": -12,
3020
+ "max": 0,
3021
+ "scale": "linear",
3022
+ "rateHz": 50
3023
+ }
2959
3024
  ]
2960
3025
  },
2961
3026
  "mixmatrix": {
@@ -3190,6 +3255,15 @@
3190
3255
  "global": "TREMOLO_MODES"
3191
3256
  }
3192
3257
  ]
3258
+ },
3259
+ "trim": {
3260
+ "controls": [
3261
+ {
3262
+ "name": "trimDb",
3263
+ "kind": "travel",
3264
+ "global": "TRIM_RANGE"
3265
+ }
3266
+ ]
3193
3267
  }
3194
3268
  }
3195
3269
  }