@openmixer/omx-dsp 0.28.0 → 0.29.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.
@@ -2,3 +2,24 @@
2
2
  export declare const RTA_TARGET_BIN_HZ = 5.9;
3
3
  /** The largest RTA FFT the native ring holds — generated into `omx_contract_limits.h` as `OMX_RTA_FFT_SIZE_MAX`. @see data/kernels/rta.json */
4
4
  export declare const RTA_FFT_SIZE_MAX = 32768;
5
+ /** Where the low-pass section hands its poles over from the matched pair to the prewarped bilinear pair, as fractions of Nyquist, and how that band moves with Q. At `qHi` (1/sqrt(2), the 12 dB Butterworth) the band runs from `loAtHiQ` to `hiAtHiQ`; at `qLo` (0.5412, the low-Q half of the 24 dB Butterworth) and below, from `loAtLoQ` to `hiAtLoQ`; between the two it moves linearly. Above `qHi` the prototype peaks, and the band's end moves down to `hiAtPeakQ` by `qPeak`, held above it. Below the band the low-pass is the matched design unchanged. @see data/kernels/eq.json */
6
+ export declare const LOWPASS_HANDOVER: {
7
+ readonly qHi: 0.7071067811865476;
8
+ readonly qLo: 0.541196100146197;
9
+ readonly qPeak: 0.9;
10
+ readonly loAtHiQ: 0.6;
11
+ readonly hiAtHiQ: 0.85;
12
+ readonly loAtLoQ: 0.8;
13
+ readonly hiAtLoQ: 0.9;
14
+ readonly hiAtPeakQ: 0.8;
15
+ };
16
+ /** The Q (resonance) extent of a parametric band. @see data/kernels/eq.json */
17
+ export declare const EQ_Q_RANGE: {
18
+ readonly min: 0.3;
19
+ readonly max: 8;
20
+ };
21
+ /** 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. @see data/kernels/eq.json */
22
+ export declare const EQ_NOTCH_Q_RANGE: {
23
+ readonly min: 0.3;
24
+ readonly max: 116;
25
+ };
@@ -5,3 +5,18 @@
5
5
  export const RTA_TARGET_BIN_HZ = 5.9;
6
6
  /** The largest RTA FFT the native ring holds — generated into `omx_contract_limits.h` as `OMX_RTA_FFT_SIZE_MAX`. @see data/kernels/rta.json */
7
7
  export const RTA_FFT_SIZE_MAX = 32768;
8
+ /** Where the low-pass section hands its poles over from the matched pair to the prewarped bilinear pair, as fractions of Nyquist, and how that band moves with Q. At `qHi` (1/sqrt(2), the 12 dB Butterworth) the band runs from `loAtHiQ` to `hiAtHiQ`; at `qLo` (0.5412, the low-Q half of the 24 dB Butterworth) and below, from `loAtLoQ` to `hiAtLoQ`; between the two it moves linearly. Above `qHi` the prototype peaks, and the band's end moves down to `hiAtPeakQ` by `qPeak`, held above it. Below the band the low-pass is the matched design unchanged. @see data/kernels/eq.json */
9
+ export const LOWPASS_HANDOVER = {
10
+ qHi: 0.7071067811865476,
11
+ qLo: 0.541196100146197,
12
+ qPeak: 0.9,
13
+ loAtHiQ: 0.6,
14
+ hiAtHiQ: 0.85,
15
+ loAtLoQ: 0.8,
16
+ hiAtLoQ: 0.9,
17
+ hiAtPeakQ: 0.8,
18
+ };
19
+ /** The Q (resonance) extent of a parametric band. @see data/kernels/eq.json */
20
+ export const EQ_Q_RANGE = { min: 0.3, max: 8 };
21
+ /** 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. @see data/kernels/eq.json */
22
+ export const EQ_NOTCH_Q_RANGE = { min: 0.3, max: 116 };
package/dist/eq.d.ts CHANGED
@@ -1,3 +1,82 @@
1
- /** The twin of omx_eq_design.h omx_eq_octaves_q, moved verbatim from the console (its catalog package). */
2
- /** Bandwidth in octaves → Q (the cookbook relation). */
1
+ /** Bandwidth in octaves → Q (the cookbook relation). The twin of omx_eq_octaves_q. */
3
2
  export declare function octavesToQ(octaves: number): number;
3
+ /**
4
+ * A band's Q clamped into the extent its shape allows: a notch may go ultra-narrow ({@link EQ_NOTCH_Q_RANGE}),
5
+ * every other shape keeps {@link EQ_Q_RANGE}. The twin of omx_eq_lv2_clamp_q, which clamps a float.
6
+ * @param type The band shape; only `'notch'` takes the notch range.
7
+ * @param q The asked Q.
8
+ */
9
+ export declare function clampEqQFor(type: string, q: number): number;
10
+ /** The section kinds of omx_eq_design, by the twin's word (omx_eq_kind 0..8 in this order). */
11
+ export type BiquadKind = 'peaking' | 'lowShelf' | 'highShelf' | 'highpass' | 'lowpass' | 'notch' | 'bandpass' | 'allpass1' | 'allpass2';
12
+ /** One designed section, `a0` carried explicitly (every design here normalises, so it is 1). */
13
+ export interface BiquadSection {
14
+ readonly b0: number;
15
+ readonly b1: number;
16
+ readonly b2: number;
17
+ readonly a0: number;
18
+ readonly a1: number;
19
+ readonly a2: number;
20
+ }
21
+ /**
22
+ * The low-pass hand-over band for a Q, as fractions of Nyquist: the matched poles below the first, the bilinear
23
+ * pair from the second. The twin of omx_eq_lowpass_handover; the table is {@link LOWPASS_HANDOVER}.
24
+ */
25
+ export declare function lowpassHandover(q: number): readonly [number, number];
26
+ /**
27
+ * One conjugate pair placed by the matched-Z map `z = exp(sT)`: the digital pair `1 + p1 z^-1 + p2 z^-2` of the
28
+ * analogue `s^2 + 2 zeta wn s + wn^2`, and `dc`, that polynomial at `z = 1` in a factored form that does not
29
+ * cancel for a low section. `wn` (radians per sample) is held at or below pi.
30
+ * @param wn The pair's centre, `2 pi f / fs`.
31
+ * @param zeta The damping, `1/(2Q)` for a plain resonator.
32
+ */
33
+ export declare function matchedPair(wn: number, zeta: number): {
34
+ p1: number;
35
+ p2: number;
36
+ dc: number;
37
+ };
38
+ /**
39
+ * One section as omx_eq_design designs it: matched-Z peaking and shelves, a matched low-pass with its fitted
40
+ * numerator and Nyquist hand-over, a matched notch under the cookbook numerator, the cookbook high-pass and
41
+ * bandpass, and the first- and second-order allpasses. The centre is clamped to `[1, 0.999 Nyquist]` and a Q at
42
+ * or under 0 is taken as 1e-3; `gainDb` is read by the peaking and shelf kinds only.
43
+ * @param kind The section kind.
44
+ * @param freqHz Centre or corner, Hz.
45
+ * @param q Quality factor.
46
+ * @param gainDb Gain, dB.
47
+ * @param sampleRate Rate the section runs at, Hz.
48
+ */
49
+ export declare function rbjSection(kind: BiquadKind, freqHz: number, q: number, gainDb: number, sampleRate: number): BiquadSection;
50
+ /**
51
+ * The magnitude of one designed section at `freq`, dB, by the unit-circle arithmetic itself. A point at or above
52
+ * Nyquist, or a degenerate denominator, is 0 dB; a full null floors at `floorDb`.
53
+ * @param section The section.
54
+ * @param freq Probe frequency, Hz.
55
+ * @param fs Sample rate, Hz.
56
+ * @param floorDb The floor of a null, dB.
57
+ */
58
+ export declare function sectionMagnitudeDb(section: BiquadSection, freq: number, fs: number, floorDb: number): number;
59
+ /** The shapes a drawn band takes: the plugin and surface spelling. */
60
+ export type BiquadType = 'bell' | 'lowshelf' | 'highshelf' | 'highpass' | 'lowpass' | 'notch' | 'allpass1' | 'allpass2';
61
+ /** One drawn band in engineering units. */
62
+ export interface BiquadBand {
63
+ readonly type: BiquadType;
64
+ /** Centre or corner, Hz. */
65
+ readonly freq: number;
66
+ /** Gain, dB (bell and shelves only). */
67
+ readonly gainDb: number;
68
+ /** Quality factor. */
69
+ readonly q: number;
70
+ }
71
+ /**
72
+ * One band's magnitude at `freq`, dB: {@link rbjSection} evaluated by {@link sectionMagnitudeDb}. The twin of
73
+ * omx_eq_band_magnitude_db.
74
+ * @param floorDb The floor of a null, dB (the kernel's `floor_db`).
75
+ */
76
+ export declare function biquadMagnitudeDb(band: BiquadBand, freq: number, fs: number, floorDb: number): number;
77
+ /**
78
+ * The summed magnitude of a band set at one frequency, dB (cascaded sections multiply, so dB add). The twin of
79
+ * omx_eq_magnitude_sum_db.
80
+ * @param floorDb The floor of each band's null, dB.
81
+ */
82
+ export declare function magnitudeSumDb(bands: readonly BiquadBand[], f: number, fs: number, floorDb: number): number;
package/dist/eq.js CHANGED
@@ -1,9 +1,240 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-or-later
2
2
  // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
