@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,405 @@
1
+ import { RHYTHM_BANDS, createBandEnergies } from './bands.js';
2
+ import { KickCore } from './kickCore.js';
3
+ /**
4
+ * Analysis hop. 5 ms is a fifth of the placement tolerance and coarse enough
5
+ * that a three-minute track is under a tenth of a second of work.
6
+ */
7
+ const HOP_SEC = 0.005;
8
+ /** Energy envelope resolution, for structure rather than for timing. */
9
+ const ENERGY_HZ = 10;
10
+ /**
11
+ * Refractory period, from the prior art. A kick's body rings for longer than
12
+ * this, and without it one hit is reported three times.
13
+ */
14
+ const MIN_INTERVAL_SEC = 0.074;
15
+ /**
16
+ * Half-width of the peak-picking window, in hops (±40 ms).
17
+ *
18
+ * A candidate must be the largest onset within this window. Wider merges
19
+ * genuinely separate kicks at fast tempos; narrower lets a ringing tail count
20
+ * as its own peak, which is how one hit became two beats 64 ms apart in the
21
+ * first working version.
22
+ */
23
+ const PEAK_WINDOW_HOPS = 8;
24
+ /**
25
+ * Where a transient is considered to have *started*, as a fraction of its peak.
26
+ *
27
+ * Reporting the peak itself is late and, worse, late by an amount that varies
28
+ * with how sharp the hit is — soft kicks land tens of milliseconds behind hard
29
+ * ones, so an edit drifts against its own music. Walking back to a fixed
30
+ * fraction of the peak gives the same reference point for every hit.
31
+ */
32
+ const ONSET_BACKTRACK = 0.35;
33
+ /**
34
+ * Minimum strength for a beat to be reported at all.
35
+ *
36
+ * A transient that barely clears its own adaptive floor is indistinguishable
37
+ * from the track breathing, and its *timing* is correspondingly vague — the
38
+ * peak is broad, so the reported instant wanders. Nothing downstream would cut
39
+ * on one anyway, since the director weights by strength.
40
+ */
41
+ const MIN_STRENGTH = 0.03;
42
+ /** Plausible tempo range. Outside it the estimate is a harmonic, not a tempo. */
43
+ const MIN_BPM = 60;
44
+ const MAX_BPM = 200;
45
+ /*
46
+ * The smoothing, whitening, floors and shape gates moved to `kickCore.ts`, which `kickDetector`
47
+ * now shares with this file. They were the same numbers in both, applied at different rates and
48
+ * to a different whitening — see that module's header. The hop below is the step they were tuned
49
+ * at and is what the core's own rates are derived from, so nothing about this file's output
50
+ * moved when they left it.
51
+ */
52
+ /** How much of each masking band is subtracted from the kick band. */
53
+ /**
54
+ * Settling time before any beat may be reported.
55
+ *
56
+ * Every filter and running mean starts at zero, so the first hop of any track
57
+ * looks like an enormous transient against a floor of nothing — a guaranteed
58
+ * false beat at the start of every song, including one that opens with four
59
+ * bars of silence. The curves are built over a warm-up prefix first and that
60
+ * prefix is then discarded, so a genuine downbeat at 0 is still found.
61
+ */
62
+ const WARMUP_SEC = 0.5;
63
+ /** A map with no beats in it, for when there is no music to analyse. */
64
+ export function emptyBeatMap(durationSec = 0) {
65
+ return {
66
+ beats: new Float32Array(0),
67
+ strength: new Float32Array(0),
68
+ bpm: 0,
69
+ bpmConfidence: 0,
70
+ energy: new Float32Array(0),
71
+ energyHz: ENERGY_HZ,
72
+ durationSec,
73
+ };
74
+ }
75
+ export function analyseTrack(samples, sampleRate) {
76
+ const durationSec = samples.length / sampleRate;
77
+ const curves = buildCurves(samples, sampleRate);
78
+ const { beats, strength } = pickPeaks(curves);
79
+ const { bpm, confidence } = estimateTempo(beats);
80
+ return {
81
+ beats: Float32Array.from(beats),
82
+ strength: Float32Array.from(strength),
83
+ bpm,
84
+ bpmConfidence: confidence,
85
+ energy: curves.energy,
86
+ energyHz: ENERGY_HZ,
87
+ durationSec,
88
+ };
89
+ }
90
+ /**
91
+ * One pass over the samples, producing the curves peak-picking works on.
92
+ *
93
+ * Separated from peak-picking because they want opposite things: this is
94
+ * causal and streaming, that one needs to look forward and backward. Trying to
95
+ * do both at once is what produces a detector that fires on the wrong edge.
96
+ */
97
+ function buildCurves(samples, sampleRate) {
98
+ const hopSamples = Math.max(1, Math.round(sampleRate * HOP_SEC));
99
+ const hops = Math.max(1, Math.floor(samples.length / hopSamples));
100
+ const warmupHops = Math.min(hops, Math.round(WARMUP_SEC / HOP_SEC));
101
+ const filters = createBandFilters(sampleRate);
102
+ const energies = createBandEnergies();
103
+ const onset = new Float32Array(hops);
104
+ const floor = new Float32Array(hops);
105
+ const shaped = new Uint8Array(hops);
106
+ const energyHops = Math.max(1, Math.round(1 / (ENERGY_HZ * HOP_SEC)));
107
+ const energy = new Float32Array(Math.max(1, Math.ceil(hops / energyHops)));
108
+ /* Every running mean, envelope and gate this analyser shares with the live detector. */
109
+ const core = new KickCore();
110
+ let energyAccum = 0;
111
+ let energyCount = 0;
112
+ let energyIndex = 0;
113
+ // Warm-up runs the same maths over the opening prefix and throws the results
114
+ // away, leaving the filters and running means settled for the real pass.
115
+ for (let pass = 0; pass < warmupHops + hops; pass++) {
116
+ const settling = pass < warmupHops;
117
+ const hop = settling ? pass : pass - warmupHops;
118
+ const start = hop * hopSamples;
119
+ measureBands(samples, start, hopSamples, filters, energies);
120
+ const { sub, punch, sweet, bassline, mud, lowMid, high } = energies;
121
+ if (!settling) {
122
+ energyAccum += sub + punch + bassline + mud + lowMid + high;
123
+ energyCount++;
124
+ if (energyCount >= energyHops && energyIndex < energy.length) {
125
+ energy[energyIndex++] = energyAccum / energyCount;
126
+ energyAccum = 0;
127
+ energyCount = 0;
128
+ }
129
+ }
130
+ /*
131
+ * The shared decision: whitening, onset, flux, the adaptive floors and the shape gates, all
132
+ * in `kickCore.ts` so that a kick means the same thing here and in `kickDetector.ts`. The
133
+ * hop is what the core smooths over, which is what its own constants were tuned at.
134
+ */
135
+ core.step(energies, HOP_SEC);
136
+ if (settling)
137
+ continue;
138
+ /*
139
+ * The offline path's own extra condition on top of the shared shape gates: the whitened
140
+ * level has to clear its own floor as well. Live, that is one of the two threshold tests
141
+ * `KickCore.rising` makes; here it belongs with the shape because peak picking applies the
142
+ * onset threshold itself, a few lines further on, against a curve it can look along.
143
+ */
144
+ const kickLike = core.shaped && core.whitened > core.energyFloor;
145
+ onset[hop] = core.onset;
146
+ floor[hop] = core.onsetFloor;
147
+ shaped[hop] = kickLike ? 1 : 0;
148
+ }
149
+ if (energyCount > 0 && energyIndex < energy.length) {
150
+ energy[energyIndex] = energyAccum / energyCount;
151
+ }
152
+ return { onset, floor, shaped, energy, hopSec: HOP_SEC };
153
+ }
154
+ /**
155
+ * Turn the onset curve into beat times.
156
+ *
157
+ * A beat is a local maximum of the onset that clears its adaptive floor and is
158
+ * shaped like a kick. Reporting the peak itself would be late by an amount that
159
+ * varies with how sharp the hit is, so the time is walked back to where the
160
+ * transient crossed a fixed fraction of that peak — the same reference point
161
+ * for a soft kick and a hard one.
162
+ */
163
+ function pickPeaks(curves) {
164
+ const { onset, floor, shaped, hopSec } = curves;
165
+ const beats = [];
166
+ const strength = [];
167
+ let lastBeatSec = -Infinity;
168
+ for (let hop = 0; hop < onset.length; hop++) {
169
+ if (shaped[hop] !== 1)
170
+ continue;
171
+ const value = onset[hop] ?? 0;
172
+ const threshold = floor[hop] ?? 0;
173
+ if (value <= threshold)
174
+ continue;
175
+ // Must be the largest onset nearby, or a ringing tail counts as its own hit.
176
+ let isPeak = true;
177
+ const from = Math.max(0, hop - PEAK_WINDOW_HOPS);
178
+ const to = Math.min(onset.length - 1, hop + PEAK_WINDOW_HOPS);
179
+ for (let i = from; i <= to; i++) {
180
+ if (i === hop)
181
+ continue;
182
+ const other = onset[i] ?? 0;
183
+ // Ties go to the earlier hop, so a plateau reports its leading edge.
184
+ if (other > value || (other === value && i < hop)) {
185
+ isPeak = false;
186
+ break;
187
+ }
188
+ }
189
+ if (!isPeak)
190
+ continue;
191
+ // Walk back to the start of the rise.
192
+ let onsetHop = hop;
193
+ const target = value * ONSET_BACKTRACK;
194
+ while (onsetHop > from && (onset[onsetHop - 1] ?? 0) > target)
195
+ onsetHop--;
196
+ const atSec = onsetHop * hopSec;
197
+ if (atSec - lastBeatSec <= MIN_INTERVAL_SEC)
198
+ continue;
199
+ // How far past its own floor the transient reached, which scales with the
200
+ // track rather than with an absolute level.
201
+ const power = Math.min(1, (value - threshold) / Math.max(1e-6, threshold * 4));
202
+ if (power < MIN_STRENGTH)
203
+ continue;
204
+ lastBeatSec = atSec;
205
+ beats.push(atSec);
206
+ strength.push(power);
207
+ }
208
+ return { beats, strength };
209
+ }
210
+ /**
211
+ * The most beats a gap between two reported ones may span, and how near a whole
212
+ * number of beats it has to land to count as one.
213
+ */
214
+ const MAX_SPANNED_BEATS = 4;
215
+ const SPAN_TOLERANCE = 0.12;
216
+ /**
217
+ * How many beats an interval spans, or 0 when it is not a whole number of them.
218
+ *
219
+ * The analyser reports the hits it is sure of, so a gap between two of them is
220
+ * one beat or several, and deciding which is a single question asked in one
221
+ * place: the tempo estimate folds an interval down by this number, and the
222
+ * confidence count tests regularity with it. Two copies of the rule would agree
223
+ * until the first time either was tuned.
224
+ */
225
+ function spannedBeats(interval, beat) {
226
+ const ratio = interval / beat;
227
+ const nearest = Math.round(ratio);
228
+ if (nearest < 1 || nearest > MAX_SPANNED_BEATS)
229
+ return 0;
230
+ return Math.abs(ratio - nearest) <= SPAN_TOLERANCE ? nearest : 0;
231
+ }
232
+ /**
233
+ * Tempo from the gaps between the beats that were reported.
234
+ *
235
+ * The reported beats are the hits the analyser is sure of and deliberately not
236
+ * every beat in the track, so a gap between two of them is a whole number of
237
+ * beats rather than one. Both steps below follow from that: the beat is chosen
238
+ * as the gap that accounts for the most others, and every gap is then folded
239
+ * down to a single beat before the median is taken.
240
+ *
241
+ * A statistic over the raw gaps cannot work however it is chosen, and a mean is
242
+ * not the only thing this rules out. The gaps form one cluster per number of
243
+ * beats skipped, so a mean sits between clusters and a median sits at the edge
244
+ * of one — neither lands on a beat, and the error grows with the share of
245
+ * beats missed rather than staying small.
246
+ *
247
+ * Confidence is the share of gaps that are a whole number of beats. Low
248
+ * confidence means the hits are real but do not lie on one pulse, which is a
249
+ * signal a caller should use rather than an error — and it is also the honest
250
+ * reading when the track has no single tempo.
251
+ *
252
+ * **What this cannot do**: separate a track counted at every other kick from
253
+ * one played at half the speed. Their gaps are identical, so nothing here can
254
+ * tell them apart, and such a track is reported at half its tempo with full
255
+ * confidence. It takes alternate kicks about 15 dB down to reach that state.
256
+ * The evidence that would settle it is not in the beat list and is not usably
257
+ * in the onset curve either — measured, the missing kick sits below the floor
258
+ * that would report it; `docs/IMPROVEMENTS.md` carries both numbers.
259
+ */
260
+ function estimateTempo(beats) {
261
+ if (beats.length < 3)
262
+ return { bpm: 0, confidence: 0 };
263
+ const intervals = [];
264
+ for (let i = 1; i < beats.length; i++) {
265
+ intervals.push((beats[i] ?? 0) - (beats[i - 1] ?? 0));
266
+ }
267
+ const sorted = [...intervals].sort((a, b) => a - b);
268
+ /*
269
+ * The beat is the gap that accounts for the most other gaps.
270
+ *
271
+ * Taking the middle of the set instead is what this replaces, and it is wrong
272
+ * for a reason no amount of tuning reaches: every interval is a whole number
273
+ * of beats, so the set has one cluster per number of beats skipped, and its
274
+ * middle falls at the top of one cluster or the foot of the next rather than
275
+ * on a beat. Measured on synthetic tracks whose kicks are two in three, that
276
+ * put four tempos of six at *exactly half* their true value, and a fully
277
+ * detected 140 counted 2.6% slow.
278
+ *
279
+ * Scoring every observed gap as a candidate is what makes it robust rather
280
+ * than merely better placed. A single unrepresentative gap — a ghost hit
281
+ * close behind a real one, or one straddling a tempo change — explains
282
+ * nothing but itself and loses; picking a fixed quantile cannot tell the two
283
+ * apart, and a 128 read that way came out at 112 off one gap of 1.14 beats.
284
+ *
285
+ * Ties go to the longer candidate: within one cluster the choice moves the
286
+ * base by less than the tolerance and the median below settles the value.
287
+ *
288
+ * The cost is a pass over the gaps per gap. This runs once per track, off the
289
+ * frame loop, and a ten-minute track at 174 counts under two million steps.
290
+ * What would make it wrong is calling it per frame, which is what the live
291
+ * detector exists for.
292
+ */
293
+ let base = 0;
294
+ let explainedByBase = -1;
295
+ for (const candidate of sorted) {
296
+ if (candidate <= 0)
297
+ continue;
298
+ let explained = 0;
299
+ for (const interval of intervals) {
300
+ if (spannedBeats(interval, candidate) > 0)
301
+ explained++;
302
+ }
303
+ if (explained >= explainedByBase) {
304
+ explainedByBase = explained;
305
+ base = candidate;
306
+ }
307
+ }
308
+ if (base <= 0)
309
+ return { bpm: 0, confidence: 0 };
310
+ /*
311
+ * The base decides only how the gaps group; the median of what they fold to
312
+ * decides the tempo, so a base a hop or two off its cluster costs nothing.
313
+ */
314
+ const folded = [];
315
+ for (const interval of intervals) {
316
+ const spanned = spannedBeats(interval, base);
317
+ if (spanned > 0)
318
+ folded.push(interval / spanned);
319
+ }
320
+ folded.sort((a, b) => a - b);
321
+ const median = folded[folded.length >> 1] ?? base;
322
+ if (median <= 0)
323
+ return { bpm: 0, confidence: 0 };
324
+ let bpm = 60 / median;
325
+ // Fold octave errors back into a plausible range: catching every other kick
326
+ // reads as half tempo, and catching both hits of a double reads as twice it.
327
+ while (bpm < MIN_BPM)
328
+ bpm *= 2;
329
+ while (bpm > MAX_BPM)
330
+ bpm /= 2;
331
+ /*
332
+ * Confidence counts intervals that are a whole multiple of the median, not
333
+ * only ones equal to it. A detector that misses the occasional quiet kick
334
+ * leaves a double-length gap, and that is still perfectly regular — treating
335
+ * it as disagreement would report a steady track as rubato.
336
+ */
337
+ let regular = 0;
338
+ for (const interval of intervals) {
339
+ if (spannedBeats(interval, median) > 0)
340
+ regular++;
341
+ }
342
+ return { bpm, confidence: regular / intervals.length };
343
+ }
344
+ function onePole(cutoffHz, sampleRate) {
345
+ // Clamped below Nyquist so a high band on a low sample rate degrades to a
346
+ // pass-through instead of going unstable.
347
+ const x = Math.exp((-2 * Math.PI * Math.min(cutoffHz, sampleRate * 0.49)) / sampleRate);
348
+ return 1 - x;
349
+ }
350
+ function makeFilter(lowHz, highHz, sampleRate) {
351
+ return {
352
+ lowA: onePole(highHz, sampleRate),
353
+ highA: onePole(lowHz, sampleRate),
354
+ low: 0,
355
+ high: 0,
356
+ };
357
+ }
358
+ function createBandFilters(sampleRate) {
359
+ const b = RHYTHM_BANDS;
360
+ return {
361
+ sub: makeFilter(b.sub.lowHz, b.sub.highHz, sampleRate),
362
+ punch: makeFilter(b.punch.lowHz, b.punch.highHz, sampleRate),
363
+ sweet: makeFilter(b.sweet.lowHz, b.sweet.highHz, sampleRate),
364
+ bassline: makeFilter(b.bassline.lowHz, b.bassline.highHz, sampleRate),
365
+ mud: makeFilter(b.mud.lowHz, b.mud.highHz, sampleRate),
366
+ lowMid: makeFilter(b.lowMid.lowHz, b.lowMid.highHz, sampleRate),
367
+ high: makeFilter(b.high.lowHz, b.high.highHz, sampleRate),
368
+ };
369
+ }
370
+ /** RMS in each band over one hop, written into a caller-owned record. */
371
+ function measureBands(samples, start, count, filters, out) {
372
+ let sub = 0;
373
+ let punch = 0;
374
+ let sweet = 0;
375
+ let bassline = 0;
376
+ let mud = 0;
377
+ let lowMid = 0;
378
+ let high = 0;
379
+ const end = Math.min(start + count, samples.length);
380
+ for (let i = start; i < end; i++) {
381
+ const x = samples[i] ?? 0;
382
+ sub += step(filters.sub, x);
383
+ punch += step(filters.punch, x);
384
+ sweet += step(filters.sweet, x);
385
+ bassline += step(filters.bassline, x);
386
+ mud += step(filters.mud, x);
387
+ lowMid += step(filters.lowMid, x);
388
+ high += step(filters.high, x);
389
+ }
390
+ const inv = 1 / Math.max(1, end - start);
391
+ out.sub = Math.sqrt(sub * inv);
392
+ out.punch = Math.sqrt(punch * inv);
393
+ out.sweet = Math.sqrt(sweet * inv);
394
+ out.bassline = Math.sqrt(bassline * inv);
395
+ out.mud = Math.sqrt(mud * inv);
396
+ out.lowMid = Math.sqrt(lowMid * inv);
397
+ out.high = Math.sqrt(high * inv);
398
+ }
399
+ /** Advance one filter by one sample and return that sample's squared output. */
400
+ function step(filter, x) {
401
+ filter.low += (x - filter.low) * filter.lowA;
402
+ filter.high += (x - filter.high) * filter.highA;
403
+ const band = filter.low - filter.high;
404
+ return band * band;
405
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * What a kick is, in one place.
3
+ *
4
+ * Two analysers decide it: `beatMap.ts` offline, where an edit is cut, and `kickDetector.ts`
5
+ * live, where a light flashes. Their headers have always claimed the same bands, the same
6
+ * whitening and the same gates, because a replay whose cuts disagree with the lights in its own
7
+ * footage is the most confusing possible bug. They were two copies of that claim, and two copies
8
+ * drift: by the time this module was written they disagreed in two ways that a reader comparing
9
+ * the constants would not have seen.
10
+ *
11
+ * - **Whitening.** Offline it was `kickBand - mask`; live it was `rms * 0.6 + kickBand * 0.6 -
12
+ * mask`, a different quantity with a term the offline path has no equivalent for.
13
+ * - **Time.** Every envelope was a fixed per-*step* lerp, and the two step at different rates:
14
+ * a 5 ms hop offline, one call per rendered frame live. The same constant `0.1` is therefore
15
+ * a 47 ms time constant in one and a 158 ms time constant in the other, and live it also
16
+ * changed with the consumer's frame rate, so the same track detected differently on a 60 Hz
17
+ * screen and a 144 Hz one.
18
+ *
19
+ * So the shared part lives here and both call it. The envelopes take a `dtSec` and smooth in
20
+ * *time* rather than per step, which is what makes one set of constants mean one thing at any
21
+ * rate. The rates are derived from the offline hop the constants were tuned at, so the offline
22
+ * analyser is unchanged to the last bit and the live one moves onto its terms.
23
+ *
24
+ * What stays outside: how the bands are measured (an FFT hop offline, an `AnalyserNode` live),
25
+ * peak picking and tempo, which need to look forward, and the pulse envelope, which is a
26
+ * lighting concern rather than a detection one.
27
+ */
28
+ /** The seven band levels a kick is decided from, each 0 to 1. */
29
+ export interface KickBands {
30
+ readonly sub: number;
31
+ readonly punch: number;
32
+ readonly sweet: number;
33
+ readonly bassline: number;
34
+ readonly mud: number;
35
+ readonly lowMid: number;
36
+ readonly high: number;
37
+ }
38
+ /** Ignore a rise this soon after the last one — one hit is one pulse. */
39
+ export declare const MIN_INTERVAL_MS = 74;
40
+ /** Where a kick's fundamental may sit. Outside this it is not a kick drum. */
41
+ export declare const PEAK_MIN_HZ = 45;
42
+ export declare const PEAK_MAX_HZ = 95;
43
+ /**
44
+ * The running state both analysers keep, advanced one step at a time.
45
+ *
46
+ * Allocation-free after construction: `step` writes fields and returns nothing, because the live
47
+ * path calls it every frame.
48
+ */
49
+ export declare class KickCore {
50
+ private onsetFast;
51
+ private onsetSlow;
52
+ private lowBandMean;
53
+ private lowBandDev;
54
+ private transientMean;
55
+ private transientDev;
56
+ private prevSub;
57
+ private prevPunch;
58
+ /** The kick band with the bassline's contribution taken out of it. */
59
+ whitened: number;
60
+ /** How much of that arrived just now rather than being present. */
61
+ onset: number;
62
+ /** Energy that *arrived* in the kick bands, rather than energy that is there. */
63
+ flux: number;
64
+ /** The adaptive thresholds `onset` and `whitened` have to clear. */
65
+ onsetFloor: number;
66
+ energyFloor: number;
67
+ /** Whether the spectral shape is kick-like at all, before any threshold. */
68
+ shaped: boolean;
69
+ /**
70
+ * One step. `dtSec` is how long it covers, which is the hop offline and the frame time live.
71
+ *
72
+ * `peakHz` is the tracked fundamental where a caller has one; the offline path has no
73
+ * band-pass to track and passes the centre of the allowed range, which is the same as saying
74
+ * it does not use this gate.
75
+ */
76
+ step(bands: KickBands, dtSec: number, peakHz?: number): void;
77
+ /** Whether this step is a hit, ignoring how recently the last one was. */
78
+ get rising(): boolean;
79
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * What a kick is, in one place.
3
+ *
4
+ * Two analysers decide it: `beatMap.ts` offline, where an edit is cut, and `kickDetector.ts`
5
+ * live, where a light flashes. Their headers have always claimed the same bands, the same
6
+ * whitening and the same gates, because a replay whose cuts disagree with the lights in its own
7
+ * footage is the most confusing possible bug. They were two copies of that claim, and two copies
8
+ * drift: by the time this module was written they disagreed in two ways that a reader comparing
9
+ * the constants would not have seen.
10
+ *
11
+ * - **Whitening.** Offline it was `kickBand - mask`; live it was `rms * 0.6 + kickBand * 0.6 -
12
+ * mask`, a different quantity with a term the offline path has no equivalent for.
13
+ * - **Time.** Every envelope was a fixed per-*step* lerp, and the two step at different rates:
14
+ * a 5 ms hop offline, one call per rendered frame live. The same constant `0.1` is therefore
15
+ * a 47 ms time constant in one and a 158 ms time constant in the other, and live it also
16
+ * changed with the consumer's frame rate, so the same track detected differently on a 60 Hz
17
+ * screen and a 144 Hz one.
18
+ *
19
+ * So the shared part lives here and both call it. The envelopes take a `dtSec` and smooth in
20
+ * *time* rather than per step, which is what makes one set of constants mean one thing at any
21
+ * rate. The rates are derived from the offline hop the constants were tuned at, so the offline
22
+ * analyser is unchanged to the last bit and the live one moves onto its terms.
23
+ *
24
+ * What stays outside: how the bands are measured (an FFT hop offline, an `AnalyserNode` live),
25
+ * peak picking and tempo, which need to look forward, and the pulse envelope, which is a
26
+ * lighting concern rather than a detection one.
27
+ */
28
+ /**
29
+ * The step the constants below were tuned at: `beatMap.ts`'s own analysis hop.
30
+ *
31
+ * Every rate is derived from this, so a smoothing factor written as "per hop" keeps meaning
32
+ * exactly what it meant when it was chosen, and a caller stepping at any other interval gets the
33
+ * same curve in time rather than a different one.
34
+ */
35
+ const REFERENCE_STEP_SEC = 0.005;
36
+ /** A per-step factor at the reference hop, as a rate per second. */
37
+ function ratePerSecond(perStep) {
38
+ return -Math.log(1 - perStep) / REFERENCE_STEP_SEC;
39
+ }
40
+ /** Envelope smoothing, named for what it follows. Written per hop, applied in time. */
41
+ const ONSET_FAST_RATE = ratePerSecond(0.62);
42
+ const ONSET_SLOW_RATE = ratePerSecond(0.1);
43
+ const FLOOR_MEAN_RATE = ratePerSecond(0.012);
44
+ const FLOOR_DEV_RATE = ratePerSecond(0.04);
45
+ const TRANSIENT_MEAN_RATE = ratePerSecond(0.016);
46
+ const TRANSIENT_DEV_RATE = ratePerSecond(0.05);
47
+ /** How much of the low end a sustained bassline is allowed to explain away. */
48
+ const MASK_BASSLINE = 0.68;
49
+ const MASK_MUD = 0.22;
50
+ const MASK_LOW_MID = 0.08;
51
+ const MASK_SCALE = 0.55;
52
+ /** Floors, as a minimum and as a multiple of the signal's own deviation. */
53
+ const ENERGY_FLOOR_MIN = 0.0028;
54
+ const ENERGY_FLOOR_DEV = 1.05;
55
+ const ONSET_FLOOR_MIN = 0.0009;
56
+ const ONSET_FLOOR_DEV = 1.35;
57
+ /** Shape gates. Each rejects something loud that is not a kick. */
58
+ const PUNCH_OVER_BASSLINE = 0.55;
59
+ const SUB_OVER_MUD = 0.24;
60
+ const LOW_SHARE_MIN = 0.18;
61
+ const BASS_DOMINANCE_MIN = 0.55;
62
+ /** Ignore a rise this soon after the last one — one hit is one pulse. */
63
+ export const MIN_INTERVAL_MS = 74;
64
+ /** Where a kick's fundamental may sit. Outside this it is not a kick drum. */
65
+ export const PEAK_MIN_HZ = 45;
66
+ export const PEAK_MAX_HZ = 95;
67
+ /**
68
+ * How much of an envelope's remaining distance to cover in `dtSec`.
69
+ *
70
+ * Zero for a step that did not advance and one for a step long enough that whatever was held is
71
+ * stale, which is the right answer to a backgrounded tab: snap to what is true now rather than
72
+ * ease from a value that describes a minute ago.
73
+ */
74
+ function approach(rate, dtSec) {
75
+ if (!(dtSec > 0))
76
+ return 0;
77
+ return 1 - Math.exp(-rate * dtSec);
78
+ }
79
+ /**
80
+ * The running state both analysers keep, advanced one step at a time.
81
+ *
82
+ * Allocation-free after construction: `step` writes fields and returns nothing, because the live
83
+ * path calls it every frame.
84
+ */
85
+ export class KickCore {
86
+ onsetFast = 0;
87
+ onsetSlow = 0;
88
+ lowBandMean = 0;
89
+ lowBandDev = 0;
90
+ transientMean = 0;
91
+ transientDev = 0;
92
+ prevSub = 0;
93
+ prevPunch = 0;
94
+ /** The kick band with the bassline's contribution taken out of it. */
95
+ whitened = 0;
96
+ /** How much of that arrived just now rather than being present. */
97
+ onset = 0;
98
+ /** Energy that *arrived* in the kick bands, rather than energy that is there. */
99
+ flux = 0;
100
+ /** The adaptive thresholds `onset` and `whitened` have to clear. */
101
+ onsetFloor = 0;
102
+ energyFloor = 0;
103
+ /** Whether the spectral shape is kick-like at all, before any threshold. */
104
+ shaped = false;
105
+ /**
106
+ * One step. `dtSec` is how long it covers, which is the hop offline and the frame time live.
107
+ *
108
+ * `peakHz` is the tracked fundamental where a caller has one; the offline path has no
109
+ * band-pass to track and passes the centre of the allowed range, which is the same as saying
110
+ * it does not use this gate.
111
+ */
112
+ step(bands, dtSec, peakHz = (PEAK_MIN_HZ + PEAK_MAX_HZ) / 2) {
113
+ const { sub, punch, sweet, bassline, mud, lowMid, high } = bands;
114
+ /*
115
+ * Whitening: the kick band minus what a sustained bassline contributes to it. This is the
116
+ * step that turns "the low end is loud" — true for the whole track — into "something just
117
+ * hit", true for a few steps.
118
+ */
119
+ const kickBand = sub * 0.35 + punch * 0.45 + sweet * 0.2;
120
+ const mask = bassline * MASK_BASSLINE + mud * MASK_MUD + lowMid * MASK_LOW_MID;
121
+ this.whitened = Math.max(0, kickBand - mask * MASK_SCALE);
122
+ this.onsetFast += (this.whitened - this.onsetFast) * approach(ONSET_FAST_RATE, dtSec);
123
+ this.onsetSlow += (this.whitened - this.onsetSlow) * approach(ONSET_SLOW_RATE, dtSec);
124
+ this.onset = Math.max(0, this.onsetFast - this.onsetSlow);
125
+ /*
126
+ * A decaying kick has plenty of energy present and none arriving, which is what keeps one
127
+ * hit one beat.
128
+ */
129
+ this.flux = Math.max(0, sub - this.prevSub) * 0.58 + Math.max(0, punch - this.prevPunch) * 0.42;
130
+ this.prevSub = sub;
131
+ this.prevPunch = punch;
132
+ /*
133
+ * Adaptive floors, so the same analyser works on tracks mastered ten decibels apart — which
134
+ * matters the moment somebody imports their own.
135
+ */
136
+ this.lowBandMean += (this.whitened - this.lowBandMean) * approach(FLOOR_MEAN_RATE, dtSec);
137
+ this.lowBandDev +=
138
+ (Math.abs(this.whitened - this.lowBandMean) - this.lowBandDev) *
139
+ approach(FLOOR_DEV_RATE, dtSec);
140
+ const transientSignal = this.onset + this.flux * 0.18;
141
+ this.transientMean +=
142
+ (transientSignal - this.transientMean) * approach(TRANSIENT_MEAN_RATE, dtSec);
143
+ this.transientDev +=
144
+ (Math.abs(transientSignal - this.transientMean) - this.transientDev) *
145
+ approach(TRANSIENT_DEV_RATE, dtSec);
146
+ this.energyFloor =
147
+ this.lowBandMean + Math.max(ENERGY_FLOOR_MIN, this.lowBandDev * ENERGY_FLOOR_DEV);
148
+ this.onsetFloor =
149
+ this.transientMean + Math.max(ONSET_FLOOR_MIN, this.transientDev * ONSET_FLOOR_DEV);
150
+ const lowSum = sub + punch;
151
+ const lowShare = lowSum / Math.max(1e-6, lowSum + bassline + mud + lowMid + high);
152
+ const percussiveBody = lowMid * 0.72 + high * 0.58;
153
+ const bassDominance = (sub * 0.62 + punch * 0.38) / Math.max(1e-6, percussiveBody + mud * 0.45);
154
+ this.shaped =
155
+ punch > bassline * PUNCH_OVER_BASSLINE &&
156
+ sub > mud * SUB_OVER_MUD &&
157
+ lowShare > LOW_SHARE_MIN &&
158
+ bassDominance > BASS_DOMINANCE_MIN &&
159
+ peakHz >= PEAK_MIN_HZ &&
160
+ peakHz <= PEAK_MAX_HZ;
161
+ }
162
+ /** Whether this step is a hit, ignoring how recently the last one was. */
163
+ get rising() {
164
+ return this.shaped && this.onset > this.onsetFloor && this.whitened > this.energyFloor;
165
+ }
166
+ }