@openmixer/omx-contract 2.0.0 → 2.2.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.
@@ -245,6 +245,25 @@ export const COMP_LIMITS = {
245
245
  }
246
246
  };
247
247
 
248
+ /**
249
+ * The compressor's character, which picks its detector: `comp` runs the RMS detector, `limiter` the peak detector. The console's comp row carries it (`CompState.kind`, `COMP_KINDS`) and comes up as `comp`.
250
+ * @see data/kernels/comp.json
251
+ */
252
+ export const COMP_KINDS = [
253
+ "comp",
254
+ "limiter"
255
+ ];
256
+
257
+ /**
258
+ * The compressor's detector oversampling: `auto` runs the control path at 4x only while the attack is faster than DETECTOR_OVERSAMPLE_AUTO_MS, `off` never, `x4` always (omx-dsp's OMX_DYN_OVS_AUTO, _OFF and _X4, in that order). The console's comp row carries it as the operator's choice (`CompState.detectorOversampling`, spelled auto, off, 4x) and comes up `auto`.
259
+ * @see data/kernels/comp.json
260
+ */
261
+ export const DETECTOR_OVERSAMPLINGS = [
262
+ "auto",
263
+ "off",
264
+ "x4"
265
+ ];
266
+
248
267
  /**
249
268
  * 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.
250
269
  * @see data/kernels/comp.json
@@ -423,6 +442,15 @@ export const DELAY_MIX_RANGE = {
423
442
  }
424
443
  };
425
444
 
445
+ /**
446
+ * The FX delay's PING-PONG switch: on cross-feeds the two legs' feedback, so each leg's repeats come from the other leg's damped tap and the echoes bounce L<->R (`omx_delay.h`'s `struct omx_fx_delay.pingpong`, read where the feedback is formed). A live control on the console's delay row (`DelayState.pingpong`) and on the omx-delay plugin. Its come-up value is FX_DELAY_PINGPONG_DEFAULT, by reference, so the default keeps one home.
447
+ * @see data/kernels/delay.json
448
+ */
449
+ export const DELAY_PINGPONGS = [
450
+ "off",
451
+ "on"
452
+ ];
453
+
426
454
  /**
427
455
  * The FX delay's PING-PONG come-up value — a toggle, so it has no travel, only this. Declared here rather than as a literal in the server's `DEFAULT_DELAY_STATE` (which now reads it) so the DPF plugin generator (`packages/omx-plugins/tools/params-gen.mjs`, `2026-09-25-omx-plugins-dpf.md` §3a) reads the same fact the desk seeds a fresh strip with.
428
456
  * @see data/kernels/delay.json
@@ -517,54 +545,47 @@ export const DRIVE_BANDS = [
517
545
  ];
518
546
 
519
547
  /**
520
- * The bias at `character = +1`, in the SHAPER's own input units — `OMX_DRIVE_BIAS_MAX`. DERIVED, not chosen (§4b). In the saturating limit every knee tends to `sgn`, the output is a rectangular wave of duty `d = ½ + asin(b)/π`, and its `n`th harmonic goes as `sin(nπd)/n` — so `|H2|` is maximal at `d = ¾`, which is `b = sin(π/4) = 1/√2` exactly. Measured too: the H2-maximising bias for a full-scale sine at unity drive is 0.712 (soft), 0.658 (tape), 0.645 (tube), and this one value costs 0.000 / 0.035 / 0.088 dB of H2 against each curve's own optimum. ONE normalised bias for all three knees, not three.
548
+ * The drive's AUTO-GAIN switch: on, the stage measures its own level change and matches it out (`omx_drive.h`'s `struct omx_drive.auto_gain`, drive-stage spec §4d), so turning the drive up adds colour rather than loudness. A field of the console's drive row (`DriveState.autoGain`), on at come-up there and in omx-dsp's drive face.
521
549
  * @see data/kernels/drive.json
522
550
  */
523
- export const DRIVE_BIAS_MAX = 0.7071067811865476;
551
+ export const DRIVE_AUTO_GAINS = [
552
+ "off",
553
+ "on"
554
+ ];
524
555
 
525
556
  /**
526
- * The frequency band a parametric band can be dragged across.
527
- * @see data/kernels/eq.json
557
+ * The drive's STEREO LINK switch: on, ONE auto-gain path is driven by both legs' summed energy, so the image cannot shift under the level match; off, each leg matches its own (`omx_drive.h`'s `struct omx_drive.stereo_link`, spec §4e). A field of the console's drive row (`DriveState.stereoLink`), on at come-up there and in omx-dsp's drive face.
558
+ * @see data/kernels/drive.json
528
559
  */
