@driftengine/audio 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +11 -0
  4. package/dist/ambientLoop.d.ts +45 -0
  5. package/dist/ambientLoop.js +88 -0
  6. package/dist/audioHarness.d.ts +180 -0
  7. package/dist/audioHarness.js +244 -0
  8. package/dist/filters.d.ts +91 -0
  9. package/dist/filters.js +103 -0
  10. package/dist/formats.d.ts +18 -0
  11. package/dist/formats.js +19 -0
  12. package/dist/graph.d.ts +406 -0
  13. package/dist/graph.js +656 -0
  14. package/dist/index.d.ts +47 -0
  15. package/dist/index.js +39 -0
  16. package/dist/manifest.d.ts +28 -0
  17. package/dist/manifest.js +71 -0
  18. package/dist/mix/bus.d.ts +203 -0
  19. package/dist/mix/bus.js +293 -0
  20. package/dist/mix/console.d.ts +96 -0
  21. package/dist/mix/console.js +131 -0
  22. package/dist/mix/defaultLayout.d.ts +37 -0
  23. package/dist/mix/defaultLayout.js +63 -0
  24. package/dist/mix/inserts.d.ts +64 -0
  25. package/dist/mix/inserts.js +187 -0
  26. package/dist/mix/returns.d.ts +38 -0
  27. package/dist/mix/returns.js +86 -0
  28. package/dist/mix/snapshot.d.ts +30 -0
  29. package/dist/mix/snapshot.js +55 -0
  30. package/dist/positional.d.ts +37 -0
  31. package/dist/positional.js +47 -0
  32. package/dist/registry.d.ts +91 -0
  33. package/dist/registry.js +128 -0
  34. package/dist/rhythm/bands.d.ts +60 -0
  35. package/dist/rhythm/bands.js +12 -0
  36. package/dist/rhythm/beatGrid.d.ts +32 -0
  37. package/dist/rhythm/beatGrid.js +98 -0
  38. package/dist/rhythm/beatMap.d.ts +42 -0
  39. package/dist/rhythm/beatMap.js +405 -0
  40. package/dist/rhythm/kickCore.d.ts +79 -0
  41. package/dist/rhythm/kickCore.js +166 -0
  42. package/dist/rhythm/kickDetector.d.ts +65 -0
  43. package/dist/rhythm/kickDetector.js +202 -0
  44. package/dist/rhythm/renderedPulse.d.ts +15 -0
  45. package/dist/rhythm/renderedPulse.js +138 -0
  46. package/dist/session.d.ts +62 -0
  47. package/dist/session.js +83 -0
  48. package/dist/spatial/ambisonic.d.ts +135 -0
  49. package/dist/spatial/ambisonic.js +299 -0
  50. package/dist/spatial/listener.d.ts +109 -0
  51. package/dist/spatial/listener.js +186 -0
  52. package/dist/spatial/occlusion.d.ts +39 -0
  53. package/dist/spatial/occlusion.js +92 -0
  54. package/dist/spatial/source.d.ts +185 -0
  55. package/dist/spatial/source.js +366 -0
  56. package/dist/spatial/zones.d.ts +129 -0
  57. package/dist/spatial/zones.js +166 -0
  58. package/dist/synth.d.ts +92 -0
  59. package/dist/synth.js +282 -0
  60. package/package.json +54 -0
  61. package/src/ambientLoop.ts +101 -0
  62. package/src/audioHarness.ts +280 -0
  63. package/src/filters.ts +109 -0
  64. package/src/formats.ts +22 -0
  65. package/src/graph.ts +805 -0
  66. package/src/index.ts +84 -0
  67. package/src/manifest.ts +73 -0
  68. package/src/mix/bus.ts +356 -0
  69. package/src/mix/console.ts +181 -0
  70. package/src/mix/defaultLayout.ts +118 -0
  71. package/src/mix/inserts.ts +242 -0
  72. package/src/mix/returns.ts +114 -0
  73. package/src/mix/snapshot.ts +75 -0
  74. package/src/positional.ts +47 -0
  75. package/src/registry.ts +167 -0
  76. package/src/rhythm/bands.ts +45 -0
  77. package/src/rhythm/beatGrid.ts +106 -0
  78. package/src/rhythm/beatMap.ts +514 -0
  79. package/src/rhythm/kickCore.ts +197 -0
  80. package/src/rhythm/kickDetector.ts +233 -0
  81. package/src/rhythm/renderedPulse.ts +147 -0
  82. package/src/session.ts +93 -0
  83. package/src/spatial/ambisonic.ts +358 -0
  84. package/src/spatial/listener.ts +249 -0
  85. package/src/spatial/occlusion.ts +95 -0
  86. package/src/spatial/source.ts +452 -0
  87. package/src/spatial/zones.ts +213 -0
  88. package/src/synth.ts +351 -0