- /** The twin of omx_eq_design.h omx_eq_octaves_q, moved verbatim from the console (its catalog package). */
4
- /** Bandwidth in octaves → Q (the cookbook relation). */
3
+ /**
4
+ * The EQ twins: the Q relation and clamp, the section design and the magnitude a surface draws. Each is the
5
+ * TypeScript form of an omx_eq_design.h / omx_magnitude.h / omx_eq_instance.h kernel, pinned to it by
6
+ * test/agreement.test.mjs. A browser draws the curve with these; the engine runs the C.
7
+ */
8
+ import { EQ_NOTCH_Q_RANGE, EQ_Q_RANGE, LOWPASS_HANDOVER } from './definitions.js';
9
+ import { linearToDbFloored } from './units.js';
10
+ const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x);
11
+ /** Bandwidth in octaves → Q (the cookbook relation). The twin of omx_eq_octaves_q. */
5
12
  export function octavesToQ(octaves) {
6
13
  const bw = Math.max(octaves, 0.05);
7
14
  const t = Math.pow(2, bw);
8
15
  return Math.sqrt(t) / (t - 1);
9
16
  }
17
+ /**
18
+ * A band's Q clamped into the extent its shape allows: a notch may go ultra-narrow ({@link EQ_NOTCH_Q_RANGE}),
19
+ * every other shape keeps {@link EQ_Q_RANGE}. The twin of omx_eq_lv2_clamp_q, which clamps a float.
20
+ * @param type The band shape; only `'notch'` takes the notch range.
21
+ * @param q The asked Q.
22
+ */
23
+ export function clampEqQFor(type, q) {
24
+ const r = type === 'notch' ? EQ_NOTCH_Q_RANGE : EQ_Q_RANGE;
25
+ return clamp(q, r.min, r.max);
26
+ }
27
+ /** How far a peaked low-pass hands its Nyquist target over: 0 at or below qHi, 1 at or above qPeak. */
28
+ function lowpassNyquistHandover(q) {
29
+ const v = (q - LOWPASS_HANDOVER.qHi) / (LOWPASS_HANDOVER.qPeak - LOWPASS_HANDOVER.qHi);
30
+ return v <= 0 ? 0 : v >= 1 ? 1 : v;
31
+ }
32
+ /** How far a low-Q low-pass raises its Nyquist target across the hand-over: 0 at or above qHi, 1 at or below qLo. */
33
+ function lowpassNyquistRaise(q) {
34
+ const u = (LOWPASS_HANDOVER.qHi - q) / (LOWPASS_HANDOVER.qHi - LOWPASS_HANDOVER.qLo);
35
+ return u <= 0 ? 0 : u >= 1 ? 1 : u;
36
+ }
37
+ /**
38
+ * The low-pass hand-over band for a Q, as fractions of Nyquist: the matched poles below the first, the bilinear
39
+ * pair from the second. The twin of omx_eq_lowpass_handover; the table is {@link LOWPASS_HANDOVER}.
40
+ */
41
+ export function lowpassHandover(q) {
42
+ const h = LOWPASS_HANDOVER;
43
+ let u = (h.qHi - q) / (h.qHi - h.qLo);
44
+ u = u < 0 ? 0 : u > 1 ? 1 : u;
45
+ const lo = h.loAtHiQ + (h.loAtLoQ - h.loAtHiQ) * u;
46
+ let hi = h.hiAtHiQ + (h.hiAtLoQ - h.hiAtHiQ) * u;
47
+ hi += (h.hiAtPeakQ - h.hiAtHiQ) * lowpassNyquistHandover(q);
48
+ return [lo, hi];
49
+ }
50
+ /**
51
+ * One conjugate pair placed by the matched-Z map `z = exp(sT)`: the digital pair `1 + p1 z^-1 + p2 z^-2` of the
52
+ * analogue `s^2 + 2 zeta wn s + wn^2`, and `dc`, that polynomial at `z = 1` in a factored form that does not
53
+ * cancel for a low section. `wn` (radians per sample) is held at or below pi.
54
+ * @param wn The pair's centre, `2 pi f / fs`.
55
+ * @param zeta The damping, `1/(2Q)` for a plain resonator.
56
+ */
57
+ export function matchedPair(wn, zeta) {
58
+ const w = wn > Math.PI ? Math.PI : wn;
59
+ const e = Math.exp(-zeta * w);
60
+ if (zeta < 1) {
61
+ const th = Math.sqrt(1 - zeta * zeta) * w;
62
+ const ec = e * Math.cos(th);
63
+ const es = e * Math.sin(th);
64
+ return { p1: -2 * ec, p2: e * e, dc: (1 - ec) * (1 - ec) + es * es };
65
+ }
66
+ // real roots; zeta - sqrt(zeta^2-1) written as 1/(zeta + sqrt(zeta^2-1)), which keeps its digits as Q -> 0
67
+ const sq = Math.sqrt(zeta * zeta - 1);
68
+ const z1 = Math.exp(-w / (zeta + sq));
69
+ const z2 = Math.exp(-w * (zeta + sq));
70
+ return { p1: -(z1 + z2), p2: z1 * z2, dc: (1 - z1) * (1 - z2) };
71
+ }
72
+ /**
73
+ * One section as omx_eq_design designs it: matched-Z peaking and shelves, a matched low-pass with its fitted
74
+ * numerator and Nyquist hand-over, a matched notch under the cookbook numerator, the cookbook high-pass and
75
+ * bandpass, and the first- and second-order allpasses. The centre is clamped to `[1, 0.999 Nyquist]` and a Q at
76
+ * or under 0 is taken as 1e-3; `gainDb` is read by the peaking and shelf kinds only.
77
+ * @param kind The section kind.
78
+ * @param freqHz Centre or corner, Hz.
79
+ * @param q Quality factor.
80
+ * @param gainDb Gain, dB.
81
+ * @param sampleRate Rate the section runs at, Hz.
82
+ */
83
+ export function rbjSection(kind, freqHz, q, gainDb, sampleRate) {
84
+ const nyquist = sampleRate * 0.5;
85
+ const f0 = clamp(freqHz, 1, nyquist * 0.999);
86
+ const qq = q <= 0 ? 1e-3 : q;
87
+ const w0 = (2 * Math.PI * f0) / sampleRate;
88
+ const A = Math.pow(10, gainDb / 40);
89
+ switch (kind) {
90
+ case 'peaking': {
91
+ const p = matchedPair(w0, 1 / (2 * A * qq));
92
+ const z = matchedPair(w0, A / (2 * qq));
93
+ const k = p.dc / z.dc;
94
+ return { b0: k, b1: k * z.p1, b2: k * z.p2, a0: 1, a1: p.p1, a2: p.p2 };
95
+ }
96
+ case 'lowShelf': {
97
+ const sa = Math.sqrt(A);
98
+ const p = matchedPair(w0 / sa, 1 / (2 * qq));
99
+ const z = matchedPair(w0 * sa, 1 / (2 * qq));
100
+ const k = (A * A * p.dc) / z.dc;
101
+ return { b0: k, b1: k * z.p1, b2: k * z.p2, a0: 1, a1: p.p1, a2: p.p2 };
102
+ }
103
+ case 'highShelf': {
104
+ const sa = Math.sqrt(A);
105
+ const p = matchedPair(w0 * sa, 1 / (2 * qq));
106
+ const z = matchedPair(w0 / sa, 1 / (2 * qq));
107
+ const k = p.dc / z.dc;
108
+ return { b0: k, b1: k * z.p1, b2: k * z.p2, a0: 1, a1: p.p1, a2: p.p2 };
109
+ }
110
+ case 'highpass': {
111
+ const cw = Math.cos(w0);
112
+ const alpha = Math.sin(w0) / (2 * qq);
113
+ const a0 = 1 + alpha;
114
+ return { b0: (1 + cw) / 2 / a0, b1: -(1 + cw) / a0, b2: (1 + cw) / 2 / a0, a0: 1, a1: (-2 * cw) / a0, a2: (1 - alpha) / a0 };
115
+ }
116
+ case 'lowpass': {
117
+ const m = matchedPair(w0, 1 / (2 * qq));
118
+ const band = lowpassHandover(qq);
119
+ const r = f0 / nyquist;
120
+ const x = (r - band[0]) / (band[1] - band[0]);
121
+ const t = x <= 0 ? 0 : x >= 1 ? 1 : x * x * (3 - 2 * x);
122
+ const h = 2 * x - 1;
123
+ const v = h <= 0 ? 0 : h >= 1 ? 1 : h * h * (3 - 2 * h);
124
+ const p = { p1: m.p1, p2: m.p2, dc: m.dc };
125
+ if (t > 0) {
126
+ const cwh = Math.cos(w0);
127
+ const alpha = Math.sin(w0) / (2 * qq);
128
+ const a0 = 1 + alpha;
129
+ p.p1 = m.p1 + ((-2 * cwh) / a0 - m.p1) * t;
130
+ p.p2 = m.p2 + ((1 - alpha) / a0 - m.p2) * t;
131
+ p.dc = 1 + p.p1 + p.p2;
132
+ }
133
+ const keep = (1 - t * lowpassNyquistHandover(qq)) * (1 + v * lowpassNyquistRaise(qq));
134
+ if (keep === 0) {
135
+ const g = (1 + p.p1 + p.p2) / 4;
136
+ return { b0: g, b1: 2 * g, b2: g, a0: 1, a1: p.p1, a2: p.p2 };
137
+ }
138
+ // the prototype's |H|^2 at DC (1), at f0 (Q^2) and at Nyquist, and |D|^2 at the same three points
139
+ const xn = nyquist / f0;
140
+ const un = 1 - xn * xn;
141
+ const tPi = 1 / (un * un + (xn / qq) * (xn / qq));
142
+ const dPi = 1 - p.p1 + p.p2;
143
+ const cw = Math.cos(w0);
144
+ const dr = 1 + p.p1 * cw + p.p2 * Math.cos(2 * w0);
145
+ const di = -(p.p1 * Math.sin(w0) + p.p2 * Math.sin(2 * w0));
146
+ // |N|^2 = U - 4 V s + 16 W s^2 with s = sin^2(w/2), solved from the three points
147
+ const s0 = Math.sin(w0 / 2) * Math.sin(w0 / 2);
148
+ const U = p.dc * p.dc;
149
+ const r1 = tPi * dPi * dPi * keep - U;
150
+ const r2 = qq * qq * (dr * dr + di * di) - U;
151
+ const W = (r2 - s0 * r1) / (16 * s0 * (s0 - 1));
152
+ const n1 = Math.sqrt(U);
153
+ const nm = Math.sqrt(U + r1 > 0 ? U + r1 : 0);
154
+ const sum = (n1 + nm) / 2;
155
+ const b1 = (n1 - nm) / 2;
156
+ const disc = sum * sum - 4 * W;
157
+ const b0 = (sum + Math.sqrt(disc > 0 ? disc : 0)) / 2;
158
+ return { b0, b1, b2: sum - b0, a0: 1, a1: p.p1, a2: p.p2 };
159
+ }
160
+ case 'notch': {
161
+ const b1 = -2 * Math.cos(w0);
162
+ const p = matchedPair(w0, 1 / (2 * qq));
163
+ const sw2 = Math.sin(w0 / 2);
164
+ const k = p.dc / (4 * sw2 * sw2); // 4 sin^2(w0/2) is 2 + b1 without the cancellation
165
+ return { b0: k, b1: k * b1, b2: k, a0: 1, a1: p.p1, a2: p.p2 };
166
+ }
167
+ case 'bandpass': {
168
+ const cw = Math.cos(w0);
169
+ const alpha = Math.sin(w0) / (2 * qq);
170
+ const a0 = 1 + alpha;
171
+ return { b0: alpha / a0, b1: 0, b2: -alpha / a0, a0: 1, a1: (-2 * cw) / a0, a2: (1 - alpha) / a0 };
172
+ }
173
+ case 'allpass1': {
174
+ const t = Math.sin(w0) / (1 + Math.cos(w0));
175
+ const k = (t - 1) / (t + 1);
176
+ return { b0: k, b1: 1, b2: 0, a0: 1, a1: k, a2: 0 };
177
+ }
178
+ case 'allpass2': {
179
+ const K = Math.tan((Math.PI * f0) / sampleRate);
180
+ const norm = 1 / (1 + K / qq + K * K);
181
+ const a1 = 2 * (K * K - 1) * norm;
182
+ const a2 = (1 - K / qq + K * K) * norm;
183
+ return { b0: a2, b1: a1, b2: 1, a0: 1, a1, a2 };
184
+ }
185
+ }
186
+ }
187
+ /**
188
+ * The magnitude of one designed section at `freq`, dB, by the unit-circle arithmetic itself. A point at or above
189
+ * Nyquist, or a degenerate denominator, is 0 dB; a full null floors at `floorDb`.
190
+ * @param section The section.
191
+ * @param freq Probe frequency, Hz.
192
+ * @param fs Sample rate, Hz.
193
+ * @param floorDb The floor of a null, dB.
194
+ */
195
+ export function sectionMagnitudeDb(section, freq, fs, floorDb) {
196
+ if (!(freq > 0) || !(fs > 0) || freq >= fs / 2)
197
+ return 0;
198
+ const { b0, b1, b2, a0, a1, a2 } = section;
199
+ const w = (2 * Math.PI * freq) / fs;
200
+ const cw = Math.cos(w);
201
+ const sw = Math.sin(w);
202
+ const cw2 = Math.cos(2 * w);
203
+ const sw2 = Math.sin(2 * w);
204
+ const num = Math.hypot(b0 + b1 * cw + b2 * cw2, -(b1 * sw + b2 * sw2));
205
+ const den = Math.hypot(a0 + a1 * cw + a2 * cw2, -(a1 * sw + a2 * sw2));
206
+ if (den === 0)
207
+ return 0;
208
+ return linearToDbFloored(num / den, floorDb);
209
+ }
210
+ const SHAPE_KIND = {
211
+ bell: 'peaking',
212
+ lowshelf: 'lowShelf',
213
+ highshelf: 'highShelf',
214
+ highpass: 'highpass',
215
+ lowpass: 'lowpass',
216
+ notch: 'notch',
217
+ allpass1: 'allpass1',
218
+ allpass2: 'allpass2',
219
+ };
220
+ /**
221
+ * One band's magnitude at `freq`, dB: {@link rbjSection} evaluated by {@link sectionMagnitudeDb}. The twin of
222
+ * omx_eq_band_magnitude_db.
223
+ * @param floorDb The floor of a null, dB (the kernel's `floor_db`).
224
+ */
225
+ export function biquadMagnitudeDb(band, freq, fs, floorDb) {
226
+ if (!(freq > 0) || !(fs > 0) || freq >= fs / 2)
227
+ return 0;
228
+ return sectionMagnitudeDb(rbjSection(SHAPE_KIND[band.type], band.freq, band.q, band.gainDb, fs), freq, fs, floorDb);
229
+ }
230
+ /**
231
+ * The summed magnitude of a band set at one frequency, dB (cascaded sections multiply, so dB add). The twin of
232
+ * omx_eq_magnitude_sum_db.
233
+ * @param floorDb The floor of each band's null, dB.
234
+ */
235
+ export function magnitudeSumDb(bands, f, fs, floorDb) {
236
+ let db = 0;
237
+ for (const b of bands)
238
+ db += biquadMagnitudeDb(b, f, fs, floorDb);
239
+ return db;
240
+ }
package/dist/index.d.ts CHANGED
@@ -4,5 +4,6 @@
4
4
  * agreement test in test/ that feeds both the same grid.