529
- export const EQ_FREQ_RANGE = {
530
- "min": 20,
531
- "max": 20000,
532
- "step": 10,
533
- "unit": "Hz"
534
- };
560
+ export const DRIVE_STEREO_LINKS = [
561
+ "off",
562
+ "on"
563
+ ];
535
564
 
536
565
  /**
537
- * The parametric-band gain travel, in dB. The ± scalar `clampEqGain` enforces is READ off this (`core/src/eq.ts` derives `EQ_GAIN_LIMIT_DB` from `max`), so the bound is stated once and a surface cannot draw a wider knob than the clamp will honour.
538
- * @see data/kernels/eq.json
566
+ * The drive's post-curve HIGH-FREQUENCY ROLL-OFF corner, in Hz: the id is the corner, and 0 names no roll-off (the section does not run: `omx_drive.h`'s `struct omx_drive.hf_on` is 0). The console's drive row offers off, 12000 and 16000 (`DriveState.hfRolloff`, `DRIVE_HF_ROLLOFFS`) and comes up off, as omx-dsp's drive face does.
567
+ * @see data/kernels/drive.json
539
568
  */
540
- export const EQ_GAIN_RANGE = {
541
- "min": -15,
542
- "max": 15,
543
- "step": 0.5,
544
- "unit": "dB"
545
- };
569
+ export const DRIVE_HF_ROLLOFFS = [
570
+ 0,
571
+ 12000,
572
+ 16000
573
+ ];
546
574
 
547
575
  /**
548
- * The Q (resonance) extent of a parametric band.
549
- * @see data/kernels/eq.json
576
+ * The bias at `character = +1`, in the SHAPER's own input units — `OMX_DRIVE_BIAS_MAX`. DERIVED, not chosen (§4b). In the saturating limit every knee tends to `sgn`, the output is a rectangular wave of duty `d = ½ + asin(b)/π`, and its `n`th harmonic goes as `sin(nπd)/n` — so `|H2|` is maximal at `d = ¾`, which is `b = sin(π/4) = 1/√2` exactly. Measured too: the H2-maximising bias for a full-scale sine at unity drive is 0.712 (soft), 0.658 (tape), 0.645 (tube), and this one value costs 0.000 / 0.035 / 0.088 dB of H2 against each curve's own optimum. ONE normalised bias for all three knees, not three.
577
+ * @see data/kernels/drive.json
550
578
  */
551
- export const EQ_Q_RANGE = {
552
- "min": 0.3,
553
- "max": 8,
554
- "step": 0.1,
555
- "unit": ""
556
- };
579
+ export const DRIVE_BIAS_MAX = 0.7071067811865476;
557
580
 
558
581
  /**
559
- * The Q extent of a NOTCH band. Wider than EQ_Q_RANGE on purpose (#116, operator decision): an ultra-narrow surgical cut is the notch shape's whole point — the FBS ring-out pool plants dbx-AFS-style ring killers at Q ≈ 116 (~1/80 octave), inaudible on program yet fatal to a single ringing frequency. Gain-carrying shapes stay bounded at EQ_Q_RANGE — a Q-116 bell is a resonator, not an EQ move.
582
+ * The HPF's switch: off, the filter is not in the EQ's bank at all; on, its one or two Butterworth sections run after the bands. It comes up off, on the console's channel EQ and in omx-dsp's EQ face (see EQ_PASS_FILTER_DEFAULTS).
560
583
  * @see data/kernels/eq.json
561
584
  */
562
- export const EQ_NOTCH_Q_RANGE = {
563
- "min": 0.3,
564
- "max": 116,
565
- "step": 1,
566
- "unit": ""
567
- };
585
+ export const EQ_HPF_ONS = [
586
+ "off",
587
+ "on"
588
+ ];
568
589
 
