@openmixer/omx-contract 1.2.1 → 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.
Files changed (42) hide show
  1. package/README.md +41 -4
  2. package/bin/omx-contract.mjs +14 -3
  3. package/data/kernels/balance.json +18 -14
  4. package/data/kernels/band_dyn.json +132 -0
  5. package/data/kernels/chorus.json +87 -56
  6. package/data/kernels/comp.json +67 -29
  7. package/data/kernels/deesser.json +114 -86
  8. package/data/kernels/delay.json +67 -57
  9. package/data/kernels/drive.json +118 -87
  10. package/data/kernels/eq.json +284 -113
  11. package/data/kernels/fbs.json +150 -0
  12. package/data/kernels/flanger.json +74 -44
  13. package/data/kernels/gate.json +76 -21
  14. package/data/kernels/geq.json +32 -27
  15. package/data/kernels/hrp.json +68 -0
  16. package/data/kernels/limiter.json +25 -9
  17. package/data/kernels/mixmatrix.json +11 -0
  18. package/data/kernels/phaser.json +110 -100
  19. package/data/kernels/pitch.json +35 -17
  20. package/data/kernels/reverb.json +176 -142
  21. package/data/kernels/rotary.json +189 -177
  22. package/data/kernels/rta.json +17 -0
  23. package/data/kernels/transient.json +64 -36
  24. package/data/kernels/tremolo.json +59 -48
  25. package/data/primitives.json +51 -0
  26. package/data/rates.json +29 -0
  27. package/dist/ts/omx-contract.d.ts +527 -21
  28. package/dist/ts/omx-contract.js +832 -57
  29. package/include/omxcontract/omx_contract_limits.h +233 -0
  30. package/lib/data.mjs +115 -3
  31. package/lib/eq-defaults.mjs +87 -0
  32. package/lib/fmt.mjs +24 -4
  33. package/lib/semver.mjs +12 -2
  34. package/lib/validate.mjs +99 -3
  35. package/package.json +1 -1
  36. package/render/c.mjs +76 -10
  37. package/render/doors.mjs +6 -3
  38. package/render/ts.mjs +40 -2
  39. package/schema/omx-contract.schema.json +39 -3
  40. package/share/omx-contract/omx-contract.json +759 -71
  41. package/render/c/omx_plugin_param.h +0 -25
  42. package/render/c/params-generate.mjs +0 -83
package/lib/semver.mjs CHANGED
@@ -7,8 +7,8 @@
7
7
  * major a field or alias removed; an item removed (MINOR in the development phase, below); a
8
8
  * unit or kind changed; a travel narrowed; a constant a kernel computes with changed (a
9
9
  * scalar, sheet leaf or derived value that is not a travel bound or default; a list member
10
- * changed or removed)
11
- * minor an item, field, sheet leaf or list member added (appended); a travel widened;
10
+ * or set id changed or removed; a set's default added, removed)
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
14
  */
@@ -32,6 +32,7 @@ function travelChanges(at, a, b, say) {
32
32
  if (a.default !== b.default) say(a.default === undefined || b.default === undefined ? 'major' : 'minor', `${at}: default ${a.default} -> ${b.default}`);
33
33
  if (a.step !== b.step) say('minor', `${at}: step ${a.step} -> ${b.step}`);
34
34
  if (a.defaultFrom !== b.defaultFrom) say('minor', `${at}: defaultFrom ${a.defaultFrom} -> ${b.defaultFrom}`);
35
+ if (a.scale !== b.scale) say('minor', `${at}: scale ${a.scale} -> ${b.scale}`);
35
36
  const ka = a.byKind ?? {};
36
37
  const kb = b.byKind ?? {};
37
38
  for (const k of Object.keys(ka)) if (!(k in kb)) say('major', `${at}.byKind.${k} removed`);
@@ -92,6 +93,15 @@ export function classify(prev, next, { developmentPhase = DEVELOPMENT_PHASE } =
92
93
  });
93
94
  if (b.value.length > a.value.length) say('minor', `${name}: ${b.value.length - a.value.length} member(s) appended`);
94
95
  break;
96
+ case 'set':
97
+ a.value.forEach((v, i) => {
98
+ if (i >= b.value.length) say('major', `${name}[${i}] removed`);
99
+ else if (b.value[i] !== v) say('major', `${name}[${i}]: ${v} -> ${b.value[i]}`);
100
+ });
101
+ if (b.value.length > a.value.length) say('minor', `${name}: ${b.value.length - a.value.length} id(s) appended`);
102
+ if (a.default !== b.default) say(a.default === undefined || b.default === undefined ? 'major' : 'minor', `${name}: default ${a.default} -> ${b.default}`);
103
+ if (!same(a.labels, b.labels)) say('patch', `${name}: labels`);
104
+ break;
95
105
  default:
96
106
  break;
97
107
  }
package/lib/validate.mjs CHANGED
@@ -8,12 +8,47 @@
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 { resolveData } from './data.mjs';
11
+ import { KERNEL_SECTIONS, entriesOf, isKernelFile, resolveData } from './data.mjs';
12
+ import { eqDefaultBands, eqDefaultParams } from './eq-defaults.mjs';
12
13
  import { renderC } from '../render/c.mjs';
13
14
  import { renderTs } from '../render/ts.mjs';
14
15
 
15
16
  const SCHEMA = JSON.parse(readFileSync(new URL('../schema/omx-contract.schema.json', import.meta.url), 'utf8'));
16
17
  let compiled;
18
+ /**
19
+ * The kernel-file shape (2.0.0), which the item schema cannot see: only the four sections at the top; every control
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.
22
+ * A kernel file written as a flat item map (the 1.x shape) is refused as a whole. Any other data file returns [].
23
+ */
24
+ export function shapeProblems(rel, doc) {
25
+ if (!isKernelFile(rel)) return [];
26
+ const sections = KERNEL_SECTIONS;
27
+ const keys = Object.keys(doc).filter((k) => k !== '$schema');
28
+ if (keys.length && !keys.some((k) => sections.includes(k))) {
29
+ return [`${rel} is a flat item map (the 1.x shape): a kernel file declares its items under ${sections.join(', ')}`];
30
+ }
31
+ const out = [];
32
+ for (const k of keys) if (!sections.includes(k)) out.push(`unknown top-level key ${k}: a kernel file holds ${sections.join(', ')}`);
33
+ const controls = doc.controls ?? [];
34
+ if (!Array.isArray(controls)) return [...out, 'controls must be a list'];
35
+ const seen = new Set();
36
+ for (const [i, c] of controls.entries()) {
37
+ 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
+ if (seen.has(c.name)) out.push(`controls[${i}]: name ${c.name} declared twice`);
39
+ seen.add(c.name);
40
+ if (c.kind === 'travel' && !c.travel) out.push(`controls[${i}] ${c.name}: a travel without "travel"`);
41
+ 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`);
43
+ if (c.table && !(doc.tables && c.table in doc.tables)) out.push(`controls[${i}] ${c.name}: table ${c.table} is not declared`);
44
+ }
45
+ 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
+ for (const [n, it] of Object.entries(doc.constants ?? {})) {
47
+ if (!['scalar', 'list', 'sheet'].includes(it?.kind)) out.push(`constant ${n}: kind ${JSON.stringify(it?.kind)} is not scalar, list or sheet (a travel or a set is a control)`);
48
+ }
49
+ return out;
50
+ }
51
+
17
52
  /** The compiled schema validator. */
18
53
  export function schemaValidator() {
19
54
  if (!compiled) compiled = new Ajv2020({ allErrors: true, strict: true, strictRequired: false, allowUnionTypes: true }).compile(SCHEMA);
@@ -22,6 +57,62 @@ export function schemaValidator() {
22
57
 
23
58
  const constCase = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
24
59
 
60
+ /**
61
+ * The rules about the meaning of the numbers, not their shape (they live with the data they judge):
62
+ * a set's default and labels; a travel's scale is a PARAM_SCALES id; the EQ band counts and budget; the
63
+ * default EQ rule's inputs.
64
+ */
65
+ function semanticRules(resolved, say) {
66
+ const val = (name) => resolved.get(name)?.value;
67
+ for (const r of resolved.values()) {
68
+ if (r.kind === 'set') {
69
+ if (r.default !== undefined && !r.value.includes(r.default)) say(r.rel, r.name, 'set-default', `default ${JSON.stringify(r.default)} is not one of ${JSON.stringify(r.value)}`);
70
+ if (r.labels !== undefined && r.labels.length !== r.value.length) say(r.rel, r.name, 'set-labels', `${r.labels.length} labels for ${r.value.length} ids`);
71
+ }
72
+ const travels = r.kind === 'travels' ? (r.shape === 'table' ? Object.entries(r.value) : [['', r.value]]) : [];
73
+ for (const [field, t] of travels) {
74
+ if (t.scale !== undefined && !(val('PARAM_SCALES') ?? []).includes(t.scale)) say(r.rel, r.name, 'scale', `${field ? `${r.name}.${field}` : r.name}: scale ${JSON.stringify(t.scale)} is not an id of PARAM_SCALES`);
75
+ }
76
+ }
77
+
78
+ // The EQ band budget (ruling 2026-10-08): what the operator keeps, what the feedback suppressor plants
79
+ // and what the ring correction plants all fit in the band maximum.
80
+ const reserve = val('OPERATOR_EQ_BANDS_RESERVE');
81
+ if (reserve !== undefined) {
82
+ const need = reserve + val('FBS_DEFAULT_MAX_AUTO_BANDS') + val('HRP_DEFAULT_MAX_AUTO_BANDS');
83
+ const cap = val('EQ_MAX_BANDS');
84
+ if (!(need <= cap)) say(resolved.get('OPERATOR_EQ_BANDS_RESERVE').rel, 'OPERATOR_EQ_BANDS_RESERVE', 'eq-band-budget',
85
+ `OPERATOR_EQ_BANDS_RESERVE ${reserve} + FBS_DEFAULT_MAX_AUTO_BANDS ${val('FBS_DEFAULT_MAX_AUTO_BANDS')} + HRP_DEFAULT_MAX_AUTO_BANDS ${val('HRP_DEFAULT_MAX_AUTO_BANDS')} = ${need} exceeds EQ_MAX_BANDS ${cap}`);
86
+ }
87
+ const counts = val('EQ_BAND_COUNTS');
88
+ if (counts) {
89
+ const rel = resolved.get('EQ_BAND_COUNTS').rel;
90
+ for (const [strip, c] of Object.entries(counts)) {
91
+ for (const k of ['default', 'max']) if (!Number.isInteger(c[k]) || c[k] < 1) say(rel, 'EQ_BAND_COUNTS', 'eq-band-counts', `${strip}.${k} ${c[k]} is not a positive integer`);
92
+ if (c.default > c.max) say(rel, 'EQ_BAND_COUNTS', 'eq-band-counts', `${strip}: default ${c.default} exceeds max ${c.max}`);
93
+ }
94
+ }
95
+
96
+ // The default EQ rule: its inputs are consistent and it answers for every declared default count.
97
+ if (resolved.has('EQ_DEFAULT_CENTRES_FOUR_BAND_HZ')) {
98
+ const rel = resolved.get('EQ_DEFAULT_CENTRES_FOUR_BAND_HZ').rel;
99
+ const bad = (rule, message) => say(rel, 'EQ_DEFAULT_CENTRES_FOUR_BAND_HZ', rule, message);
100
+ let p;
101
+ try { p = eqDefaultParams(resolved); } catch (e) { bad('eq-default-rule', e.message); return; }
102
+ const [r10, r20] = p.series;
103
+ if (p.four.length !== 4) bad('eq-default-rule', `the four-band set holds ${p.four.length} centres`);
104
+ for (const [name, list] of [['four-band set', p.four], ['R10', r10], ['R20', r20]]) {
105
+ if (!list.every((f, i) => f >= p.lo && f <= p.hi && (i === 0 || f > list[i - 1]))) bad('eq-default-rule', `the ${name} is not ascending inside [${p.lo}, ${p.hi}] Hz`);
106
+ }
107
+ for (const f of [...p.four, ...r10]) if (!r20.includes(f)) bad('eq-default-rule', `${f} Hz is not in the R20 series: R10 and the four-band set are preferred values of it`);
108
+ const types = val('EQ_BAND_TYPES') ?? [];
109
+ for (const id of ['bell', 'lowShelf', 'highShelf']) if (!types.includes(id)) bad('eq-default-rule', `EQ_BAND_TYPES lacks ${id}, which the default rule uses`);
110
+ for (const [strip, c] of Object.entries(counts ?? {})) {
111
+ try { eqDefaultBands(c.default, p); } catch (e) { bad('eq-default-rule', `${strip}: ${e.message}`); }
112
+ }
113
+ }
114
+ }
115
+
25
116
  /** Every problem of the loaded data: `[{ file, item, rule, message }]`; empty is valid. */
26
117
  export function validateData(data) {
27
118
  const problems = [];
@@ -31,7 +122,10 @@ export function validateData(data) {
31
122
  const depth = f.rel.split('/').length - 1;
32
123
  const want = posix.join(...Array(depth).fill('..'), 'schema/omx-contract.schema.json');
33
124
  if (f.doc.$schema !== want) say(f.rel, '-', 'schema-path', `"$schema" must be "${want}"`);
34
- if (!validate(f.doc)) {
125
+ for (const m of shapeProblems(f.rel, f.doc)) say(f.rel, '-', 'shape', m);
126
+ // The schema reads items: a kernel file is checked as the items it expands to, so no rule is lost.
127
+ const items = { $schema: f.doc.$schema, ...Object.fromEntries(entriesOf(f.rel, f.doc)) };
128
+ if (!validate(items)) {
35
129
  for (const e of validate.errors) {
36
130
  const item = e.instancePath.split('/')[1] || '-';
37
131
  say(f.rel, item, 'schema', `${e.instancePath || '/'} ${e.message}${e.params?.allowedValues ? ` (${e.params.allowedValues.join(', ')})` : ''}`);
@@ -71,6 +165,8 @@ export function validateData(data) {
71
165
  if (typeof r.doc !== 'string' || !r.doc.trim()) say(r.rel, r.name, 'doc', 'every item carries its doc');
72
166
  }
73
167
 
168
+ semanticRules(resolved, say);
169
+
74
170
  for (const [target, files] of [['c', () => renderC(resolved)], ['ts', () => renderTs(resolved)]]) {
75
171
  let out;
76
172
  try {
@@ -82,7 +178,7 @@ export function validateData(data) {
82
178
  if (target !== 'c') continue;
83
179
  for (const f of out) {
84
180
  const seen = new Map();
85
- for (const m of f.text.matchAll(/^#define (\w+)/gm)) {
181
+ for (const m of f.text.matchAll(/^(?:#define | )(OMX_\w+)(?= = |\s)/gm)) {
86
182
  if (seen.has(m[1])) say('-', m[1], 'c-name-unique', `${f.path} defines ${m[1]} twice`);
87
183
  seen.set(m[1], true);
88
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmixer/omx-contract",
3
- "version": "1.2.1",
3
+ "version": "2.0.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
@@ -4,17 +4,13 @@
4
4
  * The `c` target: `omxcontract/omx_contract_limits.h`. Every define is spelled by openmixer's
5
5
  * renderer, moved unchanged (c/contract-limits-render.mjs); only the frame (banner, section
6
6
  * comments) is this repository's. A plugin's parameter header is rendered by omx-plugins from its
7
- * declaration; c/params-generate.mjs stays here only for renderSheetC, the frozen fixture of the
8
- * round-trip proof.
7
+ * declaration, by reference to the items here.
9
8
  */
10
- import { readFileSync } from 'node:fs';
11
9
  import {
12
10
  genCoreLimitBlock, genDeclaredListsBlock, genDeclaredRates, genDeclaredScalarsBlock, genLimitBlock, renderContractLimits,
13
11
  } from './c/contract-limits-render.mjs';
14
- import { generate } from './c/params-generate.mjs';
15
12
  import { doors } from './doors.mjs';
16
13
 
17
- const PLUGIN_PARAM_H = readFileSync(new URL('./c/omx_plugin_param.h', import.meta.url), 'utf8');
18
14
  const defined = (text) => new Set([...text.matchAll(/^#define (OMX_\w+)/gm)].map((m) => m[1]));
19
15
 
20
16
  /** The rate tail of openmixer's header, outside the include guard, byte for byte. */
@@ -39,6 +35,71 @@ static inline int omx_declared_rates_multiple_of(unsigned base, int *out, int ca
39
35
  `;
40
36
  }
41
37
 
38
+ const constCase = (s) => String(s).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
39
+
40
+ /** The enumerator names of one set, in order: `OMX_<SET>_<ID>`. */
41
+ export const setEnumerators = (set) => set.ids.map((id) => `OMX_${set.name}_${constCase(id)}`);
42
+
43
+ /**
44
+ * Every set as a C enum: the id's position is its value for named ids, the number itself for numeric
45
+ * ids; `OMX_<SET>_COUNT`, and `OMX_<SET>_DEFAULT` when the set names a default.
46
+ */
47
+ function genDeclaredSetsEnums(sets) {
48
+ return sets.map((set) => {
49
+ const names = setEnumerators(set);
50
+ const numeric = typeof set.ids[0] === 'number';
51
+ const members = set.ids.map((id, i) => ` ${names[i]} = ${numeric ? id : i},`);
52
+ const lines = [`enum omx_${set.name.toLowerCase()} {`, ...members, '};', `#define OMX_${set.name}_COUNT ${set.ids.length}u`];
53
+ if (set.default !== undefined) lines.push(`#define OMX_${set.name}_DEFAULT ${names[set.ids.indexOf(set.default)]}`);
54
+ return lines.join('\n');
55
+ }).join('\n');
56
+ }
57
+
58
+ /**
59
+ * The default band centres of an EQ (lib/eq-defaults.mjs is the rule; this is its C spelling, held
60
+ * equal to it for every count by test/eq-defaults.test.mjs). Writes \`count\` centres to \`out\` and returns
61
+ * \`count\`, or returns 0 for a count that is 0 or has no distinct preferred centres.
62
+ */
63
+ function eqCentresFunction() {
64
+ return `static inline unsigned omx_eq_default_centres(unsigned count, float *out) {
65
+ static const double r10[] = OMX_ISO_THIRD_OCTAVE_CENTRES_HZ_INIT;
66
+ static const double r20[] = OMX_EQ_CENTRE_SERIES_R20_HZ_INIT;
67
+ static const double four[] = OMX_EQ_DEFAULT_CENTRES_FOUR_BAND_HZ_INIT;
68
+ if (count == 0) return 0;
69
+ if (count == 4) {
70
+ for (unsigned i = 0; i < 4; i++) out[i] = (float)four[i];
71
+ return 4;
72
+ }
73
+ const double lo = log((double)OMX_EQ_FREQ_RANGE_MIN);
74
+ const double span = log((double)OMX_EQ_FREQ_RANGE_MAX / (double)OMX_EQ_FREQ_RANGE_MIN);
75
+ for (unsigned s = 0; s < 2; s++) {
76
+ const double *series = s ? r20 : r10;
77
+ const unsigned n = s ? OMX_EQ_CENTRE_SERIES_R20_HZ_COUNT : OMX_ISO_THIRD_OCTAVE_CENTRES_HZ_COUNT;
78
+ int distinct = count <= n;
79
+ for (unsigned i = 0; distinct && i < count; i++) {
80
+ const double t = lo + span * ((double)i + 0.5) / (double)count;
81
+ unsigned best = 0;
82
+ double best_d = 1e300;
83
+ for (unsigned k = 0; k < n; k++) {
84
+ const double d = fabs(log(series[k]) - t);
85
+ if (d < best_d) { best_d = d; best = k; }
86
+ }
87
+ out[i] = (float)series[best];
88
+ if (i > 0 && out[i] == out[i - 1]) distinct = 0;
89
+ }
90
+ if (distinct) return count;
91
+ }
92
+ return 0;
93
+ }
94
+
95
+ /* The default type of band \`index\` of an EQ with \`count\` bands: a low shelf first, a high shelf last, bells
96
+ * between (a single band is a bell). An OMX_EQ_BAND_TYPES_* member. */
97
+ static inline int omx_eq_default_type(unsigned index, unsigned count) {
98
+ if (count < 2) return OMX_EQ_BAND_TYPES_BELL;
99
+ return index == 0 ? OMX_EQ_BAND_TYPES_LOW_SHELF : index == count - 1 ? OMX_EQ_BAND_TYPES_HIGH_SHELF : OMX_EQ_BAND_TYPES_BELL;
100
+ }`;
101
+ }
102
+
42
103
  /** The limits header of the resolved data. */
43
104
  export function renderLimitsHeader(resolved) {
44
105
  const d = doors(resolved);
@@ -48,6 +109,8 @@ export function renderLimitsHeader(resolved) {
48
109
  const above = defined([tables, aliases].join('\n'));
49
110
  const scalars = d.DECLARED_SCALARS.length ? genDeclaredScalarsBlock(d.DECLARED_SCALARS, above) : '';
50
111
  const lists = d.DECLARED_LISTS.length ? genDeclaredListsBlock(d.DECLARED_LISTS, defined([tables, aliases, scalars].join('\n'))) : '';
112
+ const sets = d.DECLARED_SET_ITEMS.length ? genDeclaredSetsEnums(d.DECLARED_SET_ITEMS) : '';
113
+ const centres = resolved.has('EQ_DEFAULT_CENTRES_FOUR_BAND_HZ') ? eqCentresFunction() : '';
51
114
  const sections = [
52
115
  [tables, `/* Per-parameter numeric travel, one block per \`travels\` table named <PREFIX>_LIMITS:
53
116
  * OMX_<PREFIX>_<FIELD>_MIN/_MAX/_DEFAULT, float literals. */`],
@@ -56,11 +119,16 @@ export function renderLimitsHeader(resolved) {
56
119
  [scalars, `/* Every scalar, sheet leaf and single travel's numbers, one block sorted by macro. An integer value
57
120
  * is an integer literal, any other a float literal AND its OMX_<macro>_DOUBLE spelling. */`],
58
121
  [lists, `/* Every list rendered to C, sorted by list: OMX_<LIST>_COUNT and OMX_<LIST>_INIT { a, b, … }. */`],
122
+ [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
+ * where the set names a default, \`OMX_<SET>_DEFAULT\`. */`],
124
+ [centres, `/* The default bands of an EQ with \`count\` bands: THE one rule, for every strip, host and count (omx-contract's
125
+ * lib/eq-defaults.mjs; the ts render exports the same as eqDefaultCentres and eqDefaultTypes). Needs -lm. */`],
59
126
  ].filter(([body]) => body);
60
127
  return `// SPDX-License-Identifier: GPL-3.0-or-later
61
128
  // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
62
129
  #ifndef OMX_CONTRACT_LIMITS_H
63
130
  #define OMX_CONTRACT_LIMITS_H
131
+ #include <math.h>
64
132
  /*
65
133
  * GENERATED — DO NOT EDIT BY HAND.
66
134
  * Produced by \`omx-contract render --target c\` from FreeMixer/omx-contract's data/, the declaration
@@ -82,13 +150,11 @@ export function renderC(resolved) {
82
150
  }
83
151
 
84
152
  /**
85
- * The `c` target of a frozen openmixer sheet (test/fixtures/): `{ sheet, plugins: [{ header, prefix,
86
- * source, params }] }` rendered by openmixer's own renderer, for the round-trip proof (spec §4.4 step 1).
153
+ * The `c` target of a frozen openmixer sheet (test/fixtures/): `{ sheet }` rendered by openmixer's own
154
+ * renderer, for the round-trip proof (spec §4.4 step 1).
87
155
  */
88
156
  export function renderSheetC(fixture) {
89
157
  const s = fixture.sheet;
90
158
  const sheet = { ...s, CORE_LIMITS: new Map(Object.entries(s.CORE_LIMITS)) };
91
- const files = [{ path: 'omxcontract/omx_contract_limits.h', text: renderContractLimits(sheet) }];
92
- for (const p of fixture.plugins) files.push({ path: `omxcontract/params/${p.header}`, text: generate(p, p.params) });
93
- return files;
159
+ return [{ path: 'omxcontract/omx_contract_limits.h', text: renderContractLimits(sheet) }];
94
160
  }
package/render/doors.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * (packages/core/src/native-limit-tables.ts, declared-scalars.ts, declared-lists.ts): a `travels`
6
6
  * table named <PREFIX>_LIMITS is a native table; every scalar, sheet and single travel is scalars
7
7
  * (its finite-number leaves, a travel's `unit`/`defaultFrom` labels skipped); a list is a declared
8
- * list unless its item says `"c": { "render": false }`; a `c.alias` is the core-limit spelling.
8
+ * list unless its item says `"c": { "render": false }`; a set is an enum; a `c.alias` is the core-limit spelling.
9
9
  */
10
10
 
11
11
  const constantCase = (key) => key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
@@ -38,7 +38,7 @@ export function doors(resolved) {
38
38
  .sort(([a], [b]) => a.localeCompare(b));
39
39
  const byMacro = new Map();
40
40
  for (const r of items) {
41
- if (isNativeTable(r) || r.kind === 'list') continue;
41
+ if (isNativeTable(r) || r.kind === 'list' || r.kind === 'set') continue;
42
42
  const leaves = numberLeaves(r.value, r.name, r.name.replace(/_(KERNEL|LIMITS)$/, ''));
43
43
  if (leaves === undefined) continue;
44
44
  for (const s of leaves) {
@@ -51,6 +51,9 @@ export function doors(resolved) {
51
51
  const DECLARED_LISTS = items.filter((r) => r.kind === 'list' && r.c?.render !== false)
52
52
  .map((r) => ({ name: r.name, values: r.value }))
53
53
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
54
+ const DECLARED_SET_ITEMS = items.filter((r) => r.kind === 'set')
55
+ .map((r) => ({ name: r.name, ids: r.value, default: r.default }))
56
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
54
57
  const ALIASES = new Map();
55
58
  for (const r of items) {
56
59
  if (!r.c?.alias) continue;
@@ -59,5 +62,5 @@ export function doors(resolved) {
59
62
  ALIASES.get(fact)[field] = r.value;
60
63
  }
61
64
  const STANDARD_SAMPLE_RATES = resolved.get('STANDARD_SAMPLE_RATES')?.value;
62
- return { NATIVE_LIMIT_TABLES, DECLARED_SCALARS, DECLARED_LISTS, ALIASES, STANDARD_SAMPLE_RATES };
65
+ return { NATIVE_LIMIT_TABLES, DECLARED_SCALARS, DECLARED_LISTS, DECLARED_SET_ITEMS, ALIASES, STANDARD_SAMPLE_RATES };
63
66
  }
package/render/ts.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-or-later
2
2
  // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ import { eqDefaultBands, eqDefaultCentres, eqDefaultParams, eqDefaultTypes } from '../lib/eq-defaults.mjs';
4
+
3
5
  /**
4
6
  * The `ts` target: one ESM module and its type declarations, every item exported under its own
5
7
  * name with its literal type (`as const`), so a re-export in openmixer keeps names, values and types.
@@ -32,10 +34,46 @@ const BANNER = `// SPDX-License-Identifier: GPL-3.0-or-later
32
34
  // GENERATED by \`omx-contract render --target ts\` from FreeMixer/omx-contract's data/ — do not edit.
33
35
  `;
34
36
 
37
+ /**
38
+ * The default EQ rule (lib/eq-defaults.mjs) as functions of the module: the library's own source,
39
+ * with the declared parameters bound, so the ts reader runs the code the tests hold equal to the c one.
40
+ */
41
+ function eqRule(resolved) {
42
+ if (!resolved.has('EQ_DEFAULT_CENTRES_FOUR_BAND_HZ')) return { js: '', dts: '' };
43
+ const bound = JSON.stringify(eqDefaultParams(resolved), null, 2);
44
+ const fn = (f, as) => f.toString().replace(`function ${f.name}(`, `function ${as}(`);
45
+ const js = `
46
+ /** The declared parameters of the default EQ rule: the four-band set, the preferred series, the band frequency travel, the band gain and Q. */
47
+ const EQ_DEFAULT_RULE = ${bound};
48
+ ${fn(eqDefaultCentres, 'centres')}
49
+ ${fn(eqDefaultTypes, 'types')}
50
+ ${fn(eqDefaultBands, 'bands')}
51
+
52
+ /** The default band centres (Hz) of an EQ with \`count\` bands: THE one rule, for every strip, host and count. */
53
+ export function eqDefaultCentres(count) { return centres(count, EQ_DEFAULT_RULE); }
54
+
55
+ /** The default band types, by EQ_BAND_TYPES id: a low shelf, bells, a high shelf (a single band is a bell). */
56
+ export function eqDefaultTypes(count) { return types(count); }
57
+
58
+ /** Every default band of an EQ with \`count\` bands: type, centre, gain and Q. */
59
+ export function eqDefaultBands(count) { return bands(count, EQ_DEFAULT_RULE); }
60
+ `;
61
+ const dts = `
62
+ /** The default band centres (Hz) of an EQ with \`count\` bands: THE one rule, for every strip, host and count. */
63
+ export declare function eqDefaultCentres(count: number): number[];
64
+ /** The default band types, by EQ_BAND_TYPES id: a low shelf, bells, a high shelf (a single band is a bell). */
65
+ export declare function eqDefaultTypes(count: number): string[];
66
+ /** Every default band of an EQ with \`count\` bands: type, centre, gain and Q. */
67
+ export declare function eqDefaultBands(count: number): { type: string; freqHz: number; gainDb: number; q: number }[];
68
+ `;
69
+ return { js, dts };
70
+ }
71
+
35
72
  /** Every file of the `ts` target: `[{ path, text }]`. */
36
73
  export function renderTs(resolved) {
37
74
  const items = [...resolved.values()];
38
- const js = BANNER + items.map((r) => `\n${docComment(r)}\nexport const ${r.name} = ${JSON.stringify(tsValue(r), null, 2)};\n`).join('');
39
- const dts = BANNER + items.map((r) => `\n${docComment(r)}\nexport declare const ${r.name}: ${tsType(tsValue(r))};\n`).join('');
75
+ 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;
40
78
  return [{ path: 'omx-contract.js', text: js }, { path: 'omx-contract.d.ts', text: dts }];
41
79
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://github.com/FreeMixer/omx-contract/raw/main/schema/omx-contract.schema.json",
4
4
  "title": "omx-contract data file",
5
- "description": "A map from an export name to one item: a scalar, a travels table or travel, a sheet or a list. GPL-3.0-or-later.",
5
+ "description": "A map from an export name to one item: a scalar, a travels table or travel, a sheet, a list or a set. GPL-3.0-or-later.",
6
6
  "type": "object",
7
7
  "properties": {
8
8
  "$schema": { "type": "string" }
@@ -15,6 +15,7 @@
15
15
  "enum": ["dB", "dBFS", "ms", "s", "min", "Hz", "oct", "cents", "%", "x", ""]
16
16
  },
17
17
  "text": { "type": "string", "minLength": 1 },
18
+ "since": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$", "description": "The release that first carried an item declared outside the kernels (rates.json, primitives.json); a kernel's items record it in recipes/answers/." },
18
19
  "name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" },
19
20
  "ref": {
20
21
  "type": "object",
@@ -37,6 +38,15 @@
37
38
  "required": ["derive", "of"],
38
39
  "additionalProperties": false
39
40
  },
41
+ {
42
+ "type": "object",
43
+ "properties": {
44
+ "derive": { "const": "ceilSum" },
45
+ "of": { "type": "array", "items": { "$ref": "#/$defs/number" }, "minItems": 2 }
46
+ },
47
+ "required": ["derive", "of"],
48
+ "additionalProperties": false
49
+ },
40
50
  {
41
51
  "type": "object",
42
52
  "properties": {
@@ -58,6 +68,7 @@
58
68
  "unit": { "$ref": "#/$defs/unit" },
59
69
  "default": { "$ref": "#/$defs/number" },
60
70
  "defaultFrom": { "enum": ["desk"] },
71
+ "scale": { "type": "string", "pattern": "^[a-z]+$", "description": "How a host draws the travel: an id of PARAM_SCALES." },
61
72
  "byKind": {
62
73
  "type": "object",
63
74
  "propertyNames": { "pattern": "^[a-z][A-Za-z0-9]*$" },
@@ -80,6 +91,7 @@
80
91
  "type": "object",
81
92
  "properties": {
82
93
  "kind": { "const": "scalar" },
94
+ "since": { "$ref": "#/$defs/since" },
83
95
  "doc": { "$ref": "#/$defs/text" },
84
96
  "why": { "$ref": "#/$defs/text" },
85
97
  "unit": { "$ref": "#/$defs/unit" },
@@ -92,6 +104,7 @@
92
104
  "type": "object",
93
105
  "properties": {
94
106
  "kind": { "const": "travels" },
107
+ "since": { "$ref": "#/$defs/since" },
95
108
  "doc": { "$ref": "#/$defs/text" },
96
109
  "why": { "$ref": "#/$defs/text" },
97
110
  "fields": {
@@ -125,6 +138,7 @@
125
138
  "type": "object",
126
139
  "properties": {
127
140
  "kind": { "const": "sheet" },
141
+ "since": { "$ref": "#/$defs/since" },
128
142
  "doc": { "$ref": "#/$defs/text" },
129
143
  "why": { "$ref": "#/$defs/text" },
130
144
  "value": { "$ref": "#/$defs/sheetValue" }
@@ -136,6 +150,7 @@
136
150
  "type": "object",
137
151
  "properties": {
138
152
  "kind": { "const": "list" },
153
+ "since": { "$ref": "#/$defs/since" },
139
154
  "doc": { "$ref": "#/$defs/text" },
140
155
  "why": { "$ref": "#/$defs/text" },
141
156
  "unit": { "$ref": "#/$defs/unit" },
@@ -149,15 +164,36 @@
149
164
  "required": ["kind", "doc", "unit", "values"],
150
165
  "additionalProperties": false
151
166
  },
167
+ "set": {
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.",
170
+ "properties": {
171
+ "kind": { "const": "set" },
172
+ "since": { "$ref": "#/$defs/since" },
173
+ "doc": { "$ref": "#/$defs/text" },
174
+ "why": { "$ref": "#/$defs/text" },
175
+ "ids": {
176
+ "oneOf": [
177
+ { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9]*$" }, "minItems": 1, "uniqueItems": true },
178
+ { "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 1, "uniqueItems": true }
179
+ ]
180
+ },
181
+ "default": { "oneOf": [{ "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9]*$" }, { "type": "integer", "minimum": 0 }] },
182
+ "labels": { "type": "array", "items": { "$ref": "#/$defs/text" }, "minItems": 1 }
183
+ },
184
+ "required": ["kind", "doc", "ids"],
185
+ "additionalProperties": false
186
+ },
152
187
  "item": {
153
188
  "type": "object",
154
189
  "required": ["kind"],
155
- "properties": { "kind": { "enum": ["scalar", "travels", "sheet", "list"] } },
190
+ "properties": { "kind": { "enum": ["scalar", "travels", "sheet", "list", "set"] } },
156
191
  "allOf": [
157
192
  { "if": { "properties": { "kind": { "const": "scalar" } } }, "then": { "$ref": "#/$defs/scalar" } },
158
193
  { "if": { "properties": { "kind": { "const": "travels" } } }, "then": { "$ref": "#/$defs/travels" } },
159
194
  { "if": { "properties": { "kind": { "const": "sheet" } } }, "then": { "$ref": "#/$defs/sheet" } },
160
- { "if": { "properties": { "kind": { "const": "list" } } }, "then": { "$ref": "#/$defs/list" } }
195
+ { "if": { "properties": { "kind": { "const": "list" } } }, "then": { "$ref": "#/$defs/list" } },
196
+ { "if": { "properties": { "kind": { "const": "set" } } }, "then": { "$ref": "#/$defs/set" } }
161
197
  ]
162
198
  }
163
199
  }