5
5
  */
6
6
  export { ANALYSIS_FLOOR_DB, dbToLinearGain, linearGainToDb, linearToDbFloored } from './units.js';
7
- export { octavesToQ } from './eq.js';
7
+ export { biquadMagnitudeDb, clampEqQFor, lowpassHandover, magnitudeSumDb, matchedPair, octavesToQ, rbjSection, sectionMagnitudeDb, type BiquadBand, type BiquadKind, type BiquadSection, type BiquadType, } from './eq.js';
8
+ export { EQ_NOTCH_Q_RANGE, EQ_Q_RANGE, LOWPASS_HANDOVER, RTA_FFT_SIZE_MAX, RTA_TARGET_BIN_HZ } from './definitions.js';
8
9
  export { fftSizeForRate } from './rta.js';
package/dist/index.js CHANGED
@@ -6,5 +6,6 @@
6
6
  * agreement test in test/ that feeds both the same grid.
7
7
  */
8
8
  export { ANALYSIS_FLOOR_DB, dbToLinearGain, linearGainToDb, linearToDbFloored } from './units.js';
9
- export { octavesToQ } from './eq.js';
9
+ export { biquadMagnitudeDb, clampEqQFor, lowpassHandover, magnitudeSumDb, matchedPair, octavesToQ, rbjSection, sectionMagnitudeDb, } from './eq.js';
10
+ export { EQ_NOTCH_Q_RANGE, EQ_Q_RANGE, LOWPASS_HANDOVER, RTA_FFT_SIZE_MAX, RTA_TARGET_BIN_HZ } from './definitions.js';
10
11
  export { fftSizeForRate } from './rta.js';