@@ -0,0 +1,514 @@
1
+ import { RHYTHM_BANDS, createBandEnergies } from './bands.ts';
2
+ import type { BandEnergies } from './bands.ts';
3
+ import { KickCore } from './kickCore.ts';
4
+
5
+ /**
6
+ * Offline rhythm analysis: where the kicks are in a whole track.
7
+ *
8
+ * Computed once, ahead of time, from decoded samples. That is the important
9
+ * difference from a live detector, and it buys three things a live one cannot:
10
+ *
11
+ * 1. **Cuts land on the beat.** A real-time detector necessarily fires
12
+ * *after* the transient it is detecting, and by a varying amount. Offline
13
+ * we can look ahead — pick the peak of the onset curve and then walk back
14
+ * to where the transient actually began, which is a fixed reference rather
15
+ * than "whenever the level happened to cross a threshold".
16
+ * 2. **Determinism.** Two people watching the same shared run see the same
17
+ * edit, and the same run watched twice is identical. Nothing here reads a
18
+ * clock or `Math.random`.
19
+ * 3. **Structure.** Choosing where to spend the best shot needs to see the
20
+ * whole track. Real-time analysis by definition cannot.
21
+ *
22
+ * The detection is ported from a production kick detector, whose central idea is
23
+ * *whitening*: in bass-led electronic music the low end is dominated by a sustained
24
+ * 808, so raw low energy is loud all the time and useless. Subtract a weighted
25
+ * bassline/mud/low-mid mask from the kick band and take the difference between
26
+ * a fast and a slow envelope, and only the transient survives.
27
+ */
28
+ export interface BeatMap {
29
+ /**
30
+ * Detected kick onsets in seconds, ascending. These are the hits the
31
+ * analyser is *sure* about — deliberately not every beat in the track.
32
+ */
33
+ readonly beats: Float32Array;
34
+ /** 0–1 per detected beat, for weighting cuts and flashes. */
35
+ readonly strength: Float32Array;
36
+ readonly bpm: number;
37
+ /** 0–1. Low means the hits are real but irregular — a rubato passage. */
38
+ readonly bpmConfidence: number;
39
+ /** Coarse loudness envelope, for finding drops and quiet passages. */
40
+ readonly energy: Float32Array;
41
+ readonly energyHz: number;
42
+ readonly durationSec: number;
43
+ }
44
+
45
+ /**
46
+ * Analysis hop. 5 ms is a fifth of the placement tolerance and coarse enough
47
+ * that a three-minute track is under a tenth of a second of work.
48
+ */
49
+ const HOP_SEC = 0.005;
50
+ /** Energy envelope resolution, for structure rather than for timing. */
51
+ const ENERGY_HZ = 10;
52
+ /**
53
+ * Refractory period, from the prior art. A kick's body rings for longer than
54
+ * this, and without it one hit is reported three times.
55
+ */
56
+ const MIN_INTERVAL_SEC = 0.074;
57
+ /**
58
+ * Half-width of the peak-picking window, in hops (±40 ms).
59
+ *
60
+ * A candidate must be the largest onset within this window. Wider merges
61
+ * genuinely separate kicks at fast tempos; narrower lets a ringing tail count
62
+ * as its own peak, which is how one hit became two beats 64 ms apart in the
63
+ * first working version.
64
+ */
65
+ const PEAK_WINDOW_HOPS = 8;
66
+ /**
67
+ * Where a transient is considered to have *started*, as a fraction of its peak.
68
+ *
69
+ * Reporting the peak itself is late and, worse, late by an amount that varies
70
+ * with how sharp the hit is — soft kicks land tens of milliseconds behind hard
71
+ * ones, so an edit drifts against its own music. Walking back to a fixed
72
+ * fraction of the peak gives the same reference point for every hit.
73
+ */
74
+ const ONSET_BACKTRACK = 0.35;
75
+ /**
76
+ * Minimum strength for a beat to be reported at all.
77
+ *
78
+ * A transient that barely clears its own adaptive floor is indistinguishable
79
+ * from the track breathing, and its *timing* is correspondingly vague — the
80
+ * peak is broad, so the reported instant wanders. Nothing downstream would cut
81
+ * on one anyway, since the director weights by strength.
82
+ */
83
+ const MIN_STRENGTH = 0.03;
84
+ /** Plausible tempo range. Outside it the estimate is a harmonic, not a tempo. */
85
+ const MIN_BPM = 60;
86
+ const MAX_BPM = 200;
87
+
88
+ /*
89
+ * The smoothing, whitening, floors and shape gates moved to `kickCore.ts`, which `kickDetector`
90
+ * now shares with this file. They were the same numbers in both, applied at different rates and
91
+ * to a different whitening — see that module's header. The hop below is the step they were tuned
92
+ * at and is what the core's own rates are derived from, so nothing about this file's output
93
+ * moved when they left it.
94
+ */
95
+
96
+ /** How much of each masking band is subtracted from the kick band. */
97
+
98
+ /**
99
+ * Settling time before any beat may be reported.
100
+ *
101
+ * Every filter and running mean starts at zero, so the first hop of any track
102
+ * looks like an enormous transient against a floor of nothing — a guaranteed
103
+ * false beat at the start of every song, including one that opens with four
104
+ * bars of silence. The curves are built over a warm-up prefix first and that
105
+ * prefix is then discarded, so a genuine downbeat at 0 is still found.
106
+ */
107
+ const WARMUP_SEC = 0.5;
108
+
109
+ /** A map with no beats in it, for when there is no music to analyse. */
110
+ export function emptyBeatMap(durationSec = 0): BeatMap {
111
+ return {
112
+ beats: new Float32Array(0),
113
+ strength: new Float32Array(0),
114
+ bpm: 0,
115
+ bpmConfidence: 0,
116
+ energy: new Float32Array(0),
117
+ energyHz: ENERGY_HZ,
118
+ durationSec,
119
+ };
120
+ }
121
+
122
+ export function analyseTrack(samples: Float32Array, sampleRate: number): BeatMap {
123
+ const durationSec = samples.length / sampleRate;
124
+ const curves = buildCurves(samples, sampleRate);
125
+ const { beats, strength } = pickPeaks(curves);
126
+ const { bpm, confidence } = estimateTempo(beats);
127
+
128
+ return {
129
+ beats: Float32Array.from(beats),
130
+ strength: Float32Array.from(strength),
131
+ bpm,
132
+ bpmConfidence: confidence,
133
+ energy: curves.energy,
134
+ energyHz: ENERGY_HZ,
135
+ durationSec,
136
+ };
137
+ }
138
+
139
+ interface Curves {
140
+ /** Whitened transient strength per hop. */
141
+ readonly onset: Float32Array;
142
+ /** Adaptive threshold per hop. */
143
+ readonly floor: Float32Array;
144
+ /** Whether the spectral shape at this hop is kick-like at all. */
145
+ readonly shaped: Uint8Array;
146
+ readonly energy: Float32Array;
147
+ readonly hopSec: number;
148
+ }
149
+
150
+ /**
151
+ * One pass over the samples, producing the curves peak-picking works on.
152
+ *
153
+ * Separated from peak-picking because they want opposite things: this is
154
+ * causal and streaming, that one needs to look forward and backward. Trying to
155
+ * do both at once is what produces a detector that fires on the wrong edge.
156
+ */
157
+ function buildCurves(samples: Float32Array, sampleRate: number): Curves {
158
+ const hopSamples = Math.max(1, Math.round(sampleRate * HOP_SEC));
159
+ const hops = Math.max(1, Math.floor(samples.length / hopSamples));
160
+ const warmupHops = Math.min(hops, Math.round(WARMUP_SEC / HOP_SEC));
161
+
162
+ const filters = createBandFilters(sampleRate);
163
+ const energies = createBandEnergies();
164
+
165
+ const onset = new Float32Array(hops);
166
+ const floor = new Float32Array(hops);
167
+ const shaped = new Uint8Array(hops);
168
+ const energyHops = Math.max(1, Math.round(1 / (ENERGY_HZ * HOP_SEC)));
169
+ const energy = new Float32Array(Math.max(1, Math.ceil(hops / energyHops)));
170
+
171
+ /* Every running mean, envelope and gate this analyser shares with the live detector. */
172
+ const core = new KickCore();
173
+ let energyAccum = 0;
174
+ let energyCount = 0;
175
+ let energyIndex = 0;
176
+
177
+ // Warm-up runs the same maths over the opening prefix and throws the results
178
+ // away, leaving the filters and running means settled for the real pass.
179
+ for (let pass = 0; pass < warmupHops + hops; pass++) {
180
+ const settling = pass < warmupHops;
181
+ const hop = settling ? pass : pass - warmupHops;
182
+ const start = hop * hopSamples;
183
+ measureBands(samples, start, hopSamples, filters, energies);
184
+
185
+ const { sub, punch, sweet, bassline, mud, lowMid, high } = energies;
186
+
187
+ if (!settling) {
188
+ energyAccum += sub + punch + bassline + mud + lowMid + high;
189
+ energyCount++;
190
+ if (energyCount >= energyHops && energyIndex < energy.length) {
191
+ energy[energyIndex++] = energyAccum / energyCount;
192
+ energyAccum = 0;
193
+ energyCount = 0;
194
+ }
195
+ }
196
+
197
+ /*
198
+ * The shared decision: whitening, onset, flux, the adaptive floors and the shape gates, all
199
+ * in `kickCore.ts` so that a kick means the same thing here and in `kickDetector.ts`. The
200
+ * hop is what the core smooths over, which is what its own constants were tuned at.
201
+ */
202
+ core.step(energies, HOP_SEC);
203
+
204
+ if (settling) continue;
205
+
206
+ /*
207
+ * The offline path's own extra condition on top of the shared shape gates: the whitened
208
+ * level has to clear its own floor as well. Live, that is one of the two threshold tests
209
+ * `KickCore.rising` makes; here it belongs with the shape because peak picking applies the
210
+ * onset threshold itself, a few lines further on, against a curve it can look along.
211
+ */
212
+ const kickLike = core.shaped && core.whitened > core.energyFloor;
213
+
214
+ onset[hop] = core.onset;
215
+ floor[hop] = core.onsetFloor;
216
+ shaped[hop] = kickLike ? 1 : 0;
217
+ }
218
+
219
+ if (energyCount > 0 && energyIndex < energy.length) {
220
+ energy[energyIndex] = energyAccum / energyCount;
221
+ }
222
+
223
+ return { onset, floor, shaped, energy, hopSec: HOP_SEC };
224
+ }
225
+
226
+ /**
227
+ * Turn the onset curve into beat times.
228
+ *
229
+ * A beat is a local maximum of the onset that clears its adaptive floor and is
230
+ * shaped like a kick. Reporting the peak itself would be late by an amount that
231
+ * varies with how sharp the hit is, so the time is walked back to where the
232
+ * transient crossed a fixed fraction of that peak — the same reference point
233
+ * for a soft kick and a hard one.
234
+ */
235
+ function pickPeaks(curves: Curves): { beats: number[]; strength: number[] } {
236
+ const { onset, floor, shaped, hopSec } = curves;
237
+ const beats: number[] = [];
238
+ const strength: number[] = [];
239
+ let lastBeatSec = -Infinity;
240
+
241
+ for (let hop = 0; hop < onset.length; hop++) {
242
+ if (shaped[hop] !== 1) continue;
243
+ const value = onset[hop] ?? 0;
244
+ const threshold = floor[hop] ?? 0;
245
+ if (value <= threshold) continue;
246
+
247
+ // Must be the largest onset nearby, or a ringing tail counts as its own hit.
248
+ let isPeak = true;
249
+ const from = Math.max(0, hop - PEAK_WINDOW_HOPS);
250
+ const to = Math.min(onset.length - 1, hop + PEAK_WINDOW_HOPS);
251
+ for (let i = from; i <= to; i++) {
252
+ if (i === hop) continue;
253
+ const other = onset[i] ?? 0;
254
+ // Ties go to the earlier hop, so a plateau reports its leading edge.
255
+ if (other > value || (other === value && i < hop)) {
256
+ isPeak = false;
257
+ break;
258
+ }
259
+ }
260
+ if (!isPeak) continue;
261
+
262
+ // Walk back to the start of the rise.
263
+ let onsetHop = hop;
264
+ const target = value * ONSET_BACKTRACK;
265
+ while (onsetHop > from && (onset[onsetHop - 1] ?? 0) > target) onsetHop--;
266
+
267
+ const atSec = onsetHop * hopSec;
268
+ if (atSec - lastBeatSec <= MIN_INTERVAL_SEC) continue;
269
+
270
+ // How far past its own floor the transient reached, which scales with the
271
+ // track rather than with an absolute level.
272
+ const power = Math.min(1, (value - threshold) / Math.max(1e-6, threshold * 4));
273
+ if (power < MIN_STRENGTH) continue;
274
+
275
+ lastBeatSec = atSec;
276
+ beats.push(atSec);
277
+ strength.push(power);
278
+ }
279
+
280
+ return { beats, strength };
281
+ }
282
+
283
+ /**
284
+ * The most beats a gap between two reported ones may span, and how near a whole
285
+ * number of beats it has to land to count as one.
286
+ */
287
+ const MAX_SPANNED_BEATS = 4;
288
+ const SPAN_TOLERANCE = 0.12;
289
+
290
+ /**
291
+ * How many beats an interval spans, or 0 when it is not a whole number of them.
292
+ *
293
+ * The analyser reports the hits it is sure of, so a gap between two of them is
294
+ * one beat or several, and deciding which is a single question asked in one
295
+ * place: the tempo estimate folds an interval down by this number, and the
296
+ * confidence count tests regularity with it. Two copies of the rule would agree
297
+ * until the first time either was tuned.
298
+ */
299
+ function spannedBeats(interval: number, beat: number): number {
300
+ const ratio = interval / beat;
301
+ const nearest = Math.round(ratio);
302
+ if (nearest < 1 || nearest > MAX_SPANNED_BEATS) return 0;
303
+ return Math.abs(ratio - nearest) <= SPAN_TOLERANCE ? nearest : 0;
304
+ }
305
+
306
+ /**
307
+ * Tempo from the gaps between the beats that were reported.
308
+ *
309
+ * The reported beats are the hits the analyser is sure of and deliberately not
310
+ * every beat in the track, so a gap between two of them is a whole number of
311
+ * beats rather than one. Both steps below follow from that: the beat is chosen
312
+ * as the gap that accounts for the most others, and every gap is then folded
313
+ * down to a single beat before the median is taken.
314
+ *
315
+ * A statistic over the raw gaps cannot work however it is chosen, and a mean is
316
+ * not the only thing this rules out. The gaps form one cluster per number of
317
+ * beats skipped, so a mean sits between clusters and a median sits at the edge
318
+ * of one — neither lands on a beat, and the error grows with the share of
319
+ * beats missed rather than staying small.
320
+ *
321
+ * Confidence is the share of gaps that are a whole number of beats. Low
322
+ * confidence means the hits are real but do not lie on one pulse, which is a
323
+ * signal a caller should use rather than an error — and it is also the honest
324
+ * reading when the track has no single tempo.
325
+ *
326
+ * **What this cannot do**: separate a track counted at every other kick from
327
+ * one played at half the speed. Their gaps are identical, so nothing here can
328
+ * tell them apart, and such a track is reported at half its tempo with full
329
+ * confidence. It takes alternate kicks about 15 dB down to reach that state.
330
+ * The evidence that would settle it is not in the beat list and is not usably
331
+ * in the onset curve either — measured, the missing kick sits below the floor
332
+ * that would report it; `docs/IMPROVEMENTS.md` carries both numbers.
333
+ */
334
+ function estimateTempo(beats: readonly number[]): { bpm: number; confidence: number } {
335
+ if (beats.length < 3) return { bpm: 0, confidence: 0 };
336
+
337
+ const intervals: number[] = [];
338
+ for (let i = 1; i < beats.length; i++) {
339
+ intervals.push((beats[i] ?? 0) - (beats[i - 1] ?? 0));
340
+ }
341
+ const sorted = [...intervals].sort((a, b) => a - b);
342
+
343
+ /*
344
+ * The beat is the gap that accounts for the most other gaps.
345
+ *
346
+ * Taking the middle of the set instead is what this replaces, and it is wrong
347
+ * for a reason no amount of tuning reaches: every interval is a whole number
348
+ * of beats, so the set has one cluster per number of beats skipped, and its
349
+ * middle falls at the top of one cluster or the foot of the next rather than
350
+ * on a beat. Measured on synthetic tracks whose kicks are two in three, that
351
+ * put four tempos of six at *exactly half* their true value, and a fully
352
+ * detected 140 counted 2.6% slow.
353
+ *
354
+ * Scoring every observed gap as a candidate is what makes it robust rather
355
+ * than merely better placed. A single unrepresentative gap — a ghost hit
356
+ * close behind a real one, or one straddling a tempo change — explains
357
+ * nothing but itself and loses; picking a fixed quantile cannot tell the two
358
+ * apart, and a 128 read that way came out at 112 off one gap of 1.14 beats.
359
+ *
360
+ * Ties go to the longer candidate: within one cluster the choice moves the
361
+ * base by less than the tolerance and the median below settles the value.
362
+ *
363
+ * The cost is a pass over the gaps per gap. This runs once per track, off the
364
+ * frame loop, and a ten-minute track at 174 counts under two million steps.
365
+ * What would make it wrong is calling it per frame, which is what the live
366
+ * detector exists for.
367
+ */
368
+ let base = 0;
369
+ let explainedByBase = -1;
370
+ for (const candidate of sorted) {
371
+ if (candidate <= 0) continue;
372
+ let explained = 0;
373
+ for (const interval of intervals) {
374
+ if (spannedBeats(interval, candidate) > 0) explained++;
375
+ }
376
+ if (explained >= explainedByBase) {
377
+ explainedByBase = explained;
378
+ base = candidate;
379
+ }
380
+ }
381
+ if (base <= 0) return { bpm: 0, confidence: 0 };
382
+
383
+ /*
384
+ * The base decides only how the gaps group; the median of what they fold to
385
+ * decides the tempo, so a base a hop or two off its cluster costs nothing.
386
+ */
387
+ const folded: number[] = [];
388
+ for (const interval of intervals) {
389
+ const spanned = spannedBeats(interval, base);
390
+ if (spanned > 0) folded.push(interval / spanned);
391
+ }
392
+ folded.sort((a, b) => a - b);
393
+ const median = folded[folded.length >> 1] ?? base;
394
+ if (median <= 0) return { bpm: 0, confidence: 0 };
395
+
396
+ let bpm = 60 / median;
397
+ // Fold octave errors back into a plausible range: catching every other kick
398
+ // reads as half tempo, and catching both hits of a double reads as twice it.
399
+ while (bpm < MIN_BPM) bpm *= 2;
400
+ while (bpm > MAX_BPM) bpm /= 2;
401
+
402
+ /*
403
+ * Confidence counts intervals that are a whole multiple of the median, not
404
+ * only ones equal to it. A detector that misses the occasional quiet kick
405
+ * leaves a double-length gap, and that is still perfectly regular — treating
406
+ * it as disagreement would report a steady track as rubato.
407
+ */
408
+ let regular = 0;
409
+ for (const interval of intervals) {
410
+ if (spannedBeats(interval, median) > 0) regular++;
411
+ }
412
+
413
+ return { bpm, confidence: regular / intervals.length };
414
+ }
415
+
416
+ /**
417
+ * A one-pole band-pass follower per band.
418
+ *
419
+ * A filter bank rather than an FFT. The detector only ever reads seven band
420
+ * energies, so a transform producing hundreds of bins computes — and then
421
+ * discards — the wrong shape of answer at several times the cost.
422
+ */
423
+ interface BandFilter {
424
+ /** Two low-pass coefficients bracketing the band; the difference is the band. */
425
+ readonly lowA: number;
426
+ readonly highA: number;
427
+ low: number;
428
+ high: number;
429
+ }
430
+
431
+ interface BandFilters {
432
+ readonly sub: BandFilter;
433
+ readonly punch: BandFilter;
434
+ readonly sweet: BandFilter;
435
+ readonly bassline: BandFilter;
436
+ readonly mud: BandFilter;
437
+ readonly lowMid: BandFilter;
438
+ readonly high: BandFilter;
439
+ }
440
+
441
+ function onePole(cutoffHz: number, sampleRate: number): number {
442
+ // Clamped below Nyquist so a high band on a low sample rate degrades to a
443
+ // pass-through instead of going unstable.
444
+ const x = Math.exp((-2 * Math.PI * Math.min(cutoffHz, sampleRate * 0.49)) / sampleRate);
445
+ return 1 - x;
446
+ }
447
+
448
+ function makeFilter(lowHz: number, highHz: number, sampleRate: number): BandFilter {
449
+ return {
450
+ lowA: onePole(highHz, sampleRate),
451
+ highA: onePole(lowHz, sampleRate),
452
+ low: 0,
453
+ high: 0,
454
+ };
455
+ }
456
+
457
+ function createBandFilters(sampleRate: number): BandFilters {
458
+ const b = RHYTHM_BANDS;
459
+ return {
460
+ sub: makeFilter(b.sub.lowHz, b.sub.highHz, sampleRate),
461
+ punch: makeFilter(b.punch.lowHz, b.punch.highHz, sampleRate),
462
+ sweet: makeFilter(b.sweet.lowHz, b.sweet.highHz, sampleRate),
463
+ bassline: makeFilter(b.bassline.lowHz, b.bassline.highHz, sampleRate),
464
+ mud: makeFilter(b.mud.lowHz, b.mud.highHz, sampleRate),
465
+ lowMid: makeFilter(b.lowMid.lowHz, b.lowMid.highHz, sampleRate),
466
+ high: makeFilter(b.high.lowHz, b.high.highHz, sampleRate),
467
+ };
468
+ }
469
+
470
+ /** RMS in each band over one hop, written into a caller-owned record. */
471
+ function measureBands(
472
+ samples: Float32Array,
473
+ start: number,
474
+ count: number,
475
+ filters: BandFilters,
476
+ out: BandEnergies,
477
+ ): void {
478
+ let sub = 0;
479
+ let punch = 0;
480
+ let sweet = 0;
481
+ let bassline = 0;
482
+ let mud = 0;
483
+ let lowMid = 0;
484
+ let high = 0;
485
+
486
+ const end = Math.min(start + count, samples.length);
487
+ for (let i = start; i < end; i++) {
488
+ const x = samples[i] ?? 0;
489
+ sub += step(filters.sub, x);
490
+ punch += step(filters.punch, x);
491
+ sweet += step(filters.sweet, x);
492
+ bassline += step(filters.bassline, x);
493
+ mud += step(filters.mud, x);
494
+ lowMid += step(filters.lowMid, x);
495
+ high += step(filters.high, x);
496
+ }
497
+
498
+ const inv = 1 / Math.max(1, end - start);
499
+ out.sub = Math.sqrt(sub * inv);
500
+ out.punch = Math.sqrt(punch * inv);
501
+ out.sweet = Math.sqrt(sweet * inv);
502
+ out.bassline = Math.sqrt(bassline * inv);
503
+ out.mud = Math.sqrt(mud * inv);
504
+ out.lowMid = Math.sqrt(lowMid * inv);
505
+ out.high = Math.sqrt(high * inv);
506
+ }
507
+
508
+ /** Advance one filter by one sample and return that sample's squared output. */
509
+ function step(filter: BandFilter, x: number): number {
510
+ filter.low += (x - filter.low) * filter.lowA;
511
+ filter.high += (x - filter.high) * filter.highA;
512
+ const band = filter.low - filter.high;
513
+ return band * band;
514
+ }