@openmixer/omx-dsp 0.32.0 → 0.32.2

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,6 +2,30 @@
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
+ /** The lowest frequency any spectrum band covers; below it is infrasonic and not drawn. (Hz) @see data/kernels/rta.json */
6
+ export declare const RTA_BAND_F_MIN = 20;
7
+ /** The highest frequency any spectrum band covers, before the Nyquist clamp (omx_rta_band_f_max). (Hz) @see data/kernels/rta.json */
8
+ export declare const RTA_BAND_F_MAX_CAP = 20000;
9
+ /** A band centred below this samples the LOW (decimated) tier when the reading carries one; at or above it, the HIGH tier. Where the high tier's ~5.86 Hz bins still give ~51 bins per octave, above the finest panel's density. (Hz) @see data/kernels/rta.json */
10
+ export declare const RTA_TIER_CROSSOVER_HZ = 300;
11
+ /** Quiet gate of the band reduction: when no band rises above this, every band reads as silence, so a rig's idle floor never draws as a flat block. Keyed on the loudest band, not per band. (dB) @see data/kernels/rta.json */
12
+ export declare const RTA_BAND_GATE_DB = -60;
13
+ /** Quiet gate of a tap's raw spectrum, before any banding: when no bin rises above this the whole tap reads as the floor. The console's overlay frame applies it; a banded reading is gated by RTA_BAND_GATE_DB instead. (dB) @see data/kernels/rta.json */
14
+ export declare const RTA_TAP_GATE_DB = -60;
15
+ /** The floor of a spectrum display's level window: the bottom of the frame, every surface. (dB) @see data/kernels/rta.json */
16
+ export declare const RTA_LEVEL_DB_MIN = -72;
17
+ /** The ceiling of a spectrum display's level window: the top of the frame, where a band's peak reaching it draws a clip tick. (dB) @see data/kernels/rta.json */
18
+ export declare const RTA_LEVEL_DB_MAX = 0;
19
+ /** The spacing of a spectrum display's one dB graticule, from the ceiling down to the floor. (dB) @see data/kernels/rta.json */
20
+ export declare const RTA_LEVEL_GRID_DB = 12;
21
+ /** How often a watched spectrum is read and published: an operator-cadence poll, never the audio thread. (Hz) @see data/kernels/rta.json */
22
+ export declare const RTA_HZ = 10;
23
+ /** How far the low zoom's window must have advanced, as a fraction of that window, before it is recomputed; between recomputes the last zoom rides the fresh main spectrum unchanged. @see data/kernels/rta.json */
24
+ export declare const RTA_LOW_ADVANCE = 0.25;
25
+ /** The frequencies of a spectrum display's labelled vertical graticule lines, Hz, on the log frequency axis: the decades and their 2 and 5 steps from 20 Hz to 20 kHz, every surface. (Hz) @see data/kernels/rta.json */
26
+ export declare const RTA_GRID_FREQS: readonly number[];
27
+ /** The smallest difference between two taps of a spectrum, POST - PRE in dB, that draws a wedge: a pair of neighbouring bands is a wedge when either band's difference reaches it. The console's overlay held it as 0.004 of its 100 dB span. (dB) @see data/kernels/rta.json */
28
+ export declare const RTA_DELTA_DEADBAND_DB = 0.4;
5
29
  /** 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
30
  export declare const LOWPASS_HANDOVER: {
7
31
  readonly qHi: 0.7071067811865476;
@@ -54,6 +78,8 @@ export declare const FBS_DEFAULT_COOLDOWN_SECONDS = 600;
54
78
  export declare const FBS_DEFAULT_NOTCH_Q = 116;
55
79
  /** Released finds the ring memory holds at once. The console's list is unbounded and expires by FBS_DEFAULT_COOLDOWN_SECONDS; a full memory here forgets its oldest entry first, and counts it. @see data/kernels/fbs.json */
56
80
  export declare const FBS_RING_MEMORY_SLOTS = 32;
81
+ /** How far ONE reconcile pass may move an HRP band's gain, in dB — the gain smoothing, done at the control level because the native cascade applies pushed coefficients verbatim and the design forbids interpolating there. An unbounded gain step is a waveform discontinuity — the click heard when a correction entered or left at full depth (found live, 2026-08-20, the evening increment 9 raised the enter/leave rate). Bounded, a correction ENTERS by deepening from silence and LEAVES by walking to exactly 0 dB — the provable parked identity (`eqBandIsIdentity`) — from which removal is bit-identical. Two decibels per pass at the corrector's 10 Hz cadence walks the full HRP_MAX_CUT_DB travel in ~300 ms: the same order as the tracker's stability hold and FBS's lift steps, while each individual move stays below audibility on a Q-5 bell. (dB) @see data/kernels/hrp.json */
82
+ export declare const HRP_GAIN_STEP_DB = 2;
57
83
  /** The displays a panel description draws from a closed form, the kinds omx_curve_points answers: an EQ's magnitude response, x in Hz and y in dB, and a dynamics stage's static transfer curve, input dB to output dB. The wire spells each id in snake case (`eq_response`, `transfer_curve`), the description's own display word. @see data/primitives/curve.json */
58
84
  export declare const CURVE_DISPLAYS: readonly ["eqResponse", "transferCurve"];
59
85
  /** The panel families omx_curve_points draws, by the description's family word. `eq` draws an eq_response; the other four a transfer_curve, the family choosing the gain computer's mode: `dynamics`, `limiter` and `deesser` act ABOVE the threshold, `gate` BELOW it with the shifted knee of the gate's knee range. Any other family has no closed form here. @see data/primitives/curve.json */
@@ -5,6 +5,30 @@
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
+ /** The lowest frequency any spectrum band covers; below it is infrasonic and not drawn. (Hz) @see data/kernels/rta.json */
9
+ export const RTA_BAND_F_MIN = 20;
10
+ /** The highest frequency any spectrum band covers, before the Nyquist clamp (omx_rta_band_f_max). (Hz) @see data/kernels/rta.json */
11
+ export const RTA_BAND_F_MAX_CAP = 20000;
12
+ /** A band centred below this samples the LOW (decimated) tier when the reading carries one; at or above it, the HIGH tier. Where the high tier's ~5.86 Hz bins still give ~51 bins per octave, above the finest panel's density. (Hz) @see data/kernels/rta.json */
13
+ export const RTA_TIER_CROSSOVER_HZ = 300;
14
+ /** Quiet gate of the band reduction: when no band rises above this, every band reads as silence, so a rig's idle floor never draws as a flat block. Keyed on the loudest band, not per band. (dB) @see data/kernels/rta.json */
15
+ export const RTA_BAND_GATE_DB = -60;
16
+ /** Quiet gate of a tap's raw spectrum, before any banding: when no bin rises above this the whole tap reads as the floor. The console's overlay frame applies it; a banded reading is gated by RTA_BAND_GATE_DB instead. (dB) @see data/kernels/rta.json */
17
+ export const RTA_TAP_GATE_DB = -60;
18
+ /** The floor of a spectrum display's level window: the bottom of the frame, every surface. (dB) @see data/kernels/rta.json */
19
+ export const RTA_LEVEL_DB_MIN = -72;
20
+ /** The ceiling of a spectrum display's level window: the top of the frame, where a band's peak reaching it draws a clip tick. (dB) @see data/kernels/rta.json */
21
+ export const RTA_LEVEL_DB_MAX = 0;
22
+ /** The spacing of a spectrum display's one dB graticule, from the ceiling down to the floor. (dB) @see data/kernels/rta.json */
23
+ export const RTA_LEVEL_GRID_DB = 12;
24
+ /** How often a watched spectrum is read and published: an operator-cadence poll, never the audio thread. (Hz) @see data/kernels/rta.json */
25
+ export const RTA_HZ = 10;
26
+ /** How far the low zoom's window must have advanced, as a fraction of that window, before it is recomputed; between recomputes the last zoom rides the fresh main spectrum unchanged. @see data/kernels/rta.json */
27
+ export const RTA_LOW_ADVANCE = 0.25;
28
+ /** The frequencies of a spectrum display's labelled vertical graticule lines, Hz, on the log frequency axis: the decades and their 2 and 5 steps from 20 Hz to 20 kHz, every surface. (Hz) @see data/kernels/rta.json */
29
+ export const RTA_GRID_FREQS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
30
+ /** The smallest difference between two taps of a spectrum, POST - PRE in dB, that draws a wedge: a pair of neighbouring bands is a wedge when either band's difference reaches it. The console's overlay held it as 0.004 of its 100 dB span. (dB) @see data/kernels/rta.json */
31
+ export const RTA_DELTA_DEADBAND_DB = 0.4;
8
32
  /** 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
33
  export const LOWPASS_HANDOVER = {
10
34
  qHi: 0.7071067811865476,
@@ -57,6 +81,8 @@ export const FBS_DEFAULT_COOLDOWN_SECONDS = 600;
57
81
  export const FBS_DEFAULT_NOTCH_Q = 116;
58
82
  /** Released finds the ring memory holds at once. The console's list is unbounded and expires by FBS_DEFAULT_COOLDOWN_SECONDS; a full memory here forgets its oldest entry first, and counts it. @see data/kernels/fbs.json */
59
83
  export const FBS_RING_MEMORY_SLOTS = 32;
84
+ /** How far ONE reconcile pass may move an HRP band's gain, in dB — the gain smoothing, done at the control level because the native cascade applies pushed coefficients verbatim and the design forbids interpolating there. An unbounded gain step is a waveform discontinuity — the click heard when a correction entered or left at full depth (found live, 2026-08-20, the evening increment 9 raised the enter/leave rate). Bounded, a correction ENTERS by deepening from silence and LEAVES by walking to exactly 0 dB — the provable parked identity (`eqBandIsIdentity`) — from which removal is bit-identical. Two decibels per pass at the corrector's 10 Hz cadence walks the full HRP_MAX_CUT_DB travel in ~300 ms: the same order as the tracker's stability hold and FBS's lift steps, while each individual move stays below audibility on a Q-5 bell. (dB) @see data/kernels/hrp.json */
85
+ export const HRP_GAIN_STEP_DB = 2;
60
86
  /** The displays a panel description draws from a closed form, the kinds omx_curve_points answers: an EQ's magnitude response, x in Hz and y in dB, and a dynamics stage's static transfer curve, input dB to output dB. The wire spells each id in snake case (`eq_response`, `transfer_curve`), the description's own display word. @see data/primitives/curve.json */