569
590
  /**
570
591
  * Where the HPF cutoff can sit (low end); mirrors the drawn low-frequency handle.
@@ -577,6 +598,24 @@ export const HPF_FREQ_RANGE = {
577
598
  "unit": "Hz"
578
599
  };
579
600
 
601
+ /**
602
+ * The pass-filter slopes the EQ offers, in dB per octave: the HPF's slope, and by use the LPF's. The id is the slope: 12 is one maximally flat section, 24 the two-section Butterworth.
603
+ * @see data/kernels/eq.json
604
+ */
605
+ export const FILTER_SLOPES = [
606
+ 12,
607
+ 24
608
+ ];
609
+
610
+ /**
611
+ * The LPF's switch: off, the filter is not in the EQ's bank at all; on, its one or two Butterworth sections run after the bands and the HPF. It comes up off, on the console's channel EQ and in omx-dsp's EQ face (see EQ_PASS_FILTER_DEFAULTS).
612
+ * @see data/kernels/eq.json
613
+ */
614
+ export const EQ_LPF_ONS = [
615
+ "off",
616
+ "on"
617
+ ];
618
+
580
619
  /**
581
620
  * Where the LPF cutoff can sit (high end).
582
621
  * @see data/kernels/eq.json
@@ -602,12 +641,56 @@ export const EQ_BAND_TYPES = [
602
641
  ];
603
642
 
604
643
  /**
605
- * The pass-filter slopes the EQ offers, in dB per octave. The id is the slope: 12 is one maximally flat section, 24 the two-section Butterworth.
644
+ * The frequency band a parametric band can be dragged across.
606
645
  * @see data/kernels/eq.json
607
646
  */
