@openmixer/omx-contract 1.3.0 → 2.0.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
@@ -12,6 +12,7 @@ omx-dsp needs, 1.1.0 adds the flanger, de-esser, phaser, rotary, tremolo and gra
12
12
  RTA constants, the EQ band budget, the choice enums (as the `set` kind), the EQ band counts and the
13
13
  one rule that gives a fresh EQ its bands, the measurement rate sets and the bus input cap. What is
14
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.
15
16
 
16
17
  ```
17
18
  data/ the declaration, one file per group, edited by hand
@@ -30,6 +31,37 @@ tools/seed/ the one-time seed from openmixer's built core (prov
30
31
  packaging/ debian/ omx-contract-devel (RPM), libomx-contract-dev (deb)
31
32
  ```
32
33
 
34
+ ## The kernel file
35
+
36
+ A kernel file, `data/kernels/<kernel>.json`, holds four sections and nothing else. A sketch, values abridged
37
+ (the tremolo's controls, a gate table, a constant):
38
+
39
+ ```json
40
+ {
41
+ "$schema": "../../schema/omx-contract.schema.json",
42
+ "controls": [
43
+ { "name": "rateHz", "kind": "travel", "global": "TREMOLO_RATE_RANGE", "doc": "…", "travel": { "min": 0.1, "max": 20, "unit": "Hz", "default": 5 } },
44
+ { "name": "depth", "kind": "travel", "doc": "…", "travel": { "min": 0, "max": 1, "unit": "", "default": 0.5 } },
45
+ { "name": "mode", "kind": "choice", "doc": "…", "ids": ["tremolo", "pan"], "labels": ["Tremolo", "Pan"] },
46
+ { "name": "thresholdDb", "kind": "travel", "table": "GATE_LIMITS", "travel": { "min": -80, "max": 0, "unit": "dB" } }
47
+ ],
48
+ "tables": { "GATE_LIMITS": { "doc": "…" } },
49
+ "aggregates": { "TREMOLO_TRAVELS": { "doc": "…", "fields": ["rateHz", "depth"] } },
50
+ "constants": { "CHORUS_BASE_MS": { "kind": "scalar", "doc": "…", "unit": "ms", "value": 10 } }
51
+ }
52
+ ```
53
+
54
+ - `controls`, in order: a `travel` (one travel) or a `choice` (ordered `ids`, an optional `default` and `labels`).
55
+ Its item name is `<KERNEL>_<NAME>_RANGE` for a travel and `<KERNEL>_<NAME>S` for a choice (`depth` in
56
+ tremolo.json is `TREMOLO_DEPTH_RANGE`, `mode` is `TREMOLO_MODES`); `global` names it when it is anything else.
57
+ A control with a `table` is one field of that table.
58
+ - `tables`: a travels table whose fields are the controls that name it, in control order (`GATE_LIMITS`).
59
+ - `aggregates`: a travels table whose fields are references to controls declared once above, listed by name.
60
+ - `constants`: scalars, lists and sheets, as items.
61
+
62
+ `omx-contract validate` refuses any other key, a constant that is a travel or a set, and a kernel file written
63
+ as a flat item map (the 1.x shape). `rates.json` and `primitives.json` stay item maps.
64
+
33
65
  ## The CLI
34
66
 
35
67
  ```
@@ -46,9 +78,9 @@ share/omx-contract` and `render --target ts --out test/golden/ts`; CI holds all
46
78
 
47
79
  ## Consuming it
48
80
 
49
- Pin one version (`omx-contract 1.3.0` in `.github/pins.txt`) and build against its renders, committing
81
+ Pin one version (`omx-contract 2.0.0` in `.github/pins.txt`) and build against its renders, committing
50
82
  none: the installed `omx-contract-devel` / `libomx-contract-dev` at exactly that version
51
- (`pkg-config --exact-version=1.3.0 omx-contract`), else the release's npm tarball, unpacked, and
83
+ (`pkg-config --exact-version=2.0.0 omx-contract`), else the release's npm tarball, unpacked, and
52
84
  `omx-contract render --target c --out build/omx-contract/include`. Include
53
85
  `<omxcontract/omx_contract_limits.h>`. A plugin's parameter header is rendered by omx-plugins from its
54
86
  declaration, by reference to the kernel travels here (since 1.1.0).
@@ -15,7 +15,7 @@ import { execFileSync } from 'node:child_process';
15
15
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { dirname, join, relative, resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
- import { loadData, packageVersion, resolveData } from '../lib/data.mjs';
18
+ import { KERNEL_SECTIONS, isKernelFile, kernelDocOf, loadData, packageVersion, resolveData } from '../lib/data.mjs';
19
19
  import { formatDoc } from '../lib/fmt.mjs';
20
20
  import { classify, refusal } from '../lib/semver.mjs';
21
21
  import { validateData } from '../lib/validate.mjs';
@@ -122,12 +122,23 @@ function cmdRender(argv) {
122
122
  return 0;
123
123
  }
124
124
 
125
- /** The data of a git tag, as `{ rel: text }`. */
125
+ /**
126
+ * The data of a git tag, as `{ rel: text }`. A tag released before 2.0.0 wrote its kernel files as flat item maps;
127
+ * each is restated in the kernel-file shape (kernelDocOf, the inverse of the expansion the loader reads), so the
128
+ * comparison is between the items the two releases declare.
129
+ */
126
130
  function dataAtTag(tag) {
127
131
  const git = (...a) => execFileSync('git', ['-C', ROOT, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
128
132
  try { git('rev-parse', '--verify', `${tag}^{commit}`); } catch { throw new Usage(`no tag ${tag} in ${ROOT}`); }
129
133
  const texts = {};
130
- for (const rel of git('ls-tree', '-r', '--name-only', tag, '--', 'data').split('\n').filter((p) => p.endsWith('.json'))) texts[rel] = git('show', `${tag}:${rel}`);
134
+ for (const rel of git('ls-tree', '-r', '--name-only', tag, '--', 'data').split('\n').filter((p) => p.endsWith('.json'))) {
135
+ const text = git('show', `${tag}:${rel}`);
136
+ let doc;
137
+ try { doc = JSON.parse(text); } catch { texts[rel] = text; continue; }
138
+ if (!isKernelFile(rel) || KERNEL_SECTIONS.some((k) => k in doc)) { texts[rel] = text; continue; }
139
+ const { $schema, ...items } = doc;
140
+ texts[rel] = JSON.stringify({ $schema, ...kernelDocOf(rel, Object.entries(items)) });
141
+ }
131
142
  return texts;
132
143
  }
133
144
 
@@ -1,19 +1,23 @@
1
1
  {
2
2
  "$schema": "../../schema/omx-contract.schema.json",
3
- "PAN_RANGE": {
4
- "kind": "travels",
5
- "doc": "The PAN travel — −1 (hard left) … 0 (center) … +1 (hard right). A property of the pan law itself, not of any desk, so it is declared once. Continuous: a pan position has no quantum, and panGains clamps to these bounds.",
6
- "travel": {
7
- "min": -1,
8
- "max": 1,
9
- "step": 0,
10
- "unit": ""
11
- },
12
- "c": {
13
- "alias": {
14
- "fact": "pan",
15
- "field": "pan"
3
+ "controls": [
4
+ {
5
+ "name": "pan",
6
+ "kind": "travel",
7
+ "global": "PAN_RANGE",
8
+ "doc": "The PAN travel — −1 (hard left) … 0 (center) … +1 (hard right). A property of the pan law itself, not of any desk, so it is declared once. Continuous: a pan position has no quantum, and panGains clamps to these bounds.",
9
+ "travel": {
10
+ "min": -1,
11
+ "max": 1,
12
+ "step": 0,
13
+ "unit": ""
14
+ },
15
+ "c": {
16
+ "alias": {
17
+ "fact": "pan",
18
+ "field": "pan"
19
+ }
16
20
  }
17
21
  }
18
- }
22
+ ]
19
23
  }
@@ -0,0 +1,132 @@
1
+ {
2
+ "$schema": "../../schema/omx-contract.schema.json",
3
+ "controls": [
4
+ {
5
+ "name": "mode",
6
+ "kind": "choice",
7
+ "doc": "The side of the threshold the band acts on. Above acts when the band is loud, taming a resonance that only rings when pushed; below acts when it is quiet, lifting a band that drops out. The order is the integer the kernel reads: above is 0, below is 1.",
8
+ "ids": [
9
+ "above",
10
+ "below"
11
+ ],
12
+ "default": "above"
13
+ },
14
+ {
15
+ "name": "threshold",
16
+ "kind": "travel",
17
+ "doc": "Where the band starts to act, in dB of the band's own level and not the strip's. It is the same fact the de-esser's threshold is, so it carries the same travel.",
18
+ "travel": {
19
+ "min": {
20
+ "ref": "DEESS_THRESHOLD_RANGE.min"
21
+ },
22
+ "max": {
23
+ "ref": "DEESS_THRESHOLD_RANGE.max"
24
+ },
25
+ "step": {
26
+ "ref": "DEESS_THRESHOLD_RANGE.step"
27
+ },
28
+ "unit": "dB",
29
+ "default": {
30
+ "ref": "DEESS_THRESHOLD_RANGE.default"
31
+ },
32
+ "defaultFrom": "desk"
33
+ }
34
+ },
35
+ {
36
+ "name": "ratio",
37
+ "kind": "travel",
38
+ "doc": "How hard the band is held once it is past its threshold. 2:1 rather than the de-esser's 4:1, because a dynamic band is corrective, not a limiter for a sibilant.",
39
+ "travel": {
40
+ "min": {
41
+ "ref": "DEESS_RATIO_RANGE.min"
42
+ },
43
+ "max": {
44
+ "ref": "DEESS_RATIO_RANGE.max"
45
+ },
46
+ "step": {
47
+ "ref": "DEESS_RATIO_RANGE.step"
48
+ },
49
+ "unit": "",
50
+ "default": 2,
51
+ "defaultFrom": "desk"
52
+ }
53
+ },
54
+ {
55
+ "name": "range",
56
+ "kind": "travel",
57
+ "doc": "The most the band may add to its static gain, signed: below zero the deepest cut, above zero the largest boost. The ceiling of +12 keeps static plus dynamic inside the EQ's own travel. Zero makes the band inert without switching it off.",
58
+ "travel": {
59
+ "min": {
60
+ "ref": "DEESS_RANGE_RANGE.min"
61
+ },
62
+ "max": 12,
63
+ "step": {
64
+ "ref": "DEESS_RANGE_RANGE.step"
65
+ },
66
+ "unit": "dB",
67
+ "default": -6,
68
+ "defaultFrom": "desk"
69
+ }
70
+ },
71
+ {
72
+ "name": "attack",
73
+ "kind": "travel",
74
+ "doc": "The detector's attack, in ms. The travel stops at the oversampling floor, below which the band would need the compressor's delayed control path.",
75
+ "travel": {
76
+ "min": {
77
+ "ref": "DETECTOR_OVERSAMPLE_AUTO_MS"
78
+ },
79
+ "max": {
80
+ "ref": "COMP_LIMITS.attackMs.max"
81
+ },
82
+ "step": {
83
+ "ref": "DEESS_ATTACK_RANGE.step"
84
+ },
85
+ "unit": "ms",
86
+ "default": 5,
87
+ "defaultFrom": "desk"
88
+ }
89
+ },
90
+ {
91
+ "name": "release",
92
+ "kind": "travel",
93
+ "doc": "The detector's release, in ms. 100 ms is long enough not to modulate a 40 Hz band inside four cycles.",
94
+ "travel": {
95
+ "min": {
96
+ "ref": "DEESS_RELEASE_RANGE.min"
97
+ },
98
+ "max": {
99
+ "ref": "COMP_LIMITS.releaseMs.max"
100
+ },
101
+ "step": {
102
+ "ref": "DEESS_RELEASE_RANGE.step"
103
+ },
104
+ "unit": "ms",
105
+ "default": 100,
106
+ "defaultFrom": "desk"
107
+ }
108
+ }
109
+ ],
110
+ "constants": {
111
+ "BAND_DYN_KNEE_DB": {
112
+ "kind": "scalar",
113
+ "doc": "The soft knee of the band's gain computer, in dB. One knee for the one band-dynamics word, shared by every stage that runs it, the de-esser included: a second spelling of 6 would be a second place where the reduction starts.",
114
+ "unit": "dB",
115
+ "value": 6
116
+ },
117
+ "BAND_DYN_MAX_BANDS": {
118
+ "kind": "scalar",
119
+ "doc": "How many dynamic bands a channel may hold. A dynamic band is an operator band, so the capacity is the operator's reserve, read and not typed again.",
120
+ "unit": "",
121
+ "value": {
122
+ "ref": "OPERATOR_EQ_BANDS_RESERVE"
123
+ }
124
+ },
125
+ "DETECTOR_OVERSAMPLE_AUTO_MS": {
126
+ "kind": "scalar",
127
+ "doc": "The shortest attack, in ms, at which the compressor's detector does not alias. Below it the compressor engages its 4x control path, which adds 72 samples of latency; a zero-latency band has no such path, so its attack travel stops at this floor.",
128
+ "unit": "ms",
129
+ "value": 0.5
130
+ }
131
+ }
132
+ }
@@ -1,85 +1,94 @@
1
1
  {
2
2
  "$schema": "../../schema/omx-contract.schema.json",
3
- "CHORUS_SPREAD_RANGE": {
4
- "kind": "travels",
5
- "doc": "The chorus's right-leg phase offset, in TURNS of its one oscillator (§2, §3a; the native-FX catalogue's M32 Dimension row). The right leg's voice k reads the LFO at k/N + spread: 0 is both legs one phase (the stage without the field, bit for bit), 0.25 quadrature, 0.5 the Dimension's opposed sweep. A fraction of a cycle carries no engineering symbol, so the unit is ''.",
6
- "travel": {
7
- "min": 0,
8
- "max": 0.5,
9
- "step": 0.01,
10
- "unit": "",
11
- "default": 0,
12
- "defaultFrom": "desk"
3
+ "controls": [
4
+ {
5
+ "name": "spread",
6
+ "kind": "travel",
7
+ "doc": "The chorus's right-leg phase offset, in TURNS of its one oscillator (§2, §3a; the native-FX catalogue's M32 Dimension row). The right leg's voice k reads the LFO at k/N + spread: 0 is both legs one phase (the stage without the field, bit for bit), 0.25 quadrature, 0.5 the Dimension's opposed sweep. A fraction of a cycle carries no engineering symbol, so the unit is ''.",
8
+ "travel": {
9
+ "min": 0,
10
+ "max": 0.5,
11
+ "step": 0.01,
12
+ "unit": "",
13
+ "default": 0,
14
+ "defaultFrom": "desk"
15
+ }
16
+ },
17
+ {
18
+ "name": "rate",
19
+ "kind": "travel",
20
+ "doc": "The chorus sweep's speed (§3a). Above ~3 Hz it stops being a chorus and becomes a vibrato.",
21
+ "travel": {
22
+ "min": 0.05,
23
+ "max": 8,
24
+ "step": 0.01,
25
+ "unit": "Hz",
26
+ "default": 0.6,
27
+ "defaultFrom": "desk"
28
+ }
29
+ },
30
+ {
31
+ "name": "depth",
32
+ "kind": "travel",
33
+ "doc": "The chorus's peak-to-peak excursion above its declared 10 ms base (§3a).",
34
+ "travel": {
35
+ "min": 0,
36
+ "max": 12,
37
+ "step": 0.1,
38
+ "unit": "ms",
39
+ "default": 4,
40
+ "defaultFrom": "desk"
41
+ }
42
+ },
43
+ {
44
+ "name": "voices",
45
+ "kind": "travel",
46
+ "doc": "The chorus's tap count — one oscillator read at k/N (§3a).",
47
+ "travel": {
48
+ "min": 1,
49
+ "max": 4,
50
+ "step": 1,
51
+ "unit": "",
52
+ "default": 3,
53
+ "defaultFrom": "desk"
54
+ }
55
+ },
56
+ {
57
+ "name": "mix",
58
+ "kind": "travel",
59
+ "doc": "The chorus's wet/dry, as an instance of DRIVE_MIX_RANGE — the same percent travel, its own come-up value: 35 %, a colour under the dry on a strip (§3a). The REVERB_LOWCUT_RANGE-over- REVERB_CUT_RANGE shape: one travel, one declared default per field that reads it.",
60
+ "travel": {
61
+ "min": 0,
62
+ "max": 100,
63
+ "step": 0.1,
64
+ "unit": "%",
65
+ "default": 35,
66
+ "defaultFrom": "desk"
67
+ }
13
68
  }
14
- },
15
- "CHORUS_RATE_RANGE": {
16
- "kind": "travels",
17
- "doc": "The chorus sweep's speed (§3a). Above ~3 Hz it stops being a chorus and becomes a vibrato.",
18
- "travel": {
19
- "min": 0.05,
20
- "max": 8,
21
- "step": 0.01,
22
- "unit": "Hz",
23
- "default": 0.6,
24
- "defaultFrom": "desk"
25
- }
26
- },
27
- "CHORUS_DEPTH_RANGE": {
28
- "kind": "travels",
29
- "doc": "The chorus's peak-to-peak excursion above its declared 10 ms base (§3a).",
30
- "travel": {
31
- "min": 0,
32
- "max": 12,
33
- "step": 0.1,
69
+ ],
70
+ "constants": {
71
+ "CHORUS_BASE_MS": {
72
+ "kind": "scalar",
73
+ "doc": "The chorus's base delay, ms: the voices swing around it by up to the depth.",
34
74
  "unit": "ms",
35
- "default": 4,
36
- "defaultFrom": "desk"
37
- }
38
- },
39
- "CHORUS_VOICES_RANGE": {
40
- "kind": "travels",
41
- "doc": "The chorus's tap count — one oscillator read at k/N (§3a).",
42
- "travel": {
43
- "min": 1,
44
- "max": 4,
45
- "step": 1,
46
- "unit": "",
47
- "default": 3,
48
- "defaultFrom": "desk"
49
- }
50
- },
51
- "CHORUS_MIX_RANGE": {
52
- "kind": "travels",
53
- "doc": "The chorus's wet/dry, as an instance of DRIVE_MIX_RANGE — the same percent travel, its own come-up value: 35 %, a colour under the dry on a strip (§3a). The REVERB_LOWCUT_RANGE-over- REVERB_CUT_RANGE shape: one travel, one declared default per field that reads it.",
54
- "travel": {
55
- "min": 0,
56
- "max": 100,
57
- "step": 0.1,
58
- "unit": "%",
59
- "default": 35,
60
- "defaultFrom": "desk"
61
- }
62
- },
63
- "CHORUS_BASE_MS": {
64
- "kind": "scalar",
65
- "doc": "The chorus's base delay, ms: the voices swing around it by up to the depth.",
66
- "unit": "ms",
67
- "value": 10
68
- },
69
- "CHORUS_MAX_MS": {
70
- "kind": "scalar",
71
- "doc": "The longest delay a chorus voice reaches, rounded up to whole ms: the base delay plus the depth maximum. The delay line is sized from it.",
72
- "unit": "ms",
73
- "value": {
74
- "derive": "ceilSum",
75
- "of": [
76
- {
77
- "ref": "CHORUS_BASE_MS"
78
- },
79
- {
80
- "ref": "CHORUS_DEPTH_RANGE.max"
81
- }
82
- ]
75
+ "value": 10
76
+ },
77
+ "CHORUS_MAX_MS": {
78
+ "kind": "scalar",
79
+ "doc": "The longest delay a chorus voice reaches, rounded up to whole ms: the base delay plus the depth maximum. The delay line is sized from it.",
80
+ "unit": "ms",
81
+ "value": {
82
+ "derive": "ceilSum",
83
+ "of": [
84
+ {
85
+ "ref": "CHORUS_BASE_MS"
86
+ },
87
+ {
88
+ "ref": "CHORUS_DEPTH_RANGE.max"
89
+ }
90
+ ]
91
+ }
83
92
  }
84
93
  }
85
94
  }
@@ -1,58 +1,89 @@
1
1
  {
2
2
  "$schema": "../../schema/omx-contract.schema.json",
3
- "COMP_LIMITS": {
4
- "kind": "travels",
5
- "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.",
6
- "fields": {
7
- "thresholdDb": {
3
+ "controls": [
4
+ {
5
+ "name": "thresholdDb",
6
+ "kind": "travel",
7
+ "table": "COMP_LIMITS",
8
+ "travel": {
8
9
  "min": -60,
9
10
  "max": 0,
10
11
  "step": 1,
11
12
  "unit": "dB",
12
13
  "default": -18,
13
14
  "defaultFrom": "desk"
14
- },
15
- "ratio": {
15
+ }
16
+ },
17
+ {
18
+ "name": "ratio",
19
+ "kind": "travel",
20
+ "table": "COMP_LIMITS",
21
+ "travel": {
16
22
  "min": 1,
17
23
  "max": 20,
18
24
  "step": 0.5,
19
25
  "unit": "",
20
26
  "default": 4,
21
27
  "defaultFrom": "desk"
22
- },
23
- "kneeDb": {
28
+ }
29
+ },
30
+ {
31
+ "name": "kneeDb",
32
+ "kind": "travel",
33
+ "table": "COMP_LIMITS",
34
+ "travel": {
24
35
  "min": 0,
25
36
  "max": 24,
26
37
  "step": 0.5,
27
38
  "unit": "dB",
28
39
  "default": 6,
29
40
  "defaultFrom": "desk"
30
- },
31
- "attackMs": {
41
+ }
42
+ },
43
+ {
44
+ "name": "attackMs",
45
+ "kind": "travel",
46
+ "table": "COMP_LIMITS",
47
+ "travel": {
32
48
  "min": 0.1,
33
49
  "max": 100,
34
50
  "step": 1,
35
51
  "unit": "ms",
36
52
  "default": 5,
37
53
  "defaultFrom": "desk"
38
- },
39
- "releaseMs": {
54
+ }
55
+ },
56
+ {
57
+ "name": "releaseMs",
58
+ "kind": "travel",
59
+ "table": "COMP_LIMITS",
60
+ "travel": {
40
61
  "min": 5,
41
62
  "max": 3000,
42
63
  "step": 10,
43
64
  "unit": "ms",
44
65
  "default": 200,
45
66
  "defaultFrom": "desk"
46
- },
47
- "makeupDb": {
67
+ }
68
+ },
69
+ {
70
+ "name": "makeupDb",
71
+ "kind": "travel",
72
+ "table": "COMP_LIMITS",
73
+ "travel": {
48
74
  "min": 0,
49
75
  "max": 24,
50
76
  "step": 0.5,
51
77
  "unit": "dB",
52
78
  "default": 0,
53
79
  "defaultFrom": "desk"
54
- },
55
- "mixPct": {
80
+ }
81
+ },
82
+ {
83
+ "name": "mixPct",
84
+ "kind": "travel",
85
+ "table": "COMP_LIMITS",
86
+ "travel": {
56
87
  "min": 0,
57
88
  "max": 100,
58
89
  "step": 5,
@@ -61,19 +92,26 @@
61
92
  "defaultFrom": "desk"
62
93
  }
63
94
  }
95
+ ],
96
+ "tables": {
97
+ "COMP_LIMITS": {
98
+ "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."
99
+ }
64
100
  },
65
- "PROGRAM_RELEASE": {
66
- "kind": "sheet",
67
- "doc": "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.",
68
- "value": {
69
- "chargeMs": 1000,
70
- "dischargeMs": 5000,
71
- "profiles": {
72
- "opto": {
73
- "fastMs": 70,
74
- "slowMinMs": 110,
75
- "slowMaxMs": 1100,
76
- "share": 0.5
101
+ "constants": {
102
+ "PROGRAM_RELEASE": {
103
+ "kind": "sheet",
104
+ "doc": "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.",
105
+ "value": {
106
+ "chargeMs": 1000,
107
+ "dischargeMs": 5000,
108
+ "profiles": {
109
+ "opto": {
110
+ "fastMs": 70,
111
+ "slowMinMs": 110,
112
+ "slowMaxMs": 1100,
113
+ "share": 0.5
114
+ }
77
115
  }
78
116
  }
79
117
  }