61
87
  export const CURVE_DISPLAYS = ['eqResponse', 'transferCurve'];
62
88
  /** The panel families omx_curve_points draws, by the description's family word. `eq` draws an eq_response; the other four a transfer_curve, the family choosing the gain computer's mode: `dynamics`, `limiter` and `deesser` act ABOVE the threshold, `gate` BELOW it with the shifted knee of the gate's knee range. Any other family has no closed form here. @see data/primitives/curve.json */
@@ -63,6 +63,8 @@ interface Slot {
63
63
  dueMs: number;
64
64
  seq: number;
65
65
  } | null;
66
+ /** Seated or re-tagged live, adopted at the next tick. */
67
+ adopt: boolean;
66
68
  }
67
69
  /** The gain a band of `origin` may hold: fbs-* in [FBS_MAX_CUT_DB, 0] (NaN reads 0), every other origin in
68
70
  * EQ_GAIN_RANGE (NaN stays NaN) — the strip store's arm. */
@@ -81,6 +83,8 @@ export declare class FbsPlace {
81
83
  cooldownS: number;
82
84
  nowMs: number;
83
85
  memoryDropped: number;
86
+ /** Bands the strip's EQ holds that are not the placement's. */
87
+ others: number;
84
88
  protected slots: Slot[];
85
89
  protected memory: Array<{
86
90
  freqHz: number;
@@ -101,6 +105,14 @@ export declare class FbsPlace {
101
105
  advance(nowMs: number): void;
102
106
  /** One detector tick: the ramp steps due, a band per newly confirmed ring, then the clean holds. */
103
107
  tick(nowMs: number, snapshotHz: readonly number[], confirmed: readonly FbsFind[]): void;
108
+ /** How many bands the strip's EQ holds that the placement does not own (omx_fbs_place_set_others). */
109
+ setOthers(n: number): void;
110
+ /** The band in slot k left the EQ: the slot frees, nothing said or remembered (omx_fbs_place_remove). */
111
+ remove(k: number): boolean;
112
+ /** The operator re-tagged or re-gained slot k's band (omx_fbs_place_edit). */
113
+ edit(k: number, originIndex: number, gainDb: number): boolean;
114
+ /** A band the EQ already holds, seated in the lowest free slot (omx_fbs_place_seat); the slot, or -1. */
115
+ seat(originIndex: number, freqHz: number, gainDb: number, q: number): number;
104
116
  /** One find or another: the same-ring window, or the merge radius in cents (band first, find second). */
105
117
  protected sameRegion(bandHz: number, findHz: number): boolean;
106
118
  /** The pool a find goes to. */
@@ -115,6 +127,16 @@ export declare class FbsPlace {
115
127
  /** Occupied slots in plant order: the console's band order. */
116
128
  protected bySeq(): number[];
117
129
  protected correct(c: FbsFind): void;
130
+ /** The EQ's other bands, as the shared cap counts them. */
131
+ protected othersCounted(): number;
132
+ /** Every band seated or re-tagged live and not yet tracked, in band order: a fresh hold and its ring memory. */
133
+ protected adopt(): void;
134
+ /** The fixed pool never lifts: a band re-tagged fixed leaves the release ladder where the edit left it. */
135
+ protected leaveLadder(k: number): void;
136
+ /** Whether a pending band is adopted: only one whose origin is fbs-live at the tick. */
137
+ protected adoptsOrigin(originIndex: number): boolean;
138
+ /** The clock an adopted band's hold starts from. */
139
+ protected adoptClock(): number;
118
140
  protected holdSweep(snapshotHz: readonly number[]): void;
119
141
  protected reArm(k: number): void;
120
142
  protected beginStage(k: number, stage: number): void;
package/dist/fbs-place.js CHANGED
@@ -27,7 +27,7 @@ export const SHAPE = { off: shape('off'), bell: shape('bell'), notch: shape('not
27
27
  export const FBS_PLACE_SLOTS = FBS_BANDS_RANGE.max;
28
28
  const freeSlot = () => ({
29
29
  freqHz: 0, gainDb: 0, q: 0, on: false, origin: ORIGIN.user, shape: SHAPE.off, seq: 0,
30
- live: false, plantedDb: 0, lastRingMs: 0, stage: 0, dwellStartMs: null, reclamps: 0, ramp: null,
30
+ live: false, plantedDb: 0, lastRingMs: 0, stage: 0, dwellStartMs: null, reclamps: 0, ramp: null, adopt: false,
31
31
  });
32
32
  /** `n < lo ? lo : n > hi ? hi : n`: core's clamp, a NaN passing through. */
33
33
  const clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n);
@@ -65,6 +65,8 @@ export class FbsPlace {
65
65
  cooldownS = FBS_DEFAULT_COOLDOWN_SECONDS;
66
66
  nowMs = 0;
67
67
  memoryDropped = 0;
68
+ /** Bands the strip's EQ holds that are not the placement's. */
69
+ others = 0;
68
70
  slots = Array.from({ length: FBS_PLACE_SLOTS }, freeSlot);
69
71
  memory = [];
70
72
  seq = 0;
@@ -115,10 +117,61 @@ export class FbsPlace {
115
117
  /** One detector tick: the ramp steps due, a band per newly confirmed ring, then the clean holds. */
116
118
  tick(nowMs, snapshotHz, confirmed) {
117
119
  this.advance(nowMs);
120
+ this.adopt();
118
121
  for (const c of confirmed)
119
122
  this.correct(c);
120
123
  this.holdSweep(snapshotHz);
121
124
  }
125
+ /** How many bands the strip's EQ holds that the placement does not own (omx_fbs_place_set_others). */
126
+ setOthers(n) {
127
+ this.others = n;
128
+ }
129
+ /** The band in slot k left the EQ: the slot frees, nothing said or remembered (omx_fbs_place_remove). */
130
+ remove(k) {
131
+ const s = this.slots[k];
132
+ if (!s || s.origin === ORIGIN.user)
133
+ return false;
134
+ this.release(k);
135
+ return true;
136
+ }
137
+ /** The operator re-tagged or re-gained slot k's band (omx_fbs_place_edit). */
138
+ edit(k, originIndex, gainDb) {
139
+ const s = this.slots[k];
140
+ if (!s || s.origin === ORIGIN.user || originIndex < 0 || originIndex >= ORIGIN_WORDS.length)
141
+ return false;
142
+ if (originIndex !== ORIGIN.fbsFixed && originIndex !== ORIGIN.fbsLive)
143
+ return this.remove(k);
144
+ s.origin = originIndex;
145
+ s.gainDb = this.capGain(originIndex, gainDb);
146
+ if (originIndex === ORIGIN.fbsLive && !s.live)
147
+ s.adopt = true;
148
+ if (originIndex === ORIGIN.fbsFixed)
149
+ this.leaveLadder(k);
150
+ return true;
151
+ }
152
+ /** A band the EQ already holds, seated in the lowest free slot (omx_fbs_place_seat); the slot, or -1. */
153
+ seat(originIndex, freqHz, gainDb, q) {
154
+ if (originIndex !== ORIGIN.fbsFixed && originIndex !== ORIGIN.fbsLive)
155
+ return -1;
156
+ if (!Number.isFinite(freqHz) || !Number.isFinite(gainDb) || !Number.isFinite(q))
157
+ return -1;
158
+ const k = this.slots.findIndex((x) => x.origin === ORIGIN.user);
159
+ if (k < 0)
160
+ return -1;
161
+ const fixed = originIndex === ORIGIN.fbsFixed;
162
+ const s = freeSlot();
163
+ s.origin = originIndex;
164
+ s.freqHz = clamp(freqHz, EQ_FREQ_RANGE.min, EQ_FREQ_RANGE.max);
165
+ s.shape = fixed ? SHAPE.notch : SHAPE.bell;
166
+ s.gainDb = this.capGain(originIndex, gainDb);
167
+ s.q = fixed ? clamp(q, EQ_NOTCH_Q_RANGE.min, EQ_NOTCH_Q_RANGE.max) : clamp(q, EQ_Q_RANGE.min, EQ_Q_RANGE.max);
168
+ s.on = true;
169
+ s.seq = ++this.seq;
170
+ s.plantedDb = s.gainDb;
171
+ s.adopt = !fixed;
172
+ this.slots[k] = s;
173
+ return k;
174
+ }
122
175
  // ---- the rules, one method each ----------------------------------------------------------------------------
123
176
  /** One find or another: the same-ring window, or the merge radius in cents (band first, find second). */
124
177
  sameRegion(bandHz, findHz) {
@@ -166,6 +219,9 @@ export class FbsPlace {
166
219
  this.emit('pool-full', c.freqHz, -1);
167
220
  return;
168
221
  }
222
+ // the strip's EQ is full (FBS_PLACE_SLOTS is the EQ's band count): its store refuses, and nothing is said
223
+ if (this.count() + this.othersCounted() >= FBS_PLACE_SLOTS)
224
+ return;
169
225
  const k = this.slots.findIndex((s) => s.origin === ORIGIN.user);
170
226
  if (k < 0)
171
227
  return;
@@ -188,6 +244,44 @@ export class FbsPlace {
188
244
  s.reclamps = fixed ? 0 : this.takeMemory(s.freqHz);
189
245
  this.emit('notched', c.freqHz, k);
190
246
  }
247
+ /** The EQ's other bands, as the shared cap counts them. */
248
+ othersCounted() {
249
+ return this.others;
250
+ }
251
+ /** Every band seated or re-tagged live and not yet tracked, in band order: a fresh hold and its ring memory. */
252
+ adopt() {
253
+ for (const k of this.bySeq()) {
254
+ const s = this.slots[k];
255
+ if (!s.adopt)
256
+ continue;
257
+ s.adopt = false;
258
+ if (!this.adoptsOrigin(s.origin))
259
+ continue;
260
+ s.live = true;
261
+ s.plantedDb = s.gainDb;
262
+ s.lastRingMs = this.adoptClock();
263
+ s.stage = 0;
264
+ s.dwellStartMs = null;
265
+ s.ramp = null;
266
+ s.reclamps = this.takeMemory(s.freqHz);
267
+ }
268
+ }
269
+ /** The fixed pool never lifts: a band re-tagged fixed leaves the release ladder where the edit left it. */
270
+ leaveLadder(k) {
271
+ const s = this.slots[k];
272
+ s.live = false;
273
+ s.ramp = null;
274
+ s.stage = 0;
275
+ s.dwellStartMs = null;
276
+ }
277
+ /** Whether a pending band is adopted: only one whose origin is fbs-live at the tick. */
278
+ adoptsOrigin(originIndex) {
279
+ return originIndex === ORIGIN.fbsLive;
280
+ }
281
+ /** The clock an adopted band's hold starts from. */
282
+ adoptClock() {
283
+ return this.nowMs;
284
+ }
191
285
  holdSweep(snapshotHz) {
192
286
  for (const k of this.bySeq()) {
193
287
  const s = this.slots[k];
@@ -0,0 +1,22 @@
1
+ /** The authority one placement is given, by the caller. */
2
+ export interface HrpPlaceLimits {
3
+ /** The largest cut, a positive magnitude. */
4
+ readonly maxCutDb: number;
5
+ /** The largest boost, a positive magnitude (0: cuts only). */
6
+ readonly maxBoostDb: number;
7
+ /** How many bands may be placed (a free-slot count). */
8
+ readonly maxBands: number;
9
+ }
10
+ /** One placed band: a bell at freqHz, gainDb (negative is a cut), q, for one harmonic (1 is the fundamental). */
11
+ export interface HrpPlacedBand {
12
+ readonly harmonic: number;
13
+ readonly freqHz: number;
14
+ readonly gainDb: number;
15
+ readonly q: number;
16
+ }
17
+ /** The bands one voice's per-harmonic gains become (`gainsDb[k]` is harmonic k+1), in harmonic order. */
18
+ export declare function hrpPlaceBands(f0Hz: number, gainsDb: readonly number[], q: number, limits: HrpPlaceLimits): HrpPlacedBand[];
19
+ /** Is a band at freqHz placeable (inside EQ_FREQ_RANGE, both bounds kept)? A NaN is not. */
20
+ export declare function hrpPlaceInRange(freqHz: number): boolean;
21
+ /** One bounded move of `current` toward `target`, never further than HRP_GAIN_STEP_DB. */
22
+ export declare function hrpStepToward(current: number, target: number): number;
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The twin of omx_hrp_place.h (src/omx_hrp_place.c): the harmonic resonance processor's placement and stepping.
5
+ * The C placement and this one read the same vector file (test/analysis/fixtures/hrp_place.vectors) in
6
+ * test/analysis/hrp_place.test.c and test/hrp-place.test.mjs, so the two are held to the same values, exactly.
7
+ *
8
+ * The rules are the header's, one function each, in the same order and arithmetic: the console's harmonicBands
9
+ * and the HRP planter's stepToward, term for term.
10
+ */
11
+ import { EQ_FREQ_RANGE, EQ_Q_RANGE, HRP_GAIN_STEP_DB } from './definitions.js';
12
+ /** A number that may take part in the arithmetic. */
13
+ const usable = (v) => Number.isFinite(v);
14
+ /** Below lo is lo, above hi is hi, anything else (a NaN too) passes through. */
15
+ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
16
+ /** REFUSED WHOLE: an input the placement cannot honour places nothing. */
17
+ function refused(f0Hz, q, l) {
18
+ if (!usable(f0Hz) || f0Hz <= 0)
19
+ return true;
20
+ if (!usable(q))
21
+ return true;
22
+ if (!usable(l.maxCutDb) || l.maxCutDb < 0)
23
+ return true;
24
+ if (!usable(l.maxBoostDb) || l.maxBoostDb < 0)
25
+ return true;
26
+ if (!usable(l.maxBands) || l.maxBands < 1)
27
+ return true;
28
+ return false;
29
+ }
30
+ /** The bands one voice's per-harmonic gains become (`gainsDb[k]` is harmonic k+1), in harmonic order. */
31
+ export function hrpPlaceBands(f0Hz, gainsDb, q, limits) {
32
+ if (refused(f0Hz, q, limits))
33
+ return [];
34
+ const bandQ = clamp(q, EQ_Q_RANGE.min, EQ_Q_RANGE.max);
35
+ const placed = [];
36
+ for (let k = 0; k < gainsDb.length; k++) {
37
+ const requested = gainsDb[k];
38
+ if (!usable(requested))
39
+ continue;
40
+ const harmonic = k + 1;
41
+ const freqHz = harmonic * f0Hz;
42
+ if (!hrpPlaceInRange(freqHz))
43
+ continue;
44
+ const gainDb = clamp(requested, -limits.maxCutDb, limits.maxBoostDb);
45
+ if (gainDb === 0)
46
+ continue; // SPARSE: -0 is the same fact
47
+ placed.push({ harmonic, freqHz, gainDb, q: bandQ });
48
+ }
49
+ if (placed.length <= limits.maxBands)
50
+ return placed;
51
+ // THE CAP: the largest |gain| kept, ties to the lower harmonic, then back in harmonic order
52
+ return [...placed]
53
+ .sort((a, b) => Math.abs(b.gainDb) - Math.abs(a.gainDb) || a.harmonic - b.harmonic)
54
+ .slice(0, limits.maxBands)
55
+ .sort((a, b) => a.harmonic - b.harmonic);
56
+ }
57
+ /** Is a band at freqHz placeable (inside EQ_FREQ_RANGE, both bounds kept)? A NaN is not. */
58
+ export function hrpPlaceInRange(freqHz) {
59
+ return !(freqHz < EQ_FREQ_RANGE.min || freqHz > EQ_FREQ_RANGE.max || Number.isNaN(freqHz));
60
+ }
61
+ /** One bounded move of `current` toward `target`, never further than HRP_GAIN_STEP_DB. */
62
+ export function hrpStepToward(current, target) {
63
+ const delta = target - current;
64
+ if (Math.abs(delta) <= HRP_GAIN_STEP_DB)
65
+ return target;
66
+ return current + Math.sign(delta) * HRP_GAIN_STEP_DB;
67
+ }
package/dist/index.d.ts CHANGED
@@ -5,10 +5,12 @@
5
5
  */
6
6
  export { ANALYSIS_FLOOR_DB, dbToLinearGain, linearGainToDb, linearToDbFloored } from './units.js';
7
7
  export { biquadMagnitudeDb, clampEqQFor, lowpassHandover, magnitudeSumDb, matchedPair, octavesToQ, rbjSection, sectionMagnitudeDb, type BiquadBand, type BiquadKind, type BiquadSection, type BiquadType, } from './eq.js';
8
- export { COMP_KNEE_DB_RANGE, COMP_RATIO_RANGE, CURVE_CODES, CURVE_DISPLAYS, CURVE_EXTENTS, CURVE_FAMILIES, CURVE_POINTS_MAX, CURVE_ORIGINS, CURVE_ROLES, CURVE_SHAPES, CURVE_UNITS, EQ_NOTCH_Q_RANGE, EQ_Q_RANGE, GATE_RANGE_DB_RANGE, GATE_RATIO_RANGE, LIN_TO_DB_FLOOR, LOWPASS_HANDOVER, METER_DECIMATION, METER_KINDS, METER_MEASUREMENTS, METER_UNITS, METER_UNITS_LABELS, METER_VIEW, RTA_FFT_SIZE_MAX, RTA_TARGET_BIN_HZ, } from './definitions.js';
9
- export { fftSizeForRate } from './rta.js';
8
+ export { COMP_KNEE_DB_RANGE, COMP_RATIO_RANGE, CURVE_CODES, CURVE_DISPLAYS, CURVE_EXTENTS, CURVE_FAMILIES, CURVE_POINTS_MAX, CURVE_ORIGINS, CURVE_ROLES, CURVE_SHAPES, CURVE_UNITS, EQ_NOTCH_Q_RANGE, EQ_Q_RANGE, GATE_RANGE_DB_RANGE, GATE_RATIO_RANGE, LIN_TO_DB_FLOOR, LOWPASS_HANDOVER, METER_DECIMATION, METER_KINDS, METER_MEASUREMENTS, METER_UNITS, METER_UNITS_LABELS, METER_VIEW, RTA_BAND_F_MAX_CAP, RTA_BAND_F_MIN, RTA_BAND_GATE_DB, RTA_DELTA_DEADBAND_DB, RTA_FFT_SIZE_MAX, RTA_GRID_FREQS, RTA_HZ, RTA_LEVEL_DB_MAX, RTA_LEVEL_DB_MIN, RTA_LEVEL_GRID_DB, RTA_LOW_ADVANCE, RTA_TAP_GATE_DB, RTA_TARGET_BIN_HZ, RTA_TIER_CROSSOVER_HZ, } from './definitions.js';
9
+ export { aggregateSpectrumBands, bandDelta, bandFMax, fftSizeForRate, hzToBin, lowTierGainDb, spectrumBandCentres, type BandDelta, type BandWedge, type MagnitudeSeries, type SpectrumBand, type SpectrumBandOptions, type SpectrumTierInput, } from './rta.js';
10
10
  export { gaincompDb, gaincompDbShifted, gateKnee, type GaincompMode, type GaincompParams } from './gaincomp.js';
11
11
  export { CURVE_WORDS, butterworthQs, curvePoints, type CurveAnswer, type CurveCode, type CurveMark, type CurvePoint, type CurveRequest, type CurveTerm, } from './curve.js';
12
12
  export { FBS_ACTIONS, FBS_HOLD_NEVER, FBS_PLACE_SLOTS, FbsPlace, ORIGIN, ORIGIN_WORDS, SHAPE, SHAPE_WORDS, fbsCents, fbsGainCap, sameRingFreq, type BandReport, type FbsAction, type FbsEvent, type FbsFind, type FbsPool, } from './fbs-place.js';
13
+ export { hrpPlaceBands, hrpPlaceInRange, hrpStepToward, type HrpPlaceLimits, type HrpPlacedBand } from './hrp-place.js';
14
+ export { EQ_FREQ_RANGE, HRP_GAIN_STEP_DB } from './definitions.js';
13
15
  export { EQ_GAIN_RANGE, FBS_MAX_CUT_DB } from './definitions.js';
14
16
  export { METER_GR_EMPTY, METER_GR_HOLD_MS, METER_MARKER_EMPTY, METER_WORDS, meterBarStep, meterClip, meterDecimate, meterFill, meterFillDb, meterGrStep, meterGrView, meterMarker, meterStep, meterUnitOf, type MeterGr, type MeterKind, type MeterMarker, type MeterMeasurement, type MeterUnit, } from './meter.js';
package/dist/index.js CHANGED
@@ -7,10 +7,12 @@
7
7
  */
8
8
  export { ANALYSIS_FLOOR_DB, dbToLinearGain, linearGainToDb, linearToDbFloored } from './units.js';
9
9
  export { biquadMagnitudeDb, clampEqQFor, lowpassHandover, magnitudeSumDb, matchedPair, octavesToQ, rbjSection, sectionMagnitudeDb, } from './eq.js';
10
- export { COMP_KNEE_DB_RANGE, COMP_RATIO_RANGE, CURVE_CODES, CURVE_DISPLAYS, CURVE_EXTENTS, CURVE_FAMILIES, CURVE_POINTS_MAX, CURVE_ORIGINS, CURVE_ROLES, CURVE_SHAPES, CURVE_UNITS, EQ_NOTCH_Q_RANGE, EQ_Q_RANGE, GATE_RANGE_DB_RANGE, GATE_RATIO_RANGE, LIN_TO_DB_FLOOR, LOWPASS_HANDOVER, METER_DECIMATION, METER_KINDS, METER_MEASUREMENTS, METER_UNITS, METER_UNITS_LABELS, METER_VIEW, RTA_FFT_SIZE_MAX, RTA_TARGET_BIN_HZ, } from './definitions.js';
11
- export { fftSizeForRate } from './rta.js';
10
+ export { COMP_KNEE_DB_RANGE, COMP_RATIO_RANGE, CURVE_CODES, CURVE_DISPLAYS, CURVE_EXTENTS, CURVE_FAMILIES, CURVE_POINTS_MAX, CURVE_ORIGINS, CURVE_ROLES, CURVE_SHAPES, CURVE_UNITS, EQ_NOTCH_Q_RANGE, EQ_Q_RANGE, GATE_RANGE_DB_RANGE, GATE_RATIO_RANGE, LIN_TO_DB_FLOOR, LOWPASS_HANDOVER, METER_DECIMATION, METER_KINDS, METER_MEASUREMENTS, METER_UNITS, METER_UNITS_LABELS, METER_VIEW, RTA_BAND_F_MAX_CAP, RTA_BAND_F_MIN, RTA_BAND_GATE_DB, RTA_DELTA_DEADBAND_DB, RTA_FFT_SIZE_MAX, RTA_GRID_FREQS, RTA_HZ, RTA_LEVEL_DB_MAX, RTA_LEVEL_DB_MIN, RTA_LEVEL_GRID_DB, RTA_LOW_ADVANCE, RTA_TAP_GATE_DB, RTA_TARGET_BIN_HZ, RTA_TIER_CROSSOVER_HZ, } from './definitions.js';
11
+ export { aggregateSpectrumBands, bandDelta, bandFMax, fftSizeForRate, hzToBin, lowTierGainDb, spectrumBandCentres, } from './rta.js';
12
12
  export { gaincompDb, gaincompDbShifted, gateKnee } from './gaincomp.js';
13
13
  export { CURVE_WORDS, butterworthQs, curvePoints, } from './curve.js';
14
14
  export { FBS_ACTIONS, FBS_HOLD_NEVER, FBS_PLACE_SLOTS, FbsPlace, ORIGIN, ORIGIN_WORDS, SHAPE, SHAPE_WORDS, fbsCents, fbsGainCap, sameRingFreq, } from './fbs-place.js';
15
+ export { hrpPlaceBands, hrpPlaceInRange, hrpStepToward } from './hrp-place.js';
16
+ export { EQ_FREQ_RANGE, HRP_GAIN_STEP_DB } from './definitions.js';
15
17
  export { EQ_GAIN_RANGE, FBS_MAX_CUT_DB } from './definitions.js';
16
18
  export { METER_GR_EMPTY, METER_GR_HOLD_MS, METER_MARKER_EMPTY, METER_WORDS, meterBarStep, meterClip, meterDecimate, meterFill, meterFillDb, meterGrStep, meterGrView, meterMarker, meterStep, meterUnitOf, } from './meter.js';
package/dist/rta.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- /** The twin of omx_rta.h omx_rta_fft_size_for, moved verbatim from the console (its spectrum bands); the two limits are omx-dsp's own (data/kernels/rta.json). */
1
+ /** The twins of omx_rta.h omx_rta_fft_size_for and omx_rta_bands.h omx_rta_bands and omx_rta_band_delta, moved verbatim from the console (its spectrum bands and its EQ curve's delta wedges); every limit is omx-dsp's own (data/kernels/rta.json) or omx-contract's (EQ_GAIN_RANGE). */
2
2
  /**
3
3
  * The RTA FFT length for `rate` — the TypeScript mirror of `mix_dsp.h`'s `omx_rta_fft_size_for`:
4
4
  * the smallest power of two whose bins are at or under {@link RTA_TARGET_BIN_HZ} (44.1/48 kHz →
@@ -9,3 +9,73 @@
9
9
  * declaring one of its own.
10
10
  */
11
11
  export declare function fftSizeForRate(rate: number): number;
12
+ /**
13
+ * A magnitude series as a consumer receives it: one `Float32Array` from a native reader, or a plain
14
+ * array from the wire, a test or an offline synth. Every reader indexes and measures length only.
15
+ */
16
+ export type MagnitudeSeries = readonly number[] | Float32Array;
17
+ /** One tier of a reading: the linear magnitudes (bin 0 = DC) and the transform that produced them. */
18
+ export interface SpectrumTierInput {
19
+ readonly mags: MagnitudeSeries;
20
+ readonly sampleRate: number;
21
+ readonly fftSize: number;
22
+ }
23
+ /** One log-spaced band: its geometric centre (Hz), the RMS across its bins and its loudest bin (dBFS). */
24
+ export interface SpectrumBand {
25
+ readonly hz: number;
26
+ readonly db: number;
27
+ readonly peakDb: number;
28
+ }
29
+ /** What {@link aggregateSpectrumBands} may be asked to vary; everything else is declared in rta.json. */
30
+ export interface SpectrumBandOptions {
31
+ /** Lowest band edge, Hz. Defaults to RTA_BAND_F_MIN. */
32
+ readonly fMin?: number;
33
+ /** Highest band edge, Hz. Defaults to {@link bandFMax} of the high tier's rate. */
34
+ readonly fMax?: number;
35
+ /** The decimated tier, where the reading carries one. Bands centred under the crossover sample it. */
36
+ readonly low?: SpectrumTierInput;
37
+ }
38
+ /** The (fractional) bin holding `hz` in a transform of `fftSize` at `sampleRate`. */
39
+ export declare function hzToBin(hz: number, sampleRate: number, fftSize: number): number;
40
+ /** The twin of omx_rta_band_f_max: RTA_BAND_F_MAX_CAP, or the Nyquist where that is lower. */
41
+ export declare function bandFMax(sampleRate: number): number;
42
+ /**
43
+ * The twin of omx_rta_low_gain_db: the dB that level-matches a low-tier band to the high tier. A band's
44
+ * value is an RMS across its bins and the low tier packs more bins into the same Hz, so it reads lower by
45
+ * the bin-density ratio; a degenerate tier declines to correct rather than emit a NaN.
46
+ */
47
+ export declare function lowTierGainDb(high: SpectrumTierInput, low: SpectrumTierInput): number;
48
+ /**
49
+ * The band tiling: `nBands` log-even bands from `fMin` to `fMax`, each at its GEOMETRIC centre, by the
50
+ * expressions {@link aggregateSpectrumBands} tiles with, so a client can regenerate the centres of a banded
51
+ * frame without the frame carrying them.
52
+ */
53
+ export declare function spectrumBandCentres(nBands: number, sampleRate: number, options?: Pick<SpectrumBandOptions, 'fMin' | 'fMax'>): number[];
54
+ /**
55
+ * The twin of omx_rta_bands (analysis/omx_rta_bands.h), moved from the console unchanged: reduce `high` (and
56
+ * `low`, for a band centred under RTA_TIER_CROSSOVER_HZ) to `nBands` log-spaced bands. `db` is the RMS across
57
+ * a band's bins, `peakDb` its loudest bin, never tier-corrected; when no band rises above RTA_BAND_GATE_DB
58
+ * every band reads as silence (-Infinity). `nBands` below 1 answers an empty set. Held to the kernel by
59
+ * vectors/rta-bands.json (test/rta-bands.test.mjs).
60
+ */
61
+ export declare function aggregateSpectrumBands(high: SpectrumTierInput, nBands: number, options?: SpectrumBandOptions): SpectrumBand[];
62
+ /** One wedge of the difference: bands `band` and `band + 1`, a boost or a cut, its strength 0 to 1. */
63
+ export interface BandWedge {
64
+ band: number;
65
+ boost: boolean;
66
+ strength: number;
67
+ }
68
+ /** The difference of two taps: POST − PRE per band (NaN where either is silent) and its wedges. */
69
+ export interface BandDelta {
70
+ diff: number[];
71
+ wedges: BandWedge[];
72
+ }
73
+ /**
74
+ * The twin of omx_rta_bands.h's `omx_rta_band_delta`: POST − PRE of two tap readings' band levels (dB, silence
75
+ * -Infinity), NaN where either band is silent; then one wedge per pair of neighbouring bands whose larger |difference|
76
+ * reaches RTA_DELTA_DEADBAND_DB, none across a silent band: a boost when the pair sums to zero or more, else a cut, its
77
+ * strength the larger |difference| over EQ_GAIN_RANGE's maximum, clamped to 1. The two readings must have as many
78
+ * bands (a RangeError otherwise: the C has one count for both). Held to the kernel by vectors/rta-delta.json
79
+ * (test/rta-delta.test.mjs).
80
+ */
81
+ export declare function bandDelta(preDb: readonly number[], postDb: readonly number[]): BandDelta;
package/dist/rta.js CHANGED
@@ -1,7 +1,8 @@
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_rta.h omx_rta_fft_size_for, moved verbatim from the console (its spectrum bands); the two limits are omx-dsp's own (data/kernels/rta.json). */
4
- import { RTA_TARGET_BIN_HZ, RTA_FFT_SIZE_MAX } from './definitions.js';
3
+ /** The twins of omx_rta.h omx_rta_fft_size_for and omx_rta_bands.h omx_rta_bands and omx_rta_band_delta, moved verbatim from the console (its spectrum bands and its EQ curve's delta wedges); every limit is omx-dsp's own (data/kernels/rta.json) or omx-contract's (EQ_GAIN_RANGE). */
4
+ import { RTA_TARGET_BIN_HZ, RTA_FFT_SIZE_MAX, RTA_BAND_F_MIN, RTA_BAND_F_MAX_CAP, RTA_TIER_CROSSOVER_HZ, RTA_BAND_GATE_DB, RTA_DELTA_DEADBAND_DB, EQ_GAIN_RANGE, } from './definitions.js';
5
+ import { linearGainToDb } from './units.js';
5
6
  /**
6
7
  * The RTA FFT length for `rate` — the TypeScript mirror of `mix_dsp.h`'s `omx_rta_fft_size_for`:
7
8
  * the smallest power of two whose bins are at or under {@link RTA_TARGET_BIN_HZ} (44.1/48 kHz →
@@ -17,3 +18,129 @@ export function fftSizeForRate(rate) {
17
18
  n *= 2;
18
19
  return n;
19
20
  }
21
+ /** The (fractional) bin holding `hz` in a transform of `fftSize` at `sampleRate`. */
22
+ export function hzToBin(hz, sampleRate, fftSize) {
23
+ return (hz * fftSize) / sampleRate;
24
+ }
25
+ /** The twin of omx_rta_band_f_max: RTA_BAND_F_MAX_CAP, or the Nyquist where that is lower. */
26
+ export function bandFMax(sampleRate) {
27
+ return Math.min(RTA_BAND_F_MAX_CAP, sampleRate / 2);
28
+ }
29
+ /**
30
+ * The twin of omx_rta_low_gain_db: the dB that level-matches a low-tier band to the high tier. A band's
31
+ * value is an RMS across its bins and the low tier packs more bins into the same Hz, so it reads lower by
32
+ * the bin-density ratio; a degenerate tier declines to correct rather than emit a NaN.
33
+ */
34
+ export function lowTierGainDb(high, low) {
35
+ const ratio = low.fftSize / low.sampleRate / (high.fftSize / high.sampleRate);
36
+ if (!Number.isFinite(ratio) || ratio <= 0)
37
+ return 0;
38
+ return 10 * Math.log10(ratio);
39
+ }
40
+ /** RMS and peak (linear) of `tier` over the bins covering [f0, f1); a band spanning none samples the nearest. DC never. */
41
+ function tierBandStats(tier, f0, f1) {
42
+ const bins = tier.mags.length;
43
+ let i0 = Math.max(1, Math.floor(hzToBin(f0, tier.sampleRate, tier.fftSize)));
44
+ const i1 = Math.min(bins - 1, Math.ceil(hzToBin(f1, tier.sampleRate, tier.fftSize)));
45
+ if (i1 < i0)
46
+ i0 = i1;
47
+ let sumSq = 0;
48
+ let peak = 0;
49
+ let n = 0;
50
+ for (let i = i0; i <= i1; i++) {
51
+ const m = tier.mags[i] ?? 0;
52
+ sumSq += m * m;
53
+ if (m > peak)
54
+ peak = m;
55
+ n++;
56
+ }
57
+ return { rms: n > 0 ? Math.sqrt(sumSq / n) : 0, peak };
58
+ }
59
+ /**
60
+ * The band tiling: `nBands` log-even bands from `fMin` to `fMax`, each at its GEOMETRIC centre, by the
61
+ * expressions {@link aggregateSpectrumBands} tiles with, so a client can regenerate the centres of a banded
62
+ * frame without the frame carrying them.
63
+ */
64
+ export function spectrumBandCentres(nBands, sampleRate, options = {}) {
65
+ if (!Number.isFinite(nBands) || nBands < 1)
66
+ return [];
67
+ const fMinV = options.fMin ?? RTA_BAND_F_MIN;
68
+ const fMaxV = Math.max(options.fMax ?? bandFMax(sampleRate), fMinV * 2);
69
+ const lo = Math.log10(fMinV);
70
+ const hi = Math.log10(fMaxV);
71
+ const centres = new Array(nBands);
72
+ for (let b = 0; b < nBands; b++) {
73
+ const f0 = 10 ** (lo + ((hi - lo) * b) / nBands);
74
+ const f1 = 10 ** (lo + ((hi - lo) * (b + 1)) / nBands);
75
+ centres[b] = Math.sqrt(f0 * f1);
76
+ }
77
+ return centres;
78
+ }
79
+ /**
80
+ * The twin of omx_rta_bands (analysis/omx_rta_bands.h), moved from the console unchanged: reduce `high` (and
81
+ * `low`, for a band centred under RTA_TIER_CROSSOVER_HZ) to `nBands` log-spaced bands. `db` is the RMS across
82
+ * a band's bins, `peakDb` its loudest bin, never tier-corrected; when no band rises above RTA_BAND_GATE_DB
83
+ * every band reads as silence (-Infinity). `nBands` below 1 answers an empty set. Held to the kernel by
84
+ * vectors/rta-bands.json (test/rta-bands.test.mjs).
85
+ */
86
+ export function aggregateSpectrumBands(high, nBands, options = {}) {
87
+ if (!Number.isFinite(nBands) || nBands < 1)
88
+ return [];
89
+ const low = options.low;
90
+ const fMinV = options.fMin ?? RTA_BAND_F_MIN;
91
+ const fMaxV = Math.max(options.fMax ?? bandFMax(high.sampleRate), fMinV * 2);
92
+ const lo = Math.log10(fMinV);
93
+ const hi = Math.log10(fMaxV);
94
+ const lowGainDb = low ? lowTierGainDb(high, low) : 0;
95
+ const bands = new Array(nBands);
96
+ let maxDb = -Infinity;
97
+ for (let b = 0; b < nBands; b++) {
98
+ const f0 = 10 ** (lo + ((hi - lo) * b) / nBands);
99
+ const f1 = 10 ** (lo + ((hi - lo) * (b + 1)) / nBands);
100
+ const centre = Math.sqrt(f0 * f1);
101
+ const tier = low && centre < RTA_TIER_CROSSOVER_HZ ? low : high;
102
+ const { rms, peak } = tierBandStats(tier, f0, f1);
103
+ const db = linearGainToDb(rms) + (tier === low ? lowGainDb : 0);
104
+ if (db > maxDb)
105
+ maxDb = db;
106
+ bands[b] = { hz: centre, db, peakDb: linearGainToDb(peak) };
107
+ }
108
+ if (maxDb < RTA_BAND_GATE_DB) {
109
+ const silence = linearGainToDb(0);
110
+ return bands.map((x) => ({ hz: x.hz, db: silence, peakDb: silence }));
111
+ }
112
+ return bands;
113
+ }
114
+ /**
115
+ * The twin of omx_rta_bands.h's `omx_rta_band_delta`: POST − PRE of two tap readings' band levels (dB, silence
116
+ * -Infinity), NaN where either band is silent; then one wedge per pair of neighbouring bands whose larger |difference|
117
+ * reaches RTA_DELTA_DEADBAND_DB, none across a silent band: a boost when the pair sums to zero or more, else a cut, its
118
+ * strength the larger |difference| over EQ_GAIN_RANGE's maximum, clamped to 1. The two readings must have as many
119
+ * bands (a RangeError otherwise: the C has one count for both). Held to the kernel by vectors/rta-delta.json
120
+ * (test/rta-delta.test.mjs).
121
+ */
122
+ export function bandDelta(preDb, postDb) {
123
+ const n = preDb.length;
124
+ if (postDb.length !== n)
125
+ throw new RangeError(`bandDelta: ${n} PRE bands and ${postDb.length} POST bands`);
126
+ const diff = new Array(n);
127
+ for (let i = 0; i < n; i++) {
128
+ const a = preDb[i];
129
+ const b = postDb[i];
130
+ diff[i] = !Number.isFinite(a) || !Number.isFinite(b) ? NaN : b - a;
131
+ }
132
+ const full = EQ_GAIN_RANGE.max;
133
+ const wedges = [];
134
+ for (let i = 0; i + 1 < n; i++) {
135
+ const d0 = diff[i];
136
+ const d1 = diff[i + 1];
137
+ if (Number.isNaN(d0) || Number.isNaN(d1))
138
+ continue;
139
+ const magnitude = Math.max(Math.abs(d0), Math.abs(d1));
140
+ if (magnitude < RTA_DELTA_DEADBAND_DB)
141
+ continue;
142
+ const strength = magnitude / full;
143
+ wedges.push({ band: i, boost: d0 + d1 >= 0, strength: strength < 1 ? strength : 1 });
144
+ }
145
+ return { diff, wedges };
146
+ }
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@openmixer/omx-dsp",
3
- "version": "0.32.0",
3
+ "version": "0.32.2",
4
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>",
8
- "homepage": "https://github.com/FreeMixer/omx-dsp",
8
+ "homepage": "https://github.com/Open-Mixer/omx-dsp",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/FreeMixer/omx-dsp.git",
11
+ "url": "git+https://github.com/Open-Mixer/omx-dsp.git",
12
12
  "directory": "ts"
13
13
  },
14
14
  "exports": {
@@ -8,6 +8,42 @@ export const RTA_TARGET_BIN_HZ = 5.9;
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
10
 
11
+ /** The lowest frequency any spectrum band covers; below it is infrasonic and not drawn. (Hz) @see data/kernels/rta.json */
12
+ export const RTA_BAND_F_MIN = 20;
13
+
14
+ /** The highest frequency any spectrum band covers, before the Nyquist clamp (omx_rta_band_f_max). (Hz) @see data/kernels/rta.json */
15
+ export const RTA_BAND_F_MAX_CAP = 20000;
16
+
17
+ /** A band centred below this samples the LOW (decimated) tier when the reading carries one; at or above it, the HIGH tier. Where the high tier's ~5.86 Hz bins still give ~51 bins per octave, above the finest panel's density. (Hz) @see data/kernels/rta.json */
18
+ export const RTA_TIER_CROSSOVER_HZ = 300;
19
+
20
+ /** Quiet gate of the band reduction: when no band rises above this, every band reads as silence, so a rig's idle floor never draws as a flat block. Keyed on the loudest band, not per band. (dB) @see data/kernels/rta.json */
21
+ export const RTA_BAND_GATE_DB = -60;
22
+
23
+ /** Quiet gate of a tap's raw spectrum, before any banding: when no bin rises above this the whole tap reads as the floor. The console's overlay frame applies it; a banded reading is gated by RTA_BAND_GATE_DB instead. (dB) @see data/kernels/rta.json */
24
+ export const RTA_TAP_GATE_DB = -60;
25
+
26
+ /** The floor of a spectrum display's level window: the bottom of the frame, every surface. (dB) @see data/kernels/rta.json */
27
+ export const RTA_LEVEL_DB_MIN = -72;
28
+
29
+ /** The ceiling of a spectrum display's level window: the top of the frame, where a band's peak reaching it draws a clip tick. (dB) @see data/kernels/rta.json */
30
+ export const RTA_LEVEL_DB_MAX = 0;
31
+
32
+ /** The spacing of a spectrum display's one dB graticule, from the ceiling down to the floor. (dB) @see data/kernels/rta.json */
33
+ export const RTA_LEVEL_GRID_DB = 12;
34
+
35
+ /** How often a watched spectrum is read and published: an operator-cadence poll, never the audio thread. (Hz) @see data/kernels/rta.json */
36
+ export const RTA_HZ = 10;
37
+
38
+ /** How far the low zoom's window must have advanced, as a fraction of that window, before it is recomputed; between recomputes the last zoom rides the fresh main spectrum unchanged. @see data/kernels/rta.json */
39
+ export const RTA_LOW_ADVANCE = 0.25;
40
+
41
+ /** The frequencies of a spectrum display's labelled vertical graticule lines, Hz, on the log frequency axis: the decades and their 2 and 5 steps from 20 Hz to 20 kHz, every surface. (Hz) @see data/kernels/rta.json */
42
+ export const RTA_GRID_FREQS: readonly number[] = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
43
+
44
+ /** The smallest difference between two taps of a spectrum, POST - PRE in dB, that draws a wedge: a pair of neighbouring bands is a wedge when either band's difference reaches it. The console's overlay held it as 0.004 of its 100 dB span. (dB) @see data/kernels/rta.json */
45
+ export const RTA_DELTA_DEADBAND_DB = 0.4;
46
+
11
47
  /** 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
48
  export const LOWPASS_HANDOVER = {
13
49
  qHi: 0.7071067811865476,
@@ -80,6 +116,9 @@ export const FBS_DEFAULT_NOTCH_Q = 116;
80
116
  /** Released finds the ring memory holds at once. The console's list is unbounded and expires by FBS_DEFAULT_COOLDOWN_SECONDS; a full memory here forgets its oldest entry first, and counts it. @see data/kernels/fbs.json */
81
117
  export const FBS_RING_MEMORY_SLOTS = 32;
82
118
 
119
+ /** How far ONE reconcile pass may move an HRP band's gain, in dB — the gain smoothing, done at the control level because the native cascade applies pushed coefficients verbatim and the design forbids interpolating there. An unbounded gain step is a waveform discontinuity — the click heard when a correction entered or left at full depth (found live, 2026-08-20, the evening increment 9 raised the enter/leave rate). Bounded, a correction ENTERS by deepening from silence and LEAVES by walking to exactly 0 dB — the provable parked identity (`eqBandIsIdentity`) — from which removal is bit-identical. Two decibels per pass at the corrector's 10 Hz cadence walks the full HRP_MAX_CUT_DB travel in ~300 ms: the same order as the tracker's stability hold and FBS's lift steps, while each individual move stays below audibility on a Q-5 bell. (dB) @see data/kernels/hrp.json */
120
+ export const HRP_GAIN_STEP_DB = 2;
121
+
83
122
  /** The displays a panel description draws from a closed form, the kinds omx_curve_points answers: an EQ's magnitude response, x in Hz and y in dB, and a dynamics stage's static transfer curve, input dB to output dB. The wire spells each id in snake case (`eq_response`, `transfer_curve`), the description's own display word. @see data/primitives/curve.json */
84
123
  export const CURVE_DISPLAYS = ['eqResponse', 'transferCurve'] as const;
85
124
 
package/src/fbs-place.ts CHANGED
@@ -102,11 +102,13 @@ interface Slot {
102
102
  dwellStartMs: number | null;
103
103
  reclamps: number;
104
104
  ramp: { step: number; stage: number; fromDb: number; toDb: number; dueMs: number; seq: number } | null;
105
+ /** Seated or re-tagged live, adopted at the next tick. */
106
+ adopt: boolean;
105
107
  }
106
108
 
107
109
  const freeSlot = (): Slot => ({
108
110
  freqHz: 0, gainDb: 0, q: 0, on: false, origin: ORIGIN.user, shape: SHAPE.off, seq: 0,
109
- live: false, plantedDb: 0, lastRingMs: 0, stage: 0, dwellStartMs: null, reclamps: 0, ramp: null,
111
+ live: false, plantedDb: 0, lastRingMs: 0, stage: 0, dwellStartMs: null, reclamps: 0, ramp: null, adopt: false,
110
112
  });
111
113
 
112
114
  /** `n < lo ? lo : n > hi ? hi : n`: core's clamp, a NaN passing through. */
@@ -148,6 +150,8 @@ export class FbsPlace {
148
150
  cooldownS: number = FBS_DEFAULT_COOLDOWN_SECONDS;
149
151
  nowMs = 0;
150
152
  memoryDropped = 0;
153
+ /** Bands the strip's EQ holds that are not the placement's. */
154
+ others = 0;
151
155
  protected slots: Slot[] = Array.from({ length: FBS_PLACE_SLOTS }, freeSlot);
152
156
  protected memory: Array<{ freqHz: number; reclamps: number; releasedMs: number }> = [];
153
157
  protected seq = 0;
@@ -199,10 +203,57 @@ export class FbsPlace {
199
203
  /** One detector tick: the ramp steps due, a band per newly confirmed ring, then the clean holds. */
200
204
  tick(nowMs: number, snapshotHz: readonly number[], confirmed: readonly FbsFind[]): void {
201
205
  this.advance(nowMs);
206
+ this.adopt();
202
207
  for (const c of confirmed) this.correct(c);
203
208
  this.holdSweep(snapshotHz);
204
209
  }
205
210
 
211
+ /** How many bands the strip's EQ holds that the placement does not own (omx_fbs_place_set_others). */
212
+ setOthers(n: number): void {
213
+ this.others = n;
214
+ }
215
+
216
+ /** The band in slot k left the EQ: the slot frees, nothing said or remembered (omx_fbs_place_remove). */
217
+ remove(k: number): boolean {
218
+ const s = this.slots[k];
219
+ if (!s || s.origin === ORIGIN.user) return false;
220
+ this.release(k);
221
+ return true;
222
+ }
223
+
224
+ /** The operator re-tagged or re-gained slot k's band (omx_fbs_place_edit). */
225
+ edit(k: number, originIndex: number, gainDb: number): boolean {
226
+ const s = this.slots[k];
227
+ if (!s || s.origin === ORIGIN.user || originIndex < 0 || originIndex >= ORIGIN_WORDS.length) return false;
228
+ if (originIndex !== ORIGIN.fbsFixed && originIndex !== ORIGIN.fbsLive) return this.remove(k);
229
+ s.origin = originIndex;
230
+ s.gainDb = this.capGain(originIndex, gainDb);
231
+ if (originIndex === ORIGIN.fbsLive && !s.live) s.adopt = true;
232
+ if (originIndex === ORIGIN.fbsFixed) this.leaveLadder(k);
233
+ return true;
234
+ }
235
+
236
+ /** A band the EQ already holds, seated in the lowest free slot (omx_fbs_place_seat); the slot, or -1. */
237
+ seat(originIndex: number, freqHz: number, gainDb: number, q: number): number {
238
+ if (originIndex !== ORIGIN.fbsFixed && originIndex !== ORIGIN.fbsLive) return -1;
239
+ if (!Number.isFinite(freqHz) || !Number.isFinite(gainDb) || !Number.isFinite(q)) return -1;
240
+ const k = this.slots.findIndex((x) => x.origin === ORIGIN.user);
241
+ if (k < 0) return -1;
242
+ const fixed = originIndex === ORIGIN.fbsFixed;
243
+ const s = freeSlot();
244
+ s.origin = originIndex;
245
+ s.freqHz = clamp(freqHz, EQ_FREQ_RANGE.min, EQ_FREQ_RANGE.max);
246
+ s.shape = fixed ? SHAPE.notch : SHAPE.bell;
247
+ s.gainDb = this.capGain(originIndex, gainDb);
248
+ s.q = fixed ? clamp(q, EQ_NOTCH_Q_RANGE.min, EQ_NOTCH_Q_RANGE.max) : clamp(q, EQ_Q_RANGE.min, EQ_Q_RANGE.max);
249
+ s.on = true;
250
+ s.seq = ++this.seq;
251
+ s.plantedDb = s.gainDb;
252
+ s.adopt = !fixed;
253
+ this.slots[k] = s;
254
+ return k;
255
+ }
256
+
206
257
  // ---- the rules, one method each ----------------------------------------------------------------------------
207
258
 
208
259
  /** One find or another: the same-ring window, or the merge radius in cents (band first, find second). */
@@ -257,6 +308,8 @@ export class FbsPlace {
257
308
  this.emit('pool-full', c.freqHz, -1);
258
309
  return;
259
310
  }
311
+ // the strip's EQ is full (FBS_PLACE_SLOTS is the EQ's band count): its store refuses, and nothing is said
312
+ if (this.count() + this.othersCounted() >= FBS_PLACE_SLOTS) return;
260
313
  const k = this.slots.findIndex((s) => s.origin === ORIGIN.user);
261
314
  if (k < 0) return;
262
315
  const fixed = this.poolOfFind() === 'fixed';
@@ -279,6 +332,47 @@ export class FbsPlace {
279
332
  this.emit('notched', c.freqHz, k);
280
333
  }
281
334
 
335
+ /** The EQ's other bands, as the shared cap counts them. */
336
+ protected othersCounted(): number {
337
+ return this.others;
338
+ }
339
+
340
+ /** Every band seated or re-tagged live and not yet tracked, in band order: a fresh hold and its ring memory. */
341
+ protected adopt(): void {
342
+ for (const k of this.bySeq()) {
343
+ const s = this.slots[k]!;
344
+ if (!s.adopt) continue;
345
+ s.adopt = false;
346
+ if (!this.adoptsOrigin(s.origin)) continue;
347
+ s.live = true;
348
+ s.plantedDb = s.gainDb;
349
+ s.lastRingMs = this.adoptClock();
350
+ s.stage = 0;
351
+ s.dwellStartMs = null;
352
+ s.ramp = null;
353
+ s.reclamps = this.takeMemory(s.freqHz);
354
+ }
355
+ }
356
+
357
+ /** The fixed pool never lifts: a band re-tagged fixed leaves the release ladder where the edit left it. */
358
+ protected leaveLadder(k: number): void {
359
+ const s = this.slots[k]!;
360
+ s.live = false;
361
+ s.ramp = null;
362
+ s.stage = 0;
363
+ s.dwellStartMs = null;
364
+ }
365
+
366
+ /** Whether a pending band is adopted: only one whose origin is fbs-live at the tick. */
367
+ protected adoptsOrigin(originIndex: number): boolean {
368
+ return originIndex === ORIGIN.fbsLive;
369
+ }
370
+
371
+ /** The clock an adopted band's hold starts from. */
372
+ protected adoptClock(): number {
373
+ return this.nowMs;
374
+ }
375
+
282
376
  protected holdSweep(snapshotHz: readonly number[]): void {
283
377
  for (const k of this.bySeq()) {
284
378
  const s = this.slots[k]!;
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The twin of omx_hrp_place.h (src/omx_hrp_place.c): the harmonic resonance processor's placement and stepping.
5
+ * The C placement and this one read the same vector file (test/analysis/fixtures/hrp_place.vectors) in
6
+ * test/analysis/hrp_place.test.c and test/hrp-place.test.mjs, so the two are held to the same values, exactly.
7
+ *
8
+ * The rules are the header's, one function each, in the same order and arithmetic: the console's harmonicBands
9
+ * and the HRP planter's stepToward, term for term.
10
+ */
11
+ import { EQ_FREQ_RANGE, EQ_Q_RANGE, HRP_GAIN_STEP_DB } from './definitions.js';
12
+
13
+ /** The authority one placement is given, by the caller. */
14
+ export interface HrpPlaceLimits {
15
+ /** The largest cut, a positive magnitude. */
16
+ readonly maxCutDb: number;
17
+ /** The largest boost, a positive magnitude (0: cuts only). */
18
+ readonly maxBoostDb: number;
19
+ /** How many bands may be placed (a free-slot count). */
20
+ readonly maxBands: number;
21
+ }
22
+
23
+ /** One placed band: a bell at freqHz, gainDb (negative is a cut), q, for one harmonic (1 is the fundamental). */
24
+ export interface HrpPlacedBand {
25
+ readonly harmonic: number;
26
+ readonly freqHz: number;
27
+ readonly gainDb: number;
28
+ readonly q: number;
29
+ }
30
+
31
+ /** A number that may take part in the arithmetic. */
32
+ const usable = (v: number): boolean => Number.isFinite(v);
33
+
34
+ /** Below lo is lo, above hi is hi, anything else (a NaN too) passes through. */
35
+ const clamp = (v: number, lo: number, hi: number): number => (v < lo ? lo : v > hi ? hi : v);
36
+
37
+ /** REFUSED WHOLE: an input the placement cannot honour places nothing. */
38
+ function refused(f0Hz: number, q: number, l: HrpPlaceLimits): boolean {
39
+ if (!usable(f0Hz) || f0Hz <= 0) return true;
40
+ if (!usable(q)) return true;
41
+ if (!usable(l.maxCutDb) || l.maxCutDb < 0) return true;
42
+ if (!usable(l.maxBoostDb) || l.maxBoostDb < 0) return true;
43
+ if (!usable(l.maxBands) || l.maxBands < 1) return true;
44
+ return false;
45
+ }
46
+
47
+ /** The bands one voice's per-harmonic gains become (`gainsDb[k]` is harmonic k+1), in harmonic order. */
48
+ export function hrpPlaceBands(
49
+ f0Hz: number,
50
+ gainsDb: readonly number[],
51
+ q: number,
52
+ limits: HrpPlaceLimits,
53
+ ): HrpPlacedBand[] {
54
+ if (refused(f0Hz, q, limits)) return [];
55
+ const bandQ = clamp(q, EQ_Q_RANGE.min, EQ_Q_RANGE.max);
56
+ const placed: HrpPlacedBand[] = [];
57
+ for (let k = 0; k < gainsDb.length; k++) {
58
+ const requested = gainsDb[k]!;
59
+ if (!usable(requested)) continue;
60
+ const harmonic = k + 1;
61
+ const freqHz = harmonic * f0Hz;
62
+ if (!hrpPlaceInRange(freqHz)) continue;
63
+ const gainDb = clamp(requested, -limits.maxCutDb, limits.maxBoostDb);
64
+ if (gainDb === 0) continue; // SPARSE: -0 is the same fact
65
+ placed.push({ harmonic, freqHz, gainDb, q: bandQ });
66
+ }
67
+ if (placed.length <= limits.maxBands) return placed;
68
+ // THE CAP: the largest |gain| kept, ties to the lower harmonic, then back in harmonic order
69
+ return [...placed]
70
+ .sort((a, b) => Math.abs(b.gainDb) - Math.abs(a.gainDb) || a.harmonic - b.harmonic)
71
+ .slice(0, limits.maxBands)
72
+ .sort((a, b) => a.harmonic - b.harmonic);
73
+ }
74
+
75
+ /** Is a band at freqHz placeable (inside EQ_FREQ_RANGE, both bounds kept)? A NaN is not. */
76
+ export function hrpPlaceInRange(freqHz: number): boolean {
77
+ return !(freqHz < EQ_FREQ_RANGE.min || freqHz > EQ_FREQ_RANGE.max || Number.isNaN(freqHz));
78
+ }
79
+
80
+ /** One bounded move of `current` toward `target`, never further than HRP_GAIN_STEP_DB. */
81
+ export function hrpStepToward(current: number, target: number): number {
82
+ const delta = target - current;
83
+ if (Math.abs(delta) <= HRP_GAIN_STEP_DB) return target;
84
+ return current + Math.sign(delta) * HRP_GAIN_STEP_DB;
85
+ }
package/src/index.ts CHANGED
@@ -44,10 +44,36 @@ export {
44
44
  METER_UNITS,
45
45
  METER_UNITS_LABELS,
46
46
  METER_VIEW,
47
+ RTA_BAND_F_MAX_CAP,
48
+ RTA_BAND_F_MIN,
49
+ RTA_BAND_GATE_DB,
50
+ RTA_DELTA_DEADBAND_DB,
47
51
  RTA_FFT_SIZE_MAX,
52
+ RTA_GRID_FREQS,
53
+ RTA_HZ,
54
+ RTA_LEVEL_DB_MAX,
55
+ RTA_LEVEL_DB_MIN,
56
+ RTA_LEVEL_GRID_DB,
57
+ RTA_LOW_ADVANCE,
58
+ RTA_TAP_GATE_DB,
48
59
  RTA_TARGET_BIN_HZ,
60
+ RTA_TIER_CROSSOVER_HZ,
49
61
  } from './definitions.js';
50
- export { fftSizeForRate } from './rta.js';
62
+ export {
63
+ aggregateSpectrumBands,
64
+ bandDelta,
65
+ bandFMax,
66
+ fftSizeForRate,
67
+ hzToBin,
68
+ lowTierGainDb,
69
+ spectrumBandCentres,
70
+ type BandDelta,
71
+ type BandWedge,
72
+ type MagnitudeSeries,
73
+ type SpectrumBand,
74
+ type SpectrumBandOptions,
75
+ type SpectrumTierInput,
76
+ } from './rta.js';
51
77
  export { gaincompDb, gaincompDbShifted, gateKnee, type GaincompMode, type GaincompParams } from './gaincomp.js';
52
78
  export {
53
79
  CURVE_WORDS,
@@ -78,6 +104,8 @@ export {
78
104
  type FbsFind,
79
105
  type FbsPool,
80
106
  } from './fbs-place.js';
107
+ export { hrpPlaceBands, hrpPlaceInRange, hrpStepToward, type HrpPlaceLimits, type HrpPlacedBand } from './hrp-place.js';
108
+ export { EQ_FREQ_RANGE, HRP_GAIN_STEP_DB } from './definitions.js';
81
109
  export { EQ_GAIN_RANGE, FBS_MAX_CUT_DB } from './definitions.js';
82
110
  export {
83
111
  METER_GR_EMPTY,
package/src/rta.ts CHANGED
@@ -1,8 +1,18 @@
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_rta.h omx_rta_fft_size_for, moved verbatim from the console (its spectrum bands); the two limits are omx-dsp's own (data/kernels/rta.json). */
3
+ /** The twins of omx_rta.h omx_rta_fft_size_for and omx_rta_bands.h omx_rta_bands and omx_rta_band_delta, moved verbatim from the console (its spectrum bands and its EQ curve's delta wedges); every limit is omx-dsp's own (data/kernels/rta.json) or omx-contract's (EQ_GAIN_RANGE). */
4
4
 
5
- import { RTA_TARGET_BIN_HZ, RTA_FFT_SIZE_MAX } from './definitions.js';
5
+ import {
6
+ RTA_TARGET_BIN_HZ,
7
+ RTA_FFT_SIZE_MAX,
8
+ RTA_BAND_F_MIN,
9
+ RTA_BAND_F_MAX_CAP,
10
+ RTA_TIER_CROSSOVER_HZ,
11
+ RTA_BAND_GATE_DB,
12
+ RTA_DELTA_DEADBAND_DB,
13
+ EQ_GAIN_RANGE,
14
+ } from './definitions.js';
15
+ import { linearGainToDb } from './units.js';
6
16
 
7
17
  /**
8
18
  * The RTA FFT length for `rate` — the TypeScript mirror of `mix_dsp.h`'s `omx_rta_fft_size_for`:
@@ -18,3 +28,178 @@ export function fftSizeForRate(rate: number): number {
18
28
  while (n < RTA_FFT_SIZE_MAX && rate / n > RTA_TARGET_BIN_HZ) n *= 2;
19
29
  return n;
20
30
  }
31
+
32
+ /**
33
+ * A magnitude series as a consumer receives it: one `Float32Array` from a native reader, or a plain
34
+ * array from the wire, a test or an offline synth. Every reader indexes and measures length only.
35
+ */
36
+ export type MagnitudeSeries = readonly number[] | Float32Array;
37
+
38
+ /** One tier of a reading: the linear magnitudes (bin 0 = DC) and the transform that produced them. */
39
+ export interface SpectrumTierInput {
40
+ readonly mags: MagnitudeSeries;
41
+ readonly sampleRate: number;
42
+ readonly fftSize: number;
43
+ }
44
+
45
+ /** One log-spaced band: its geometric centre (Hz), the RMS across its bins and its loudest bin (dBFS). */
46
+ export interface SpectrumBand {
47
+ readonly hz: number;
48
+ readonly db: number;
49
+ readonly peakDb: number;
50
+ }
51
+
52
+ /** What {@link aggregateSpectrumBands} may be asked to vary; everything else is declared in rta.json. */
53
+ export interface SpectrumBandOptions {
54
+ /** Lowest band edge, Hz. Defaults to RTA_BAND_F_MIN. */
55
+ readonly fMin?: number;
56
+ /** Highest band edge, Hz. Defaults to {@link bandFMax} of the high tier's rate. */
57
+ readonly fMax?: number;
58
+ /** The decimated tier, where the reading carries one. Bands centred under the crossover sample it. */
59
+ readonly low?: SpectrumTierInput;
60
+ }
61
+
62
+ /** The (fractional) bin holding `hz` in a transform of `fftSize` at `sampleRate`. */
63
+ export function hzToBin(hz: number, sampleRate: number, fftSize: number): number {
64
+ return (hz * fftSize) / sampleRate;
65
+ }
66
+
67
+ /** The twin of omx_rta_band_f_max: RTA_BAND_F_MAX_CAP, or the Nyquist where that is lower. */
68
+ export function bandFMax(sampleRate: number): number {
69
+ return Math.min(RTA_BAND_F_MAX_CAP, sampleRate / 2);
70
+ }
71
+
72
+ /**
73
+ * The twin of omx_rta_low_gain_db: the dB that level-matches a low-tier band to the high tier. A band's
74
+ * value is an RMS across its bins and the low tier packs more bins into the same Hz, so it reads lower by
75
+ * the bin-density ratio; a degenerate tier declines to correct rather than emit a NaN.
76
+ */
77
+ export function lowTierGainDb(high: SpectrumTierInput, low: SpectrumTierInput): number {
78
+ const ratio = low.fftSize / low.sampleRate / (high.fftSize / high.sampleRate);
79
+ if (!Number.isFinite(ratio) || ratio <= 0) return 0;
80
+ return 10 * Math.log10(ratio);
81
+ }
82
+
83
+ /** RMS and peak (linear) of `tier` over the bins covering [f0, f1); a band spanning none samples the nearest. DC never. */
84
+ function tierBandStats(tier: SpectrumTierInput, f0: number, f1: number): { rms: number; peak: number } {
85
+ const bins = tier.mags.length;
86
+ let i0 = Math.max(1, Math.floor(hzToBin(f0, tier.sampleRate, tier.fftSize)));
87
+ const i1 = Math.min(bins - 1, Math.ceil(hzToBin(f1, tier.sampleRate, tier.fftSize)));
88
+ if (i1 < i0) i0 = i1;
89
+ let sumSq = 0;
90
+ let peak = 0;
91
+ let n = 0;
92
+ for (let i = i0; i <= i1; i++) {
93
+ const m = tier.mags[i] ?? 0;
94
+ sumSq += m * m;
95
+ if (m > peak) peak = m;
96
+ n++;
97
+ }
98
+ return { rms: n > 0 ? Math.sqrt(sumSq / n) : 0, peak };
99
+ }
100
+
101
+ /**
102
+ * The band tiling: `nBands` log-even bands from `fMin` to `fMax`, each at its GEOMETRIC centre, by the
103
+ * expressions {@link aggregateSpectrumBands} tiles with, so a client can regenerate the centres of a banded
104
+ * frame without the frame carrying them.
105
+ */
106
+ export function spectrumBandCentres(
107
+ nBands: number,
108
+ sampleRate: number,
109
+ options: Pick<SpectrumBandOptions, 'fMin' | 'fMax'> = {},
110
+ ): number[] {
111
+ if (!Number.isFinite(nBands) || nBands < 1) return [];
112
+ const fMinV = options.fMin ?? RTA_BAND_F_MIN;
113
+ const fMaxV = Math.max(options.fMax ?? bandFMax(sampleRate), fMinV * 2);
114
+ const lo = Math.log10(fMinV);
115
+ const hi = Math.log10(fMaxV);
116
+ const centres = new Array<number>(nBands);
117
+ for (let b = 0; b < nBands; b++) {
118
+ const f0 = 10 ** (lo + ((hi - lo) * b) / nBands);
119
+ const f1 = 10 ** (lo + ((hi - lo) * (b + 1)) / nBands);
120
+ centres[b] = Math.sqrt(f0 * f1);
121
+ }
122
+ return centres;
123
+ }
124
+
125
+ /**
126
+ * The twin of omx_rta_bands (analysis/omx_rta_bands.h), moved from the console unchanged: reduce `high` (and
127
+ * `low`, for a band centred under RTA_TIER_CROSSOVER_HZ) to `nBands` log-spaced bands. `db` is the RMS across
128
+ * a band's bins, `peakDb` its loudest bin, never tier-corrected; when no band rises above RTA_BAND_GATE_DB
129
+ * every band reads as silence (-Infinity). `nBands` below 1 answers an empty set. Held to the kernel by
130
+ * vectors/rta-bands.json (test/rta-bands.test.mjs).
131
+ */
132
+ export function aggregateSpectrumBands(
133
+ high: SpectrumTierInput,
134
+ nBands: number,
135
+ options: SpectrumBandOptions = {},
136
+ ): SpectrumBand[] {
137
+ if (!Number.isFinite(nBands) || nBands < 1) return [];
138
+ const low = options.low;
139
+ const fMinV = options.fMin ?? RTA_BAND_F_MIN;
140
+ const fMaxV = Math.max(options.fMax ?? bandFMax(high.sampleRate), fMinV * 2);
141
+ const lo = Math.log10(fMinV);
142
+ const hi = Math.log10(fMaxV);
143
+ const lowGainDb = low ? lowTierGainDb(high, low) : 0;
144
+ const bands = new Array<SpectrumBand>(nBands);
145
+ let maxDb = -Infinity;
146
+ for (let b = 0; b < nBands; b++) {
147
+ const f0 = 10 ** (lo + ((hi - lo) * b) / nBands);
148
+ const f1 = 10 ** (lo + ((hi - lo) * (b + 1)) / nBands);
149
+ const centre = Math.sqrt(f0 * f1);
150
+ const tier = low && centre < RTA_TIER_CROSSOVER_HZ ? low : high;
151
+ const { rms, peak } = tierBandStats(tier, f0, f1);
152
+ const db = linearGainToDb(rms) + (tier === low ? lowGainDb : 0);
153
+ if (db > maxDb) maxDb = db;
154
+ bands[b] = { hz: centre, db, peakDb: linearGainToDb(peak) };
155
+ }
156
+ if (maxDb < RTA_BAND_GATE_DB) {
157
+ const silence = linearGainToDb(0);
158
+ return bands.map((x) => ({ hz: x.hz, db: silence, peakDb: silence }));
159
+ }
160
+ return bands;
161
+ }
162
+
163
+ /** One wedge of the difference: bands `band` and `band + 1`, a boost or a cut, its strength 0 to 1. */
164
+ export interface BandWedge {
165
+ band: number;
166
+ boost: boolean;
167
+ strength: number;
168
+ }
169
+
170
+ /** The difference of two taps: POST − PRE per band (NaN where either is silent) and its wedges. */
171
+ export interface BandDelta {
172
+ diff: number[];
173
+ wedges: BandWedge[];
174
+ }
175
+
176
+ /**
177
+ * The twin of omx_rta_bands.h's `omx_rta_band_delta`: POST − PRE of two tap readings' band levels (dB, silence
178
+ * -Infinity), NaN where either band is silent; then one wedge per pair of neighbouring bands whose larger |difference|
179
+ * reaches RTA_DELTA_DEADBAND_DB, none across a silent band: a boost when the pair sums to zero or more, else a cut, its
180
+ * strength the larger |difference| over EQ_GAIN_RANGE's maximum, clamped to 1. The two readings must have as many
181
+ * bands (a RangeError otherwise: the C has one count for both). Held to the kernel by vectors/rta-delta.json
182
+ * (test/rta-delta.test.mjs).
183
+ */
184
+ export function bandDelta(preDb: readonly number[], postDb: readonly number[]): BandDelta {
185
+ const n = preDb.length;
186
+ if (postDb.length !== n) throw new RangeError(`bandDelta: ${n} PRE bands and ${postDb.length} POST bands`);
187
+ const diff = new Array<number>(n);
188
+ for (let i = 0; i < n; i++) {
189
+ const a = preDb[i]!;
190
+ const b = postDb[i]!;
191
+ diff[i] = !Number.isFinite(a) || !Number.isFinite(b) ? NaN : b - a;
192
+ }
193
+ const full = EQ_GAIN_RANGE.max;
194
+ const wedges: BandWedge[] = [];
195
+ for (let i = 0; i + 1 < n; i++) {
196
+ const d0 = diff[i]!;
197
+ const d1 = diff[i + 1]!;
198
+ if (Number.isNaN(d0) || Number.isNaN(d1)) continue;
199
+ const magnitude = Math.max(Math.abs(d0), Math.abs(d1));
200
+ if (magnitude < RTA_DELTA_DEADBAND_DB) continue;
201
+ const strength = magnitude / full;
202
+ wedges.push({ band: i, boost: d0 + d1 >= 0, strength: strength < 1 ? strength : 1 });
203
+ }
204
+ return { diff, wedges };
205
+ }