608
- export const FILTER_SLOPES = [
609
- 12,
610
- 24
647
+ export const EQ_FREQ_RANGE = {
648
+ "min": 20,
649
+ "max": 20000,
650
+ "step": 10,
651
+ "unit": "Hz"
652
+ };
653
+
654
+ /**
655
+ * The parametric-band gain travel, in dB. The ± scalar `clampEqGain` enforces is READ off this (`core/src/eq.ts` derives `EQ_GAIN_LIMIT_DB` from `max`), so the bound is stated once and a surface cannot draw a wider knob than the clamp will honour.
656
+ * @see data/kernels/eq.json
657
+ */
658
+ export const EQ_GAIN_RANGE = {
659
+ "min": -15,
660
+ "max": 15,
661
+ "step": 0.5,
662
+ "unit": "dB"
663
+ };
664
+
665
+ /**
666
+ * The Q (resonance) extent of a parametric band.
667
+ * @see data/kernels/eq.json
668
+ */
669
+ export const EQ_Q_RANGE = {
670
+ "min": 0.3,
671
+ "max": 8,
672
+ "step": 0.1,
673
+ "unit": ""
674
+ };
675
+
676
+ /**
677
+ * The Q extent of a NOTCH band. Wider than EQ_Q_RANGE on purpose (#116, operator decision): an ultra-narrow surgical cut is the notch shape's whole point — the FBS ring-out pool plants dbx-AFS-style ring killers at Q ≈ 116 (~1/80 octave), inaudible on program yet fatal to a single ringing frequency. Gain-carrying shapes stay bounded at EQ_Q_RANGE — a Q-116 bell is a resonator, not an EQ move.
678
+ * @see data/kernels/eq.json
679
+ */
680
+ export const EQ_NOTCH_Q_RANGE = {
681
+ "min": 0.3,
682
+ "max": 116,
683
+ "step": 1,
684
+ "unit": ""
685
+ };
686
+
687
+ /**
688
+ * A band's switch. Off, the band is not in the EQ's bank (the slots close up); on, it runs, parked when it is a gain-carrying shape at 0 dB. No come-up value is declared, because the strips differ: the console's channel EQ brings its default bands up on, an omx-eq plugin brings every band up off so a racked instance is a wire.
689
+ * @see data/kernels/eq.json
690
+ */
691
+ export const EQ_BAND_ONS = [
692
+ "off",
693
+ "on"
611
694
  ];
612
695
 
613
696
  /**
@@ -622,11 +622,36 @@ enum omx_band_dyn_modes {
622
622
  };
623
623
  #define OMX_BAND_DYN_MODES_COUNT 2u
624
624
  #define OMX_BAND_DYN_MODES_DEFAULT OMX_BAND_DYN_MODES_ABOVE
625
+ enum omx_comp_kinds {
626
+ OMX_COMP_KINDS_COMP = 0,
627
+ OMX_COMP_KINDS_LIMITER = 1,
628
+ };
629
+ #define OMX_COMP_KINDS_COUNT 2u
630
+ #define OMX_COMP_KINDS_DEFAULT OMX_COMP_KINDS_COMP
625
631
  enum omx_deess_modes {
626
632
  OMX_DEESS_MODES_SPLIT = 0,
627
633
  OMX_DEESS_MODES_WIDEBAND = 1,
628
634
  };
629
635
  #define OMX_DEESS_MODES_COUNT 2u
636
+ enum omx_delay_pingpongs {
637
+ OMX_DELAY_PINGPONGS_OFF = 0,
638
+ OMX_DELAY_PINGPONGS_ON = 1,
639
+ };
640
+ #define OMX_DELAY_PINGPONGS_COUNT 2u
641
+ #define OMX_DELAY_PINGPONGS_DEFAULT OMX_DELAY_PINGPONGS_OFF
642
+ enum omx_detector_oversamplings {
643
+ OMX_DETECTOR_OVERSAMPLINGS_AUTO = 0,
644
+ OMX_DETECTOR_OVERSAMPLINGS_OFF = 1,
645
+ OMX_DETECTOR_OVERSAMPLINGS_X4 = 2,
646
+ };
647
+ #define OMX_DETECTOR_OVERSAMPLINGS_COUNT 3u
648
+ #define OMX_DETECTOR_OVERSAMPLINGS_DEFAULT OMX_DETECTOR_OVERSAMPLINGS_AUTO
649
+ enum omx_drive_auto_gains {
650
+ OMX_DRIVE_AUTO_GAINS_OFF = 0,
651
+ OMX_DRIVE_AUTO_GAINS_ON = 1,
652
+ };
653
+ #define OMX_DRIVE_AUTO_GAINS_COUNT 2u
654
+ #define OMX_DRIVE_AUTO_GAINS_DEFAULT OMX_DRIVE_AUTO_GAINS_ON
630
655
  enum omx_drive_bands {
631
656
  OMX_DRIVE_BANDS_FULL = 0,
632
657
  OMX_DRIVE_BANDS_LOW = 1,
@@ -641,6 +666,24 @@ enum omx_drive_curves {
641
666
  OMX_DRIVE_CURVES_EXCITER = 3,
642
667
  };
643
668
  #define OMX_DRIVE_CURVES_COUNT 4u
669
+ enum omx_drive_hf_rolloffs {
670
+ OMX_DRIVE_HF_ROLLOFFS_0 = 0,
671
+ OMX_DRIVE_HF_ROLLOFFS_12000 = 12000,
672
+ OMX_DRIVE_HF_ROLLOFFS_16000 = 16000,
673
+ };
674
+ #define OMX_DRIVE_HF_ROLLOFFS_COUNT 3u
675
+ #define OMX_DRIVE_HF_ROLLOFFS_DEFAULT OMX_DRIVE_HF_ROLLOFFS_0
676
+ enum omx_drive_stereo_links {
677
+ OMX_DRIVE_STEREO_LINKS_OFF = 0,
678
+ OMX_DRIVE_STEREO_LINKS_ON = 1,
679
+ };
680
+ #define OMX_DRIVE_STEREO_LINKS_COUNT 2u
681
+ #define OMX_DRIVE_STEREO_LINKS_DEFAULT OMX_DRIVE_STEREO_LINKS_ON
682
+ enum omx_eq_band_ons {
683
+ OMX_EQ_BAND_ONS_OFF = 0,
684
+ OMX_EQ_BAND_ONS_ON = 1,
685
+ };
686
+ #define OMX_EQ_BAND_ONS_COUNT 2u
644
687
  enum omx_eq_band_types {
645
688
  OMX_EQ_BAND_TYPES_BELL = 0,
646
689
  OMX_EQ_BAND_TYPES_LOW_SHELF = 1,
@@ -651,6 +694,18 @@ enum omx_eq_band_types {
651
694
  };
652
695
  #define OMX_EQ_BAND_TYPES_COUNT 6u
653
696
  #define OMX_EQ_BAND_TYPES_DEFAULT OMX_EQ_BAND_TYPES_BELL
697
+ enum omx_eq_hpf_ons {
698
+ OMX_EQ_HPF_ONS_OFF = 0,
699
+ OMX_EQ_HPF_ONS_ON = 1,
700
+ };
701
+ #define OMX_EQ_HPF_ONS_COUNT 2u
702
+ #define OMX_EQ_HPF_ONS_DEFAULT OMX_EQ_HPF_ONS_OFF
703
+ enum omx_eq_lpf_ons {
704
+ OMX_EQ_LPF_ONS_OFF = 0,
705
+ OMX_EQ_LPF_ONS_ON = 1,
706
+ };
707
+ #define OMX_EQ_LPF_ONS_COUNT 2u
708
+ #define OMX_EQ_LPF_ONS_DEFAULT OMX_EQ_LPF_ONS_OFF
654
709
  enum omx_filter_slopes {
655
710
  OMX_FILTER_SLOPES_12 = 12,
656
711
  OMX_FILTER_SLOPES_24 = 24,
package/lib/data.mjs CHANGED
@@ -54,6 +54,13 @@ export const isKernelFile = (rel) => /(^|\/)kernels\/[^/]+\.json$/.test(rel);
54
54
  /** The kernel's upper-case prefix, read off its file name: `kernels/band_dyn.json` is BAND_DYN. */
55
55
  export const kernelPrefix = (rel) => rel.replace(/^.*\//, '').replace(/\.json$/, '').toUpperCase().replace(/-/g, '_');
56
56
 
57
+ /**
58
+ * Whether a control is a USE: it declares no travel and no ids of its own and takes its values from the item its
59
+ * `global` names, which another control of the same kernel declares (the EQ's lpfSlope takes FILTER_SLOPES, which
60
+ * hpfSlope declares). A use adds no item; it is a control of the kernel all the same.
61
+ */
62
+ export const isUse = (c) => c && !c.table && c.travel === undefined && c.ids === undefined && typeof c.global === 'string';
63
+
57
64
  /** A control's item name: its `global`, else <KERNEL>_<NAME>_RANGE for a travel and <KERNEL>_<NAME>S for a choice. */
58
65
  export const controlGlobal = (rel, c) => c.global ?? `${kernelPrefix(rel)}_${snakeOf(c.name)}${c.kind === 'choice' ? 'S' : '_RANGE'}`;
59
66
 
@@ -69,16 +76,20 @@ export function entriesOf(rel, doc) {
69
76
  const out = [];
70
77
  const controls = Array.isArray(doc.controls) ? doc.controls : [];
71
78
  const tables = new Map(Object.entries(doc.tables ?? {}).map(([n, meta]) => [n, { kind: 'travels', ...meta, fields: {} }]));
79
+ const placed = new Set();
72
80
  for (const c of controls) {
73
- const { name, kind, global, table, ...rest } = c;
81
+ const { name, kind, global, table, rearms, count, of, when, ...rest } = c;
82
+ if (isUse(c)) continue;
74
83
  if (table) {
75
84
  if (!tables.has(table)) throw new Error(`${rel}: control ${name} names table ${table}, which is not declared`);
85
+ // a table is an item where its first control stands, so a control after a table keeps its place
86
+ if (!placed.has(table)) { placed.add(table); out.push([table, tables.get(table)]); }
76
87
  tables.get(table).fields[name] = rest.travel;
77
88
  continue;
78
89
  }
79
90
  out.push([controlGlobal(rel, c), kind === 'choice' ? { kind: 'set', ...rest } : { kind: 'travels', ...rest }]);
80
91
  }
81
- for (const [n, t] of tables) out.push([n, t]);
92
+ for (const [n, t] of tables) if (!placed.has(n)) out.push([n, t]);
82
93
  const byName = new Map(controls.map((c) => [c.name, c]));
83
94
  for (const [n, a] of Object.entries(doc.aggregates ?? {})) {
84
95
  const { fields, ...meta } = a;
@@ -91,6 +102,42 @@ export function entriesOf(rel, doc) {
91
102
  return out;
92
103
  }
93
104
 
105
+ /** A kernel's name, read off its file name: `kernels/band_dyn.json` is band_dyn. */
106
+ export const kernelName = (rel) => rel.replace(/^.*\//, '').replace(/\.json$/, '');
107
+
108
+ /**
109
+ * Every kernel's controls, in its file's order: `{ <kernel>: { controls: [{ name, kind, global, table?, rearms?, count?, when? }] } }`,
110
+ * kernels in file order. `global` is the item the control's value lives in: its own travel or set, or, for a field of
111
+ * a table, the table (`table` names it too, and the field is the control's name). `rearms` is carried only when true:
112
+ * changing the control re-arms the kernel's state, it is not a smooth parameter. `count` names the item that counts a
113
+ * control the kernel takes once per band (a scalar, or a sheet whose every variant gives its count as `max`). A
114
+ * control that declares `of` is no control of its own but the travel the control it names reaches while the choice
115
+ * `when` names holds the id it names: it is listed under that control's `when` as `{ control, is, global }`.
116
+ */
117
+ export function kernelControls(data) {
118
+ const out = {};
119
+ for (const f of data.files) {
120
+ if (!isKernelFile(f.rel)) continue;
121
+ const controls = Array.isArray(f.doc.controls) ? f.doc.controls : [];
122
+ const global = (c) => c.table ?? controlGlobal(f.rel, c);
123
+ out[kernelName(f.rel)] = {
124
+ controls: controls.filter((c) => c.of === undefined).map((c) => {
125
+ const when = controls.filter((x) => x.of === c.name).map((x) => ({ control: x.when.control, is: x.when.is, global: global(x) }));
126
+ return {
127
+ name: c.name,
128
+ kind: c.kind,
129
+ global: global(c),
130
+ ...(c.table ? { table: c.table } : {}),
131
+ ...(c.rearms === true ? { rearms: true } : {}),
132
+ ...(c.count !== undefined ? { count: c.count } : {}),
133
+ ...(when.length ? { when } : {}),
134
+ };
135
+ }),
136
+ };
137
+ }
138
+ return out;
139
+ }
140
+
94
141
  const isWholeRef = (v) => typeof v === 'object' && v !== null && Object.keys(v).length === 1 && typeof v.ref === 'string' && !v.ref.includes('.');
95
142
 
96
143
  /**
@@ -213,7 +260,8 @@ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
213
260
 
214
261
  /**
215
262
  * Resolve every item: `{ name, rel, kind, doc, why?, unit?, c?, shape?, default?, labels?, value }` (default and labels: a set's), where `value` has
216
- * every `{ref}` and `{derive}` evaluated.
263
+ * 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.
217
265
  * @throws naming the item: a reference that does not resolve, a cycle, an unknown derivation.
218
266
  */
219
267
  export function resolveData(data) {
@@ -251,7 +299,7 @@ export function resolveData(data) {
251
299
  if (item.unit !== undefined) r.unit = item.unit;
252
300
  if (item.c !== undefined) r.c = item.c;
253
301
  if (item.kind === 'set') {
254
- if (item.default !== undefined) r.default = item.default;
302
+ if (item.default !== undefined) r.default = setDefault(item, (v) => val(v, name), name);
255
303
  if (item.labels !== undefined) r.labels = item.labels;
256
304
  }
257
305
  if (item.kind === 'travels') r.shape = item.fields ? 'table' : 'travel';
@@ -262,9 +310,24 @@ export function resolveData(data) {
262
310
  }
263
311
  const out = new Map();
264
312
  for (const name of data.items.keys()) out.set(name, resolveItem(name));
313
+ out.kernels = kernelControls(data);
265
314
  return out;
266
315
  }
267
316
 
317
+ /**
318
+ * A set's default, resolved: an id as written, or `{ ref }` to the scalar that states it. A boolean scalar names the
319
+ * second id of a two-id switch when true and the first when false (FX_DELAY_PINGPONG_DEFAULT is the delay's
320
+ * DELAY_PINGPONG_SWITCH default, so the come-up value keeps one home).
321
+ * @throws naming the set: a boolean default of a set that is not two ids.
322
+ */
323
+ export function setDefault(item, val, name) {
324
+ if (!isObj(item.default)) return item.default;
325
+ const v = val(item.default);
326
+ if (typeof v !== 'boolean') return v;
327
+ if (item.ids.length !== 2) throw new Error(`${name}: a boolean default needs a two-id switch, not ${item.ids.length} ids`);
328
+ return item.ids[v ? 1 : 0];
329
+ }
330
+
268
331
  /** The package version (package.json), which names the tag, the release and the npm version. */
269
332
  export function packageVersion(root) {
270
333
  return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
package/lib/fmt.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  const ORDER = {
11
- control: ['name', 'kind', 'global', 'table', 'since', 'doc', 'why', 'unit', 'ids', 'default', 'labels', 'travel', 'c'],
11
+ control: ['name', 'kind', 'global', 'table', 'rearms', 'since', 'doc', 'why', 'unit', 'ids', 'default', 'labels', 'travel', 'c'],
12
12
  sections: ['controls', 'tables', 'aggregates', 'constants'],
13
13
  item: ['kind', 'since', 'doc', 'why', 'unit', 'value', 'values', 'ids', 'default', 'labels', 'fields', 'travel', 'c'],
14
14
  travel: ['min', 'max', 'step', 'unit', 'default', 'defaultFrom', 'scale', 'byKind', 'doc', 'why'],
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, entriesOf, isKernelFile, resolveData } from './data.mjs';
11
+ import { 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';
@@ -18,7 +18,10 @@ let compiled;
18
18
  /**
19
19
  * The kernel-file shape (2.0.0), which the item schema cannot see: only the four sections at the top; every control
20
20
  * named, of kind travel or choice, unique by name, a travel carrying a `travel` and a choice its `ids`; a control's
21
- * `table` declared under `tables`; an aggregate's fields naming controls; a constant a scalar, a list or a sheet.
21
+ * `table` declared under `tables`; `rearms` a boolean, and only on a control; a use (a control with a `global` and no
22
+ * travel or ids) naming an item another control of the kernel declares, of its kind; `count` an item name; `of` naming
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.
22
25
  * A kernel file written as a flat item map (the 1.x shape) is refused as a whole. Any other data file returns [].
23
26
  */
24
27
  export function shapeProblems(rel, doc) {
@@ -37,10 +40,31 @@ export function shapeProblems(rel, doc) {
37
40
  if (typeof c.name !== 'string' || !/^[a-z][A-Za-z0-9]*$/.test(c.name)) out.push(`controls[${i}]: name ${JSON.stringify(c.name)} is not a lowerCamel identifier`);
38
41
  if (seen.has(c.name)) out.push(`controls[${i}]: name ${c.name} declared twice`);
39
42
  seen.add(c.name);
40
- if (c.kind === 'travel' && !c.travel) out.push(`controls[${i}] ${c.name}: a travel without "travel"`);
43
+ if (c.kind !== 'travel' && c.kind !== 'choice') out.push(`controls[${i}] ${c.name}: kind ${JSON.stringify(c.kind)} is neither travel nor choice`);
44
+ else if (isUse(c)) {
45
+ const owner = controls.find((x) => x !== c && !isUse(x) && !x.table && controlGlobal(rel, x) === c.global);
46
+ if (!owner) out.push(`controls[${i}] ${c.name}: uses ${c.global}, which no other control of this kernel declares`);
47
+ else if (owner.kind !== c.kind) out.push(`controls[${i}] ${c.name}: a ${c.kind} that uses ${c.global}, which is a ${owner.kind}`);
48
+ for (const k of ['doc', 'default', 'labels', 'c']) if (k in c) out.push(`controls[${i}] ${c.name}: a use carries no ${k}; ${c.global} holds it`);
49
+ } else if (c.kind === 'travel' && !c.travel) out.push(`controls[${i}] ${c.name}: a travel without "travel"`);
41
50
  else if (c.kind === 'choice' && !Array.isArray(c.ids)) out.push(`controls[${i}] ${c.name}: a choice without "ids"`);
42
- else if (c.kind !== 'travel' && c.kind !== 'choice') out.push(`controls[${i}] ${c.name}: kind ${JSON.stringify(c.kind)} is neither travel nor choice`);
51
+ if ('count' in c && (typeof c.count !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(c.count))) out.push(`controls[${i}] ${c.name}: count ${JSON.stringify(c.count)} is not an item name`);
52
+ if ('of' in c || 'when' in c) {
53
+ const target = controls.find((x) => x.name === c.of);
54
+ const w = c.when;
55
+ const chooser = w && controls.find((x) => x.name === w.control);
56
+ const ids = chooser && (isUse(chooser) ? controls.find((x) => !isUse(x) && controlGlobal(rel, x) === chooser.global)?.ids : chooser.ids);
57
+ if (!target || target === c || target.of !== undefined || target.kind !== c.kind) out.push(`controls[${i}] ${c.name}: of ${JSON.stringify(c.of)} names no other control of its kind`);
58
+ else if (!w || typeof w !== 'object' || Object.keys(w).sort().join() !== 'control,is') out.push(`controls[${i}] ${c.name}: when must be { control, is }`);
59
+ else if (!chooser || chooser.kind !== 'choice') out.push(`controls[${i}] ${c.name}: when.control ${JSON.stringify(w.control)} names no choice of this kernel`);
60
+ else if (!(ids ?? []).includes(w.is)) out.push(`controls[${i}] ${c.name}: when.is ${JSON.stringify(w.is)} is not an id of ${w.control}`);
61
+ if (c.count !== undefined) out.push(`controls[${i}] ${c.name}: a control with of is counted as the control it names`);
62
+ }
43
63
  if (c.table && !(doc.tables && c.table in doc.tables)) out.push(`controls[${i}] ${c.name}: table ${c.table} is not declared`);
64
+ if ('rearms' in c && typeof c.rearms !== 'boolean') out.push(`controls[${i}] ${c.name}: rearms ${JSON.stringify(c.rearms)} is not a boolean`);
65
+ }
66
+ for (const s of ['tables', 'aggregates', 'constants']) {
67
+ for (const [n, it] of Object.entries(doc[s] ?? {})) if (it && typeof it === 'object' && 'rearms' in it) out.push(`${s} ${n}: rearms is a control's flag, never a ${s.slice(0, -1)}'s`);
44
68
  }
45
69
  for (const [n, a] of Object.entries(doc.aggregates ?? {})) for (const f of a.fields ?? []) if (!seen.has(f)) out.push(`aggregate ${n}: field ${f} names no control`);
46
70
  for (const [n, it] of Object.entries(doc.constants ?? {})) {
@@ -75,6 +99,19 @@ function semanticRules(resolved, say) {
75
99
  }
76
100
  }
77
101
 
102
+ // A per-band control's count: a positive integer scalar, or a sheet whose every variant has a positive integer max.
103
+ for (const [k, { controls }] of Object.entries(resolved.kernels ?? {})) {
104
+ for (const c of controls) {
105
+ if (c.count === undefined) continue;
106
+ const it = resolved.get(c.count);
107
+ const rel = `data/kernels/${k}.json`;
108
+ if (!it) { say(rel, c.global, 'count', `${k}.${c.name}: count ${c.count} is no item`); continue; }
109
+ const ok = (n) => Number.isInteger(n) && n >= 1;
110
+ const good = it.kind === 'scalar' ? ok(it.value) : it.kind === 'sheet' && Object.values(it.value).length > 0 && Object.values(it.value).every((v) => ok(v?.max));
111
+ if (!good) say(rel, c.global, 'count', `${k}.${c.name}: count ${c.count} is neither a positive integer scalar nor a sheet of variants with a positive integer max`);
112
+ }
113
+ }
114
+
78
115
  // The EQ band budget (ruling 2026-10-08): what the operator keeps, what the feedback suppressor plants
79
116
  // and what the ring correction plants all fit in the band maximum.
80
117
  const reserve = val('OPERATOR_EQ_BANDS_RESERVE');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmixer/omx-contract",
3
- "version": "2.0.0",
3
+ "version": "2.2.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/json.mjs CHANGED
@@ -2,7 +2,9 @@
2
2
  // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
3
  /**
4
4
  * The `json` target: the resolved data (every reference and derivation evaluated) for readers in
5
- * other languages, which need no resolver of their own.
5
+ * other languages, which need no resolver of their own. Beside the items, `kernels` lists each
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.
6
8
  */
7
9
 
8
10
  /** Every file of the `json` target: `[{ path, text }]`. */
@@ -12,5 +14,7 @@ export function renderJson(resolved, version) {
12
14
  const { name, ...rest } = r;
13
15
  items[name] = rest;
14
16
  }
15
- return [{ path: 'omx-contract.json', text: `${JSON.stringify({ name: '@openmixer/omx-contract', version, items }, null, 2)}\n` }];
17
+ const doc = { name: '@openmixer/omx-contract', version, items };
18
+ if (resolved.kernels) doc.kernels = resolved.kernels;
19
+ return [{ path: 'omx-contract.json', text: `${JSON.stringify(doc, null, 2)}\n` }];
16
20
  }
@@ -166,7 +166,7 @@
166
166
  },
167
167
  "set": {
168
168
  "type": "object",
169
- "description": "Ordered ids with an optional default and labels. The ids and their order are the contract: all strings (the id is the name, its position the value) or all non-negative integers (the id is the value). Labels are face text and optional.",
169
+ "description": "Ordered ids with an optional default and labels. The ids and their order are the contract: all strings (the id is the name, its position the value) or all non-negative integers (the id is the value). Labels are face text and optional. The default may be a reference to the scalar that states it: a boolean names the second id of a two-id switch when true, the first when false.",
170
170
  "properties": {
171
171
  "kind": { "const": "set" },
172
172
  "since": { "$ref": "#/$defs/since" },
@@ -178,7 +178,7 @@
178
178
  { "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 1, "uniqueItems": true }
179
179
  ]
180
180
  },
181
- "default": { "oneOf": [{ "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9]*$" }, { "type": "integer", "minimum": 0 }] },
181
+ "default": { "oneOf": [{ "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9]*$" }, { "type": "integer", "minimum": 0 }, { "$ref": "#/$defs/ref" }] },
182
182
  "labels": { "type": "array", "items": { "$ref": "#/$defs/text" }, "minItems": 1 }
183
183
  },
184
184
  "required": ["kind", "doc", "ids"],