@@ -0,0 +1,392 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /*
4
+ * The C side of the TypeScript-twin agreement tests (#1823).
5
+ *
6
+ * omx-dsp holds the law for a set of small kernels that the console also carries in TypeScript
7
+ * (each header tags its kernel `@twin <name>`). A twin cannot be bit-identical to its kernel: the
8
+ * kernel runs libm in double or float, the twin runs V8's math in double. So each twin is pinned
9
+ * by a test that feeds both the same inputs and compares the answers within a stated tolerance.
10
+ * This program is the kernel half. It reads requests on stdin, one per line,
11
+ *
12
+ * <kernel> <number> <number> ...
13
+ *
14
+ * and answers each with one line, `ok <number> <number> ...` or `err <word>`. Numbers are decimal
15
+ * (JavaScript's own shortest round-trip form in, "%.17g" out), so no value is rounded on the way.
16
+ * Each kernel's argument layout is the comment above its handler. Float kernels are called with the
17
+ * inputs narrowed to float and answer the float widened back, exactly as the engine calls them.
18
+ *
19
+ * It links omx-dsp (ts-twin-oracle.mjs says which build), never a copy of any maths of its own: a number
20
+ * here that the library does not produce is a bug in this file.
21
+ */
22
+ #include <math.h>
23
+ #include <stdint.h>
24
+ #include <stdio.h>
25
+ #include <stdlib.h>
26
+ #include <string.h>
27
+
28
+ #include <omxdsp/analysis/omx_align_fit.h>
29
+ #include <omxdsp/analysis/omx_hrp_correct.h>
30
+ #include <omxdsp/analysis/omx_hrp_track.h>
31
+ #include <omxdsp/analysis/omx_room.h>
32
+ #include <omxdsp/analysis/omx_rta.h>
33
+ #include <omxdsp/fx/omx_eq_instance.h>
34
+ #include <omxdsp/mix/omx_pan_law.h>
35
+ #include <omxdsp/omx_balance_law.h>
36
+ #include <omxdsp/omx_contract.h>
37
+ #include <omxdsp/omx_dyn.h>
38
+ #include <omxdsp/omx_eq_design.h>
39
+ #include <omxdsp/omx_magnitude.h>
40
+ #include <omxdsp/omx_units.h>
41
+
42
+ #define MAXV 4096
43
+ #define MAXOUT 8192
44
+
45
+ typedef int (*handler)(const double *in, int n, double *out);
46
+
47
+ /* eq_design: kind freq q gain_db rate -> b0 b1 b2 a1 a2 */
48
+ static int h_eq_design(const double *in, int n, double *out) {
49
+ if (n != 5) return -1;
50
+ omx_eq_design((enum omx_eq_kind)(int)in[0], in[1], in[2], in[3], in[4], out);
51
+ return 5;
52
+ }
53
+
54
+ /* eq_octaves_q: bw_oct -> q */
55
+ static int h_eq_octaves_q(const double *in, int n, double *out) {
56
+ if (n != 1) return -1;
57
+ out[0] = omx_eq_octaves_q(in[0]);
58
+ return 1;
59
+ }
60
+
61
+ /* eq_band_is_identity: kind gain_db -> 0|1 */
62
+ static int h_eq_band_is_identity(const double *in, int n, double *out) {
63
+ if (n != 2) return -1;
64
+ out[0] = omx_eq_band_is_identity((enum omx_eq_kind)(int)in[0], in[1]);
65
+ return 1;
66
+ }
67
+
68
+ /* eq_lv2_clamp_q: lv2_band_type q -> q (float) */
69
+ static int h_eq_lv2_clamp_q(const double *in, int n, double *out) {
70
+ if (n != 2) return -1;
71
+ out[0] = omx_eq_lv2_clamp_q((int)in[0], (float)in[1]);
72
+ return 1;
73
+ }
74
+
75
+ /* eq_bank: eq_on rate nbands { kind freq gain q on }*nbands -> count { b0 b1 b2 a1 a2 enabled }*count */
76
+ static int h_eq_bank(const double *in, int n, double *out) {
77
+ if (n < 3) return -1;
78
+ const int nb = (int)in[2];
79
+ if (nb < 0 || nb > 64 || n != 3 + 5 * nb) return -1;
80
+ struct omx_eq_band bands[64];
81
+ double coeffs[64][5];
82
+ uint8_t enabled[64];
83
+ for (int i = 0; i < nb; i++) {
84
+ const double *b = in + 3 + 5 * i;
85
+ bands[i].kind = (enum omx_eq_kind)(int)b[0];
86
+ bands[i].freq_hz = b[1];
87
+ bands[i].gain_db = b[2];
88
+ bands[i].q = b[3];
89
+ bands[i].on = (int)b[4];
90
+ }
91
+ const uint32_t cnt = omx_eq_bank_design((int)in[0], bands, (uint32_t)nb, in[1], coeffs, enabled);
92
+ out[0] = cnt;
93
+ for (uint32_t i = 0; i < cnt; i++) {
94
+ for (int j = 0; j < 5; j++) out[1 + 6 * i + j] = coeffs[i][j];
95
+ out[1 + 6 * i + 5] = enabled[i];
96
+ }
97
+ return 1 + 6 * (int)cnt;
98
+ }
99
+
100
+ /* eq_pass: hpf_on hpf_freq hpf_slope24 lpf_on lpf_freq lpf_slope24 rate -> count { b0 b1 b2 a1 a2 }*count */
101
+ static int h_eq_pass(const double *in, int n, double *out) {
102
+ if (n != 7) return -1;
103
+ const struct omx_eq_pass hpf = {(int)in[0], in[1], (int)in[2]};
104
+ const struct omx_eq_pass lpf = {(int)in[3], in[4], (int)in[5]};
105
+ double coeffs[OMX_EQ_PASS_MAX_SECTIONS][5];
106
+ const uint32_t cnt = omx_eq_pass_design(&hpf, &lpf, in[6], coeffs);
107
+ out[0] = cnt;
108
+ for (uint32_t i = 0; i < cnt; i++)
109
+ for (int j = 0; j < 5; j++) out[1 + 5 * i + j] = coeffs[i][j];
110
+ return 1 + 5 * (int)cnt;
111
+ }
112
+
113
+ /* eq_band_magnitude_db: kind freq gain_db q at_hz rate floor_db -> dB */
114
+ static int h_eq_band_magnitude_db(const double *in, int n, double *out) {
115
+ if (n != 7) return -1;
116
+ const struct omx_eq_band b = {(enum omx_eq_kind)(int)in[0], in[1], in[2], in[3], 1};
117
+ out[0] = omx_eq_band_magnitude_db(&b, in[4], in[5], in[6]);
118
+ return 1;
119
+ }
120
+
121
+ /* eq_magnitude_sum_db: at_hz rate floor_db nbands { kind freq gain q }*nbands -> dB */
122
+ static int h_eq_magnitude_sum_db(const double *in, int n, double *out) {
123
+ if (n < 4) return -1;
124
+ const int nb = (int)in[3];
125
+ if (nb < 0 || nb > 64 || n != 4 + 4 * nb) return -1;
126
+ struct omx_eq_band bands[64];
127
+ for (int i = 0; i < nb; i++) {
128
+ const double *b = in + 4 + 4 * i;
129
+ bands[i].kind = (enum omx_eq_kind)(int)b[0];
130
+ bands[i].freq_hz = b[1];
131
+ bands[i].gain_db = b[2];
132
+ bands[i].q = b[3];
133
+ bands[i].on = 1;
134
+ }
135
+ out[0] = omx_eq_magnitude_sum_db(bands, (uint32_t)nb, in[0], in[1], in[2]);
136
+ return 1;
137
+ }
138
+
139
+ /* hrp_cut_db: excess_db amount max_cut_db -> cut_db (float) */
140
+ static int h_hrp_cut_db(const double *in, int n, double *out) {
141
+ if (n != 3) return -1;
142
+ out[0] = omx_hrp_cut_db((float)in[0], (float)in[1], (float)in[2]);
143
+ return 1;
144
+ }
145
+
146
+ /* hrp_resonance_score: cut_db voice_confidence partial_confidence -> score (float) */
147
+ static int h_hrp_resonance_score(const double *in, int n, double *out) {
148
+ if (n != 3) return -1;
149
+ out[0] = omx_hrp_resonance_score((float)in[0], (float)in[1], (float)in[2]);
150
+ return 1;
151
+ }
152
+
153
+ /* hrp_cents_between: a_hz b_hz -> cents (float) */
154
+ static int h_hrp_cents_between(const double *in, int n, double *out) {
155
+ if (n != 2) return -1;
156
+ out[0] = omx_hrp_cents_between((float)in[0], (float)in[1]);
157
+ return 1;
158
+ }
159
+
160
+ /* pan_stereo: pan -> gain_l gain_r (float): the mono pan law over the stereo layout */
161
+ static int h_pan_stereo(const double *in, int n, double *out) {
162
+ if (n != 1) return -1;
163
+ float l = 0.0f, r = 0.0f;
164
+ omx_pan_stereo((float)in[0], &l, &r);
165
+ out[0] = l;
166
+ out[1] = r;
167
+ return 2;
168
+ }
169
+
170
+ /* balance_law: pan -> gain_l gain_r (float): the stereo balance law */
171
+ static int h_balance_law(const double *in, int n, double *out) {
172
+ if (n != 1) return -1;
173
+ float l = 0.0f, r = 0.0f;
174
+ omx_balance_law((float)in[0], &l, &r);
175
+ out[0] = l;
176
+ out[1] = r;
177
+ return 2;
178
+ }
179
+
180
+ /* lin_to_db: lin -> dB (float, floored at 1e-9) */
181
+ static int h_lin_to_db(const double *in, int n, double *out) {
182
+ if (n != 1) return -1;
183
+ out[0] = omx_lin_to_db((float)in[0]);
184
+ return 1;
185
+ }
186
+
187
+ /* lin_to_db_d: lin floor_lin -> dB (double) */
188
+ static int h_lin_to_db_d(const double *in, int n, double *out) {
189
+ if (n != 2) return -1;
190
+ out[0] = omx_lin_to_db_d(in[0], in[1]);
191
+ return 1;
192
+ }
193
+
194
+ /* db_to_lin: dB -> linear (float) */
195
+ static int h_db_to_lin(const double *in, int n, double *out) {
196
+ if (n != 1) return -1;
197
+ out[0] = omx_db_to_lin((float)in[0]);
198
+ return 1;
199
+ }
200
+
201
+ /* eq_butterworth_qs: slope_24 -> sections q0 q1 (q1 is 0 for one section) */
202
+ static int h_eq_butterworth_qs(const double *in, int n, double *out) {
203
+ if (n != 1) return -1;
204
+ double q[2] = {0.0, 0.0};
205
+ out[0] = omx_eq_butterworth_qs(in[0] != 0.0, q);
206
+ out[1] = q[0];
207
+ out[2] = q[1];
208
+ return 3;
209
+ }
210
+
211
+ /* dyn_oversample_factor: enabled ovs_mode attack_ms topology -> factor (attack narrowed to float, as the
212
+ * engine stores it) */
213
+ static int h_dyn_oversample_factor(const double *in, int n, double *out) {
214
+ if (n != 4) return -1;
215
+ struct omx_dyn p;
216
+ memset(&p, 0, sizeof p);
217
+ p.enabled = in[0] != 0.0;
218
+ p.ovs_mode = (int)in[1];
219
+ p.attack_ms = (float)in[2];
220
+ p.topology = (int)in[3];
221
+ out[0] = omx_dyn_oversample_factor(&p);
222
+ return 1;
223
+ }
224
+
225
+ /* rta_fft_size_for: rate_hz -> window length */
226
+ static int h_rta_fft_size_for(const double *in, int n, double *out) {
227
+ if (n != 1) return -1;
228
+ out[0] = omx_rta_fft_size_for((uint32_t)in[0]);
229
+ return 1;
230
+ }
231
+
232
+ /* align_correction: fit_delay_ms fit_invert applied_delay_ms applied_inverted travel_min travel_max
233
+ * -> delay_ms inverted clamped */
234
+ static int h_align_correction(const double *in, int n, double *out) {
235
+ if (n != 6) return -1;
236
+ OmxAlignCorrection c;
237
+ omx_align_correction(in[0], (uint32_t)in[1], in[2], (uint32_t)in[3], in[4], in[5], &c);
238
+ out[0] = c.delay_ms;
239
+ out[1] = c.inverted;
240
+ out[2] = c.clamped;
241
+ return 3;
242
+ }
243
+
244
+ static int read_modes(const double *in, int count, OmxRoomMode *modes) {
245
+ for (int i = 0; i < count; i++) {
246
+ modes[i].hz = in[4 * i];
247
+ modes[i].positions = (uint32_t)in[4 * i + 1];
248
+ modes[i].q = in[4 * i + 2];
249
+ modes[i].above_median_db = in[4 * i + 3];
250
+ }
251
+ return 4 * count;
252
+ }
253
+
254
+ /* room_mode_eq_proposals: min_positions nmodes { hz positions q above_median_db }*nmodes
255
+ * -> count { hz gain_db q positions bounded requested_db }*count */
256
+ static int h_room_mode_eq_proposals(const double *in, int n, double *out) {
257
+ if (n < 2) return -1;
258
+ const int nm = (int)in[1];
259
+ if (nm < 0 || nm > 64 || n != 2 + 4 * nm) return -1;
260
+ OmxRoomMode modes[64];
261
+ OmxRoomModeEqProposal props[64];
262
+ read_modes(in + 2, nm, modes);
263
+ uint32_t cnt = 0;
264
+ omx_room_mode_eq_proposals(modes, (uint32_t)nm, (uint32_t)in[0], props, 64u, &cnt);
265
+ out[0] = cnt;
266
+ for (uint32_t i = 0; i < cnt; i++) {
267
+ out[1 + 6 * i + 0] = props[i].hz;
268
+ out[1 + 6 * i + 1] = props[i].gain_db;
269
+ out[1 + 6 * i + 2] = props[i].q;
270
+ out[1 + 6 * i + 3] = props[i].positions;
271
+ out[1 + 6 * i + 4] = props[i].bounded;
272
+ out[1 + 6 * i + 5] = props[i].requested_db;
273
+ }
274
+ return 1 + 6 * (int)cnt;
275
+ }
276
+
277
+ /* room_mode_residual_db: target_hz nbins { hz }*nbins { magnitude_db }*nbins -> has_value value */
278
+ static int h_room_mode_residual_db(const double *in, int n, double *out) {
279
+ if (n < 2) return -1;
280
+ const int nb = (int)in[1];
281
+ if (nb < 0 || nb > 1024 || n != 2 + 2 * nb) return -1;
282
+ static double scratch[1024];
283
+ double residual = 0.0;
284
+ const int has = omx_room_mode_residual_db(in + 2, in + 2 + nb, (uint32_t)nb, in[0], scratch, &residual);
285
+ out[0] = has;
286
+ out[1] = has ? residual : 0.0;
287
+ return 2;
288
+ }
289
+
290
+ /* room_mode_set_stable: tolerance_hz nbefore nafter { hz }*nbefore { hz }*nafter -> 0|1 */
291
+ static int h_room_mode_set_stable(const double *in, int n, double *out) {
292
+ if (n < 3) return -1;
293
+ const int nb = (int)in[1], na = (int)in[2];
294
+ if (nb < 0 || na < 0 || nb > 64 || na > 64 || n != 3 + nb + na) return -1;
295
+ OmxRoomMode before[64], after[64];
296
+ unsigned char used[64];
297
+ for (int i = 0; i < nb; i++) before[i] = (OmxRoomMode){in[3 + i], 2u, 5.0, 6.0};
298
+ for (int i = 0; i < na; i++) after[i] = (OmxRoomMode){in[3 + nb + i], 2u, 5.0, 6.0};
299
+ out[0] = omx_room_mode_set_stable(before, (uint32_t)nb, after, (uint32_t)na, in[0], used);
300
+ return 1;
301
+ }
302
+
303
+ static const struct {
304
+ const char *name;
305
+ handler fn;
306
+ } TABLE[] = {
307
+ {"eq_design", h_eq_design},
308
+ {"eq_octaves_q", h_eq_octaves_q},
309
+ {"eq_band_is_identity", h_eq_band_is_identity},
310
+ {"eq_lv2_clamp_q", h_eq_lv2_clamp_q},
311
+ {"eq_bank", h_eq_bank},
312
+ {"eq_pass", h_eq_pass},
313
+ {"eq_band_magnitude_db", h_eq_band_magnitude_db},
314
+ {"eq_magnitude_sum_db", h_eq_magnitude_sum_db},
315
+ {"hrp_cut_db", h_hrp_cut_db},
316
+ {"hrp_resonance_score", h_hrp_resonance_score},
317
+ {"hrp_cents_between", h_hrp_cents_between},
318
+ {"pan_stereo", h_pan_stereo},
319
+ {"balance_law", h_balance_law},
320
+ {"lin_to_db", h_lin_to_db},
321
+ {"lin_to_db_d", h_lin_to_db_d},
322
+ {"db_to_lin", h_db_to_lin},
323
+ {"rta_fft_size_for", h_rta_fft_size_for},
324
+ {"eq_butterworth_qs", h_eq_butterworth_qs},
325
+ {"dyn_oversample_factor", h_dyn_oversample_factor},
326
+ {"align_correction", h_align_correction},
327
+ {"room_mode_eq_proposals", h_room_mode_eq_proposals},
328
+ {"room_mode_residual_db", h_room_mode_residual_db},
329
+ {"room_mode_set_stable", h_room_mode_set_stable},
330
+ };
331
+
332
+ /* The kernels the table answers, for a test that asks "is the oracle really here". */
333
+ static void list_kernels(void) {
334
+ for (size_t i = 0; i < sizeof TABLE / sizeof TABLE[0]; i++) printf("%s\n", TABLE[i].name);
335
+ }
336
+
337
+ /* The rates a rate-dependent twin is checked at: the contract's full oracle grid, as the library was built with it. */
338
+ static void list_rates(void) {
339
+ static const float rates[OMX_ORACLE_FULL_RATES_COUNT] = OMX_ORACLE_FULL_RATES_INIT;
340
+ for (uint32_t i = 0; i < OMX_ORACLE_FULL_RATES_COUNT; i++) printf("%.17g\n", (double)rates[i]);
341
+ }
342
+
343
+ int main(int argc, char **argv) {
344
+ if (argc > 1 && strcmp(argv[1], "--list") == 0) {
345
+ list_kernels();
346
+ return 0;
347
+ }
348
+ if (argc > 1 && strcmp(argv[1], "--rates") == 0) {
349
+ list_rates();
350
+ return 0;
351
+ }
352
+ static char line[1 << 20];
353
+ static double in[MAXV], out[MAXOUT];
354
+ while (fgets(line, sizeof line, stdin)) {
355
+ char *save = NULL;
356
+ char *name = strtok_r(line, " \t\r\n", &save);
357
+ if (!name) continue;
358
+ int n = 0;
359
+ for (char *tok; (tok = strtok_r(NULL, " \t\r\n", &save)) != NULL;) {
360
+ if (n >= MAXV) {
361
+ n = -2;
362
+ break;
363
+ }
364
+ char *end = NULL;
365
+ in[n++] = strtod(tok, &end);
366
+ if (*end) {
367
+ n = -3;
368
+ break;
369
+ }
370
+ }
371
+ if (n < 0) {
372
+ printf("err bad-number\n");
373
+ continue;
374
+ }
375
+ handler fn = NULL;
376
+ for (size_t i = 0; i < sizeof TABLE / sizeof TABLE[0]; i++)
377
+ if (strcmp(TABLE[i].name, name) == 0) fn = TABLE[i].fn;
378
+ if (!fn) {
379
+ printf("err unknown-kernel\n");
380
+ continue;
381
+ }
382
+ const int k = fn(in, n, out);
383
+ if (k < 0) {
384
+ printf("err bad-arity\n");
385
+ continue;
386
+ }
387
+ printf("ok");
388
+ for (int i = 0; i < k; i++) printf(" %.17g", out[i]);
389
+ printf("\n");
390
+ }
391
+ return 0;
392
+ }
@@ -0,0 +1,35 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ export interface Tolerance {
4
+ /** Allowed relative gap, a fraction of the kernel's number. */
5
+ readonly rel?: number;
6
+ /** Allowed absolute gap. */
7
+ readonly abs?: number;
8
+ }
9
+ export declare const oraclePath: () => string;
10
+ export declare const oracleKernels: () => string[];
11
+ /** The contract's full oracle rate grid, as the library was built with it. */
12
+ export declare const oracleRates: () => number[];
13
+ /** The flags the oracle is built with and where its library came from. */
14
+ export declare const linkage: () => { from: string; flags: string };
15
+ /** One answer row per question row, in order. */
16
+ export declare const oracle: (kernel: string, rows: readonly (readonly number[])[]) => number[][];
17
+ /** '' when `twin` agrees with `kernel` inside `tol`, else a sentence saying how far off it is. */
18
+ export declare const gap: (twin: number, kernel: number, tol: Tolerance) => string;
19
+ export declare const compare: (
20
+ twin: readonly number[],
21
+ kernel: readonly number[],
22
+ tol: Tolerance,
23
+ label?: string,
24
+ ) => { failures: string[]; worst: number };
25
+ export interface Asked {
26
+ readonly kernel: string;
27
+ readonly rows: readonly (readonly number[])[];
28
+ readonly answers: readonly (readonly number[])[];
29
+ }
30
+ export declare const ask: (kernel: string, rows: readonly (readonly number[])[]) => Asked;
31
+ export declare const against: (
32
+ asked: Asked,
33
+ twin: (row: readonly number[]) => readonly number[],
34
+ tol: Tolerance,
35
+ ) => { rows: number; failures: string[]; worst: number };
@@ -0,0 +1,149 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The kernel half of every agreement test: ts-twin-oracle.c answers a kernel by name for a grid of rows, one
5
+ * process per call. It links, in this order: the library OMXDSP_CFLAGS and OMXDSP_LIBS name (a consumer that
6
+ * resolves its own omx-dsp build sets both), this checkout's build/libomxdsp.a when `make lib` has built it (CI
7
+ * does), else the installed omx-dsp through pkg-config, and says which on stderr so a run never hides what it
8
+ * measured. Published in @openmixer/omx-dsp as `@openmixer/omx-dsp/oracle`, with the C source beside it.
9
+ */
10
+ import { execFileSync, spawnSync } from 'node:child_process';
11
+ import { createHash } from 'node:crypto';
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url));
18
+ const SRC = join(HERE, 'ts-twin-oracle.c');
19
+ const REPO = join(HERE, '..', '..');
20
+
21
+ /** The compile and link flags: the caller's library first, the in-tree one second, the installed one third. */
22
+ export const linkage = () => {
23
+ const { OMXDSP_CFLAGS: cflags, OMXDSP_LIBS: libs } = process.env;
24
+ if (cflags !== undefined || libs !== undefined) {
25
+ if (cflags === undefined || !libs) throw new Error('ts-twin-oracle: set both OMXDSP_CFLAGS and OMXDSP_LIBS, or neither');
26
+ return { from: `OMXDSP_LIBS ${libs}`, flags: `${cflags} -o @ '${SRC}' ${libs}` };
27
+ }
28
+ const archive = join(REPO, 'build', 'libomxdsp.a');
29
+ if (existsSync(archive) && existsSync(join(REPO, 'include', 'omxdsp'))) return { from: 'tree', flags: `-ffp-contract=off -I'${join(REPO, 'include')}' -o @ '${SRC}' '${archive}' -lm` };
30
+ const pc = execFileSync('pkg-config', ['--cflags', '--libs', 'omxdsp'], { encoding: 'utf8' }).trim();
31
+ return { from: `installed omx-dsp ${execFileSync('pkg-config', ['--modversion', 'omxdsp'], { encoding: 'utf8' }).trim()}`, flags: `-o @ '${SRC}' ${pc}` };
32
+ };
33
+
34
+ let built = '';
35
+ export const oraclePath = () => {
36
+ if (built) return built;
37
+ const { from, flags } = linkage();
38
+ // an archive the flags name is part of the key, so a rebuilt library is never answered by an old binary
39
+ const stamp = (flags.match(/'?([^' ]+\.a)'?/g) ?? []).map((a) => a.replace(/'/g, '')).filter(existsSync).map((a) => `${a}:${statSync(a).size}:${statSync(a).mtimeMs}`).join(',');
40
+ const key = createHash('sha256').update(readFileSync(SRC)).update(flags).update(stamp).digest('hex').slice(0, 16);
41
+ const dir = join(tmpdir(), `omx-dsp-ts-oracle-${process.getuid?.() ?? 0}`);
42
+ mkdirSync(dir, { recursive: true });
43
+ const bin = join(dir, `oracle-${key}`);
44
+ if (!existsSync(bin)) {
45
+ const tmp = `${bin}.${process.pid}`;
46
+ execFileSync('sh', ['-c', `cc -Wall -Wextra -Werror -O2 ${flags.replace('-o @', `-o '${tmp}'`)}`], { stdio: ['ignore', 'inherit', 'inherit'] });
47
+ renameSync(tmp, bin);
48
+ }
49
+ process.stderr.write(`ts-twin-oracle: linked against the ${from}\n`);
50
+ built = bin;
51
+ return bin;
52
+ };
53
+
54
+ export const oracleKernels = () =>
55
+ execFileSync(oraclePath(), ['--list'], { encoding: 'utf8' }).split('\n').filter(Boolean);
56
+
57
+ /** The rates a rate-dependent twin is checked at: the contract's full oracle grid the library was built with. */
58
+ export const oracleRates = () =>
59
+ execFileSync(oraclePath(), ['--rates'], { encoding: 'utf8' }).split('\n').filter(Boolean).map(Number);
60
+
61
+ const wire = (x) => {
62
+ if (Object.is(x, -0)) return '-0';
63
+ if (typeof x === 'boolean') return x ? '1' : '0';
64
+ if (Number.isNaN(x)) return 'nan';
65
+ if (x === Infinity) return 'inf';
66
+ if (x === -Infinity) return '-inf';
67
+ return String(x);
68
+ };
69
+ const unwire = (t) => {
70
+ const s = t.toLowerCase();
71
+ if (s === 'inf') return Infinity;
72
+ if (s === '-inf') return -Infinity;
73
+ if (s === 'nan' || s === '-nan') return NaN;
74
+ return Number(t);
75
+ };
76
+
77
+ /**
78
+ * Ask the C kernel `kernel` one question per row of `rows`; the answer rows come back in order.
79
+ * A refusal from the oracle (unknown kernel, wrong arity) throws: a silent empty answer would
80
+ * read as agreement with an empty twin.
81
+ */
82
+ export const oracle = (kernel, rows) => {
83
+ if (rows.length === 0) throw new Error(`oracle(${kernel}): no rows — an empty grid proves nothing`);
84
+ const input = rows.map((r) => `${kernel} ${r.map(wire).join(' ')}\n`).join('');
85
+ const r = spawnSync(oraclePath(), [], { input, encoding: 'utf8', maxBuffer: 1 << 28 });
86
+ if (r.status !== 0) throw new Error(`oracle(${kernel}) exited ${r.status}: ${r.stderr}`);
87
+ const lines = r.stdout.split('\n').filter(Boolean);
88
+ if (lines.length !== rows.length) throw new Error(`oracle(${kernel}): ${rows.length} questions, ${lines.length} answers`);
89
+ return lines.map((line, i) => {
90
+ const [tag, ...rest] = line.split(' ');
91
+ if (tag !== 'ok') throw new Error(`oracle(${kernel}) row ${i} [${rows[i].join(' ')}]: ${line}`);
92
+ return rest.map(unwire);
93
+ });
94
+ };
95
+
96
+ /**
97
+ * The gap between a twin's number and the kernel's, as the test states its tolerance: a number
98
+ * agrees when it is within `abs` OR within `rel` of the kernel's (the larger of the two bounds), and
99
+ * non-finite answers agree only when they are the same non-finite value. Returns '' on agreement,
100
+ * else a sentence for the failure message.
101
+ */
102
+ export const gap = (twin, kernel, { rel = 0, abs = 0 }) => {
103
+ if (Number.isNaN(twin) || Number.isNaN(kernel)) return Number.isNaN(twin) && Number.isNaN(kernel) ? '' : `twin ${twin}, kernel ${kernel}`;
104
+ if (!Number.isFinite(twin) || !Number.isFinite(kernel)) return twin === kernel ? '' : `twin ${twin}, kernel ${kernel}`;
105
+ const d = Math.abs(twin - kernel);
106
+ return d <= Math.max(abs, rel * Math.abs(kernel)) ? '' : `twin ${twin}, kernel ${kernel}, off by ${d} (allowed abs ${abs}, rel ${rel})`;
107
+ };
108
+
109
+ /**
110
+ * Compare two equal-length lists of numbers; returns the failures (an empty list is agreement) and
111
+ * the largest gap seen, so a test can print how close the tolerance sits to the truth.
112
+ */
113
+ export const compare = (twin, kernel, tol, label = '') => {
114
+ if (twin.length !== kernel.length) return { failures: [`${label}: twin has ${twin.length} numbers, kernel ${kernel.length}`], worst: Infinity };
115
+ const failures = [];
116
+ let worst = 0;
117
+ for (let i = 0; i < twin.length; i++) {
118
+ const g = gap(twin[i], kernel[i], tol);
119
+ if (g) failures.push(`${label}[${i}]: ${g}`);
120
+ if (Number.isFinite(twin[i]) && Number.isFinite(kernel[i])) worst = Math.max(worst, Math.abs(twin[i] - kernel[i]));
121
+ }
122
+ return { failures, worst };
123
+ };
124
+
125
+ /** The kernel's answers to a grid, asked once so the twin and its sabotage arms read the same ones. */
126
+ export const ask = (kernel, rows) => ({ kernel, rows, answers: oracle(kernel, rows) });
127
+
128
+ /**
129
+ * Run `twin` (row -> list of numbers) over every row of an {@link ask} and compare with the kernel's
130
+ * answer inside `tol`. A twin that throws on a row is a failure of that row. Returns
131
+ * `{ rows, failures, worst }`: the row count, one sentence per disagreement and the largest finite gap.
132
+ */
133
+ export const against = (asked, twin, tol) => {
134
+ const failures = [];
135
+ let worst = 0;
136
+ asked.rows.forEach((row, i) => {
137
+ let t;
138
+ try {
139
+ t = twin(row);
140
+ } catch (e) {
141
+ failures.push(`${asked.kernel}(${row.join(', ')}): the twin threw ${e}`);
142
+ return;
143
+ }
144
+ const c = compare(t, asked.answers[i], tol, `${asked.kernel}(${row.join(', ')})`);
145
+ failures.push(...c.failures);
146
+ worst = Math.max(worst, c.worst);
147
+ });
148
+ return { rows: asked.rows.length, failures, worst };
149
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openmixer/omx-dsp",
3
- "version": "0.28.0",
4
- "description": "The TypeScript twins of omx-dsp kernels, each pinned to its C kernel by an agreement test",
3
+ "version": "0.29.0",
4
+ "description": "The TypeScript twins of omx-dsp kernels, each pinned to its C kernel by an agreement test, and the oracle that pins them",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
7
7
  "author": "Pau Aliagas <linuxnow@gmail.com>",
@@ -16,11 +16,16 @@
16
16
  "types": "./dist/index.d.ts",
17
17
  "import": "./dist/index.js"
18
18
  },
19
+ "./oracle": {
20
+ "types": "./oracle/ts-twin-oracle.d.mts",
21
+ "import": "./oracle/ts-twin-oracle.mjs"
22
+ },
19
23
  "./package.json": "./package.json"
20
24
  },
21
25
  "files": [
22
26
  "dist/",
23
- "src/"
27
+ "src/",
28
+ "oracle/"
24
29
  ],
25
30
  "scripts": {
26
31
  "build": "tsc -p tsconfig.json",
@@ -7,3 +7,21 @@ export const RTA_TARGET_BIN_HZ = 5.9;
7
7
 
8
8
  /** The largest RTA FFT the native ring holds — generated into `omx_contract_limits.h` as `OMX_RTA_FFT_SIZE_MAX`. @see data/kernels/rta.json */
9
9
  export const RTA_FFT_SIZE_MAX = 32768;
10
+
11
+ /** Where the low-pass section hands its poles over from the matched pair to the prewarped bilinear pair, as fractions of Nyquist, and how that band moves with Q. At `qHi` (1/sqrt(2), the 12 dB Butterworth) the band runs from `loAtHiQ` to `hiAtHiQ`; at `qLo` (0.5412, the low-Q half of the 24 dB Butterworth) and below, from `loAtLoQ` to `hiAtLoQ`; between the two it moves linearly. Above `qHi` the prototype peaks, and the band's end moves down to `hiAtPeakQ` by `qPeak`, held above it. Below the band the low-pass is the matched design unchanged. @see data/kernels/eq.json */
12
+ export const LOWPASS_HANDOVER = {
13
+ qHi: 0.7071067811865476,
14
+ qLo: 0.541196100146197,
15
+ qPeak: 0.9,
16
+ loAtHiQ: 0.6,
17
+ hiAtHiQ: 0.85,
18
+ loAtLoQ: 0.8,
19
+ hiAtLoQ: 0.9,
20
+ hiAtPeakQ: 0.8,
21
+ } as const;
22
+
23
+ /** The Q (resonance) extent of a parametric band. @see data/kernels/eq.json */
24
+ export const EQ_Q_RANGE = { min: 0.3, max: 8 } as const;
25
+
26
+ /** 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. @see data/kernels/eq.json */
27
+ export const EQ_NOTCH_Q_RANGE = { min: 0.3, max: 116 } as const;
package/src/eq.ts CHANGED
@@ -1,10 +1,284 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-or-later
2
2
  // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
- /** The twin of omx_eq_design.h omx_eq_octaves_q, moved verbatim from the console (its catalog package). */
3
+ /**
4
+ * The EQ twins: the Q relation and clamp, the section design and the magnitude a surface draws. Each is the
5
+ * TypeScript form of an omx_eq_design.h / omx_magnitude.h / omx_eq_instance.h kernel, pinned to it by
6
+ * test/agreement.test.mjs. A browser draws the curve with these; the engine runs the C.
7
+ */
8
+ import { EQ_NOTCH_Q_RANGE, EQ_Q_RANGE, LOWPASS_HANDOVER } from './definitions.js';
9
+ import { linearToDbFloored } from './units.js';
4
10
 
5
- /** Bandwidth in octaves → Q (the cookbook relation). */
11
+ const clamp = (x: number, lo: number, hi: number): number => (x < lo ? lo : x > hi ? hi : x);
12
+
13
+ /** Bandwidth in octaves → Q (the cookbook relation). The twin of omx_eq_octaves_q. */
6
14
  export function octavesToQ(octaves: number): number {
7
15
  const bw = Math.max(octaves, 0.05);
8
16
  const t = Math.pow(2, bw);
9
17
  return Math.sqrt(t) / (t - 1);
10
18
  }
19
+
20
+ /**
21
+ * A band's Q clamped into the extent its shape allows: a notch may go ultra-narrow ({@link EQ_NOTCH_Q_RANGE}),
22
+ * every other shape keeps {@link EQ_Q_RANGE}. The twin of omx_eq_lv2_clamp_q, which clamps a float.
23
+ * @param type The band shape; only `'notch'` takes the notch range.
24
+ * @param q The asked Q.
25
+ */
26
+ export function clampEqQFor(type: string, q: number): number {
27
+ const r = type === 'notch' ? EQ_NOTCH_Q_RANGE : EQ_Q_RANGE;
28
+ return clamp(q, r.min, r.max);
29
+ }
30
+
31
+ /** The section kinds of omx_eq_design, by the twin's word (omx_eq_kind 0..8 in this order). */
32
+ export type BiquadKind =
33
+ | 'peaking'
34
+ | 'lowShelf'
35
+ | 'highShelf'
36
+ | 'highpass'
37
+ | 'lowpass'
38
+ | 'notch'
39
+ | 'bandpass'
40
+ | 'allpass1'
41
+ | 'allpass2';
42
+
43
+ /** One designed section, `a0` carried explicitly (every design here normalises, so it is 1). */
44
+ export interface BiquadSection {
45
+ readonly b0: number;
46
+ readonly b1: number;
47
+ readonly b2: number;
48
+ readonly a0: number;
49
+ readonly a1: number;
50
+ readonly a2: number;
51
+ }
52
+
53
+ /** How far a peaked low-pass hands its Nyquist target over: 0 at or below qHi, 1 at or above qPeak. */
54
+ function lowpassNyquistHandover(q: number): number {
55
+ const v = (q - LOWPASS_HANDOVER.qHi) / (LOWPASS_HANDOVER.qPeak - LOWPASS_HANDOVER.qHi);
56
+ return v <= 0 ? 0 : v >= 1 ? 1 : v;
57
+ }
58
+
59
+ /** How far a low-Q low-pass raises its Nyquist target across the hand-over: 0 at or above qHi, 1 at or below qLo. */
60
+ function lowpassNyquistRaise(q: number): number {
61
+ const u = (LOWPASS_HANDOVER.qHi - q) / (LOWPASS_HANDOVER.qHi - LOWPASS_HANDOVER.qLo);
62
+ return u <= 0 ? 0 : u >= 1 ? 1 : u;
63
+ }
64
+
65
+ /**
66
+ * The low-pass hand-over band for a Q, as fractions of Nyquist: the matched poles below the first, the bilinear
67
+ * pair from the second. The twin of omx_eq_lowpass_handover; the table is {@link LOWPASS_HANDOVER}.
68
+ */
69
+ export function lowpassHandover(q: number): readonly [number, number] {
70
+ const h = LOWPASS_HANDOVER;
71
+ let u = (h.qHi - q) / (h.qHi - h.qLo);
72
+ u = u < 0 ? 0 : u > 1 ? 1 : u;
73
+ const lo = h.loAtHiQ + (h.loAtLoQ - h.loAtHiQ) * u;
74
+ let hi = h.hiAtHiQ + (h.hiAtLoQ - h.hiAtHiQ) * u;
75
+ hi += (h.hiAtPeakQ - h.hiAtHiQ) * lowpassNyquistHandover(q);
76
+ return [lo, hi];
77
+ }
78
+
79
+ /**
80
+ * One conjugate pair placed by the matched-Z map `z = exp(sT)`: the digital pair `1 + p1 z^-1 + p2 z^-2` of the
81
+ * analogue `s^2 + 2 zeta wn s + wn^2`, and `dc`, that polynomial at `z = 1` in a factored form that does not
82
+ * cancel for a low section. `wn` (radians per sample) is held at or below pi.
83
+ * @param wn The pair's centre, `2 pi f / fs`.
84
+ * @param zeta The damping, `1/(2Q)` for a plain resonator.
85
+ */
86
+ export function matchedPair(wn: number, zeta: number): { p1: number; p2: number; dc: number } {
87
+ const w = wn > Math.PI ? Math.PI : wn;
88
+ const e = Math.exp(-zeta * w);
89
+ if (zeta < 1) {
90
+ const th = Math.sqrt(1 - zeta * zeta) * w;
91
+ const ec = e * Math.cos(th);
92
+ const es = e * Math.sin(th);
93
+ return { p1: -2 * ec, p2: e * e, dc: (1 - ec) * (1 - ec) + es * es };
94
+ }
95
+ // real roots; zeta - sqrt(zeta^2-1) written as 1/(zeta + sqrt(zeta^2-1)), which keeps its digits as Q -> 0
96
+ const sq = Math.sqrt(zeta * zeta - 1);
97
+ const z1 = Math.exp(-w / (zeta + sq));
98
+ const z2 = Math.exp(-w * (zeta + sq));
99
+ return { p1: -(z1 + z2), p2: z1 * z2, dc: (1 - z1) * (1 - z2) };
100
+ }
101
+
102
+ /**
103
+ * One section as omx_eq_design designs it: matched-Z peaking and shelves, a matched low-pass with its fitted
104
+ * numerator and Nyquist hand-over, a matched notch under the cookbook numerator, the cookbook high-pass and
105
+ * bandpass, and the first- and second-order allpasses. The centre is clamped to `[1, 0.999 Nyquist]` and a Q at
106
+ * or under 0 is taken as 1e-3; `gainDb` is read by the peaking and shelf kinds only.
107
+ * @param kind The section kind.
108
+ * @param freqHz Centre or corner, Hz.
109
+ * @param q Quality factor.
110
+ * @param gainDb Gain, dB.
111
+ * @param sampleRate Rate the section runs at, Hz.
112
+ */
113
+ export function rbjSection(kind: BiquadKind, freqHz: number, q: number, gainDb: number, sampleRate: number): BiquadSection {
114
+ const nyquist = sampleRate * 0.5;
115
+ const f0 = clamp(freqHz, 1, nyquist * 0.999);
116
+ const qq = q <= 0 ? 1e-3 : q;
117
+ const w0 = (2 * Math.PI * f0) / sampleRate;
118
+ const A = Math.pow(10, gainDb / 40);
119
+ switch (kind) {
120
+ case 'peaking': {
121
+ const p = matchedPair(w0, 1 / (2 * A * qq));
122
+ const z = matchedPair(w0, A / (2 * qq));
123
+ const k = p.dc / z.dc;
124
+ return { b0: k, b1: k * z.p1, b2: k * z.p2, a0: 1, a1: p.p1, a2: p.p2 };
125
+ }
126
+ case 'lowShelf': {
127
+ const sa = Math.sqrt(A);
128
+ const p = matchedPair(w0 / sa, 1 / (2 * qq));
129
+ const z = matchedPair(w0 * sa, 1 / (2 * qq));
130
+ const k = (A * A * p.dc) / z.dc;
131
+ return { b0: k, b1: k * z.p1, b2: k * z.p2, a0: 1, a1: p.p1, a2: p.p2 };
132
+ }
133
+ case 'highShelf': {
134
+ const sa = Math.sqrt(A);
135
+ const p = matchedPair(w0 * sa, 1 / (2 * qq));
136
+ const z = matchedPair(w0 / sa, 1 / (2 * qq));
137
+ const k = p.dc / z.dc;
138
+ return { b0: k, b1: k * z.p1, b2: k * z.p2, a0: 1, a1: p.p1, a2: p.p2 };
139
+ }
140
+ case 'highpass': {
141
+ const cw = Math.cos(w0);
142
+ const alpha = Math.sin(w0) / (2 * qq);
143
+ const a0 = 1 + alpha;
144
+ return { b0: (1 + cw) / 2 / a0, b1: -(1 + cw) / a0, b2: (1 + cw) / 2 / a0, a0: 1, a1: (-2 * cw) / a0, a2: (1 - alpha) / a0 };
145
+ }
146
+ case 'lowpass': {
147
+ const m = matchedPair(w0, 1 / (2 * qq));
148
+ const band = lowpassHandover(qq);
149
+ const r = f0 / nyquist;
150
+ const x = (r - band[0]) / (band[1] - band[0]);
151
+ const t = x <= 0 ? 0 : x >= 1 ? 1 : x * x * (3 - 2 * x);
152
+ const h = 2 * x - 1;
153
+ const v = h <= 0 ? 0 : h >= 1 ? 1 : h * h * (3 - 2 * h);
154
+ const p = { p1: m.p1, p2: m.p2, dc: m.dc };
155
+ if (t > 0) {
156
+ const cwh = Math.cos(w0);
157
+ const alpha = Math.sin(w0) / (2 * qq);
158
+ const a0 = 1 + alpha;
159
+ p.p1 = m.p1 + ((-2 * cwh) / a0 - m.p1) * t;
160
+ p.p2 = m.p2 + ((1 - alpha) / a0 - m.p2) * t;
161
+ p.dc = 1 + p.p1 + p.p2;
162
+ }
163
+ const keep = (1 - t * lowpassNyquistHandover(qq)) * (1 + v * lowpassNyquistRaise(qq));
164
+ if (keep === 0) {
165
+ const g = (1 + p.p1 + p.p2) / 4;
166
+ return { b0: g, b1: 2 * g, b2: g, a0: 1, a1: p.p1, a2: p.p2 };
167
+ }
168
+ // the prototype's |H|^2 at DC (1), at f0 (Q^2) and at Nyquist, and |D|^2 at the same three points
169
+ const xn = nyquist / f0;
170
+ const un = 1 - xn * xn;
171
+ const tPi = 1 / (un * un + (xn / qq) * (xn / qq));
172
+ const dPi = 1 - p.p1 + p.p2;
173
+ const cw = Math.cos(w0);
174
+ const dr = 1 + p.p1 * cw + p.p2 * Math.cos(2 * w0);
175
+ const di = -(p.p1 * Math.sin(w0) + p.p2 * Math.sin(2 * w0));
176
+ // |N|^2 = U - 4 V s + 16 W s^2 with s = sin^2(w/2), solved from the three points
177
+ const s0 = Math.sin(w0 / 2) * Math.sin(w0 / 2);
178
+ const U = p.dc * p.dc;
179
+ const r1 = tPi * dPi * dPi * keep - U;
180
+ const r2 = qq * qq * (dr * dr + di * di) - U;
181
+ const W = (r2 - s0 * r1) / (16 * s0 * (s0 - 1));
182
+ const n1 = Math.sqrt(U);
183
+ const nm = Math.sqrt(U + r1 > 0 ? U + r1 : 0);
184
+ const sum = (n1 + nm) / 2;
185
+ const b1 = (n1 - nm) / 2;
186
+ const disc = sum * sum - 4 * W;
187
+ const b0 = (sum + Math.sqrt(disc > 0 ? disc : 0)) / 2;
188
+ return { b0, b1, b2: sum - b0, a0: 1, a1: p.p1, a2: p.p2 };
189
+ }
190
+ case 'notch': {
191
+ const b1 = -2 * Math.cos(w0);
192
+ const p = matchedPair(w0, 1 / (2 * qq));
193
+ const sw2 = Math.sin(w0 / 2);
194
+ const k = p.dc / (4 * sw2 * sw2); // 4 sin^2(w0/2) is 2 + b1 without the cancellation
195
+ return { b0: k, b1: k * b1, b2: k, a0: 1, a1: p.p1, a2: p.p2 };
196
+ }
197
+ case 'bandpass': {
198
+ const cw = Math.cos(w0);
199
+ const alpha = Math.sin(w0) / (2 * qq);
200
+ const a0 = 1 + alpha;
201
+ return { b0: alpha / a0, b1: 0, b2: -alpha / a0, a0: 1, a1: (-2 * cw) / a0, a2: (1 - alpha) / a0 };
202
+ }
203
+ case 'allpass1': {
204
+ const t = Math.sin(w0) / (1 + Math.cos(w0));
205
+ const k = (t - 1) / (t + 1);
206
+ return { b0: k, b1: 1, b2: 0, a0: 1, a1: k, a2: 0 };
207
+ }
208
+ case 'allpass2': {
209
+ const K = Math.tan((Math.PI * f0) / sampleRate);
210
+ const norm = 1 / (1 + K / qq + K * K);
211
+ const a1 = 2 * (K * K - 1) * norm;
212
+ const a2 = (1 - K / qq + K * K) * norm;
213
+ return { b0: a2, b1: a1, b2: 1, a0: 1, a1, a2 };
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * The magnitude of one designed section at `freq`, dB, by the unit-circle arithmetic itself. A point at or above
220
+ * Nyquist, or a degenerate denominator, is 0 dB; a full null floors at `floorDb`.
221
+ * @param section The section.
222
+ * @param freq Probe frequency, Hz.
223
+ * @param fs Sample rate, Hz.
224
+ * @param floorDb The floor of a null, dB.
225
+ */
226
+ export function sectionMagnitudeDb(section: BiquadSection, freq: number, fs: number, floorDb: number): number {
227
+ if (!(freq > 0) || !(fs > 0) || freq >= fs / 2) return 0;
228
+ const { b0, b1, b2, a0, a1, a2 } = section;
229
+ const w = (2 * Math.PI * freq) / fs;
230
+ const cw = Math.cos(w);
231
+ const sw = Math.sin(w);
232
+ const cw2 = Math.cos(2 * w);
233
+ const sw2 = Math.sin(2 * w);
234
+ const num = Math.hypot(b0 + b1 * cw + b2 * cw2, -(b1 * sw + b2 * sw2));
235
+ const den = Math.hypot(a0 + a1 * cw + a2 * cw2, -(a1 * sw + a2 * sw2));
236
+ if (den === 0) return 0;
237
+ return linearToDbFloored(num / den, floorDb);
238
+ }
239
+
240
+ /** The shapes a drawn band takes: the plugin and surface spelling. */
241
+ export type BiquadType = 'bell' | 'lowshelf' | 'highshelf' | 'highpass' | 'lowpass' | 'notch' | 'allpass1' | 'allpass2';
242
+
243
+ const SHAPE_KIND: Readonly<Record<BiquadType, BiquadKind>> = {
244
+ bell: 'peaking',
245
+ lowshelf: 'lowShelf',
246
+ highshelf: 'highShelf',
247
+ highpass: 'highpass',
248
+ lowpass: 'lowpass',
249
+ notch: 'notch',
250
+ allpass1: 'allpass1',
251
+ allpass2: 'allpass2',
252
+ };
253
+
254
+ /** One drawn band in engineering units. */
255
+ export interface BiquadBand {
256
+ readonly type: BiquadType;
257
+ /** Centre or corner, Hz. */
258
+ readonly freq: number;
259
+ /** Gain, dB (bell and shelves only). */
260
+ readonly gainDb: number;
261
+ /** Quality factor. */
262
+ readonly q: number;
263
+ }
264
+
265
+ /**
266
+ * One band's magnitude at `freq`, dB: {@link rbjSection} evaluated by {@link sectionMagnitudeDb}. The twin of
267
+ * omx_eq_band_magnitude_db.
268
+ * @param floorDb The floor of a null, dB (the kernel's `floor_db`).
269
+ */
270
+ export function biquadMagnitudeDb(band: BiquadBand, freq: number, fs: number, floorDb: number): number {
271
+ if (!(freq > 0) || !(fs > 0) || freq >= fs / 2) return 0;
272
+ return sectionMagnitudeDb(rbjSection(SHAPE_KIND[band.type], band.freq, band.q, band.gainDb, fs), freq, fs, floorDb);
273
+ }
274
+
275
+ /**
276
+ * The summed magnitude of a band set at one frequency, dB (cascaded sections multiply, so dB add). The twin of
277
+ * omx_eq_magnitude_sum_db.
278
+ * @param floorDb The floor of each band's null, dB.
279
+ */
280
+ export function magnitudeSumDb(bands: readonly BiquadBand[], f: number, fs: number, floorDb: number): number {
281
+ let db = 0;
282
+ for (const b of bands) db += biquadMagnitudeDb(b, f, fs, floorDb);
283
+ return db;
284
+ }
package/src/index.ts CHANGED
@@ -6,5 +6,19 @@
6
6
  * agreement test in test/ that feeds both the same grid.
7
7
  */
8
8
  export { ANALYSIS_FLOOR_DB, dbToLinearGain, linearGainToDb, linearToDbFloored } from './units.js';
9
- export { octavesToQ } from './eq.js';
9
+ export {
10
+ biquadMagnitudeDb,
11
+ clampEqQFor,
12
+ lowpassHandover,
13
+ magnitudeSumDb,
14
+ matchedPair,
15
+ octavesToQ,
16
+ rbjSection,
17
+ sectionMagnitudeDb,
18
+ type BiquadBand,
19
+ type BiquadKind,
20
+ type BiquadSection,
21
+ type BiquadType,
22
+ } from './eq.js';
23
+ export { EQ_NOTCH_Q_RANGE, EQ_Q_RANGE, LOWPASS_HANDOVER, RTA_FFT_SIZE_MAX, RTA_TARGET_BIN_HZ } from './definitions.js';
10
24
  export { fftSizeForRate } from './rta.js';