@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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +11 -0
- package/dist/ambientLoop.d.ts +45 -0
- package/dist/ambientLoop.js +88 -0
- package/dist/audioHarness.d.ts +180 -0
- package/dist/audioHarness.js +244 -0
- package/dist/filters.d.ts +91 -0
- package/dist/filters.js +103 -0
- package/dist/formats.d.ts +18 -0
- package/dist/formats.js +19 -0
- package/dist/graph.d.ts +406 -0
- package/dist/graph.js +656 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +39 -0
- package/dist/manifest.d.ts +28 -0
- package/dist/manifest.js +71 -0
- package/dist/mix/bus.d.ts +203 -0
- package/dist/mix/bus.js +293 -0
- package/dist/mix/console.d.ts +96 -0
- package/dist/mix/console.js +131 -0
- package/dist/mix/defaultLayout.d.ts +37 -0
- package/dist/mix/defaultLayout.js +63 -0
- package/dist/mix/inserts.d.ts +64 -0
- package/dist/mix/inserts.js +187 -0
- package/dist/mix/returns.d.ts +38 -0
- package/dist/mix/returns.js +86 -0
- package/dist/mix/snapshot.d.ts +30 -0
- package/dist/mix/snapshot.js +55 -0
- package/dist/positional.d.ts +37 -0
- package/dist/positional.js +47 -0
- package/dist/registry.d.ts +91 -0
- package/dist/registry.js +128 -0
- package/dist/rhythm/bands.d.ts +60 -0
- package/dist/rhythm/bands.js +12 -0
- package/dist/rhythm/beatGrid.d.ts +32 -0
- package/dist/rhythm/beatGrid.js +98 -0
- package/dist/rhythm/beatMap.d.ts +42 -0
- package/dist/rhythm/beatMap.js +405 -0
- package/dist/rhythm/kickCore.d.ts +79 -0
- package/dist/rhythm/kickCore.js +166 -0
- package/dist/rhythm/kickDetector.d.ts +65 -0
- package/dist/rhythm/kickDetector.js +202 -0
- package/dist/rhythm/renderedPulse.d.ts +15 -0
- package/dist/rhythm/renderedPulse.js +138 -0
- package/dist/session.d.ts +62 -0
- package/dist/session.js +83 -0
- package/dist/spatial/ambisonic.d.ts +135 -0
- package/dist/spatial/ambisonic.js +299 -0
- package/dist/spatial/listener.d.ts +109 -0
- package/dist/spatial/listener.js +186 -0
- package/dist/spatial/occlusion.d.ts +39 -0
- package/dist/spatial/occlusion.js +92 -0
- package/dist/spatial/source.d.ts +185 -0
- package/dist/spatial/source.js +366 -0
- package/dist/spatial/zones.d.ts +129 -0
- package/dist/spatial/zones.js +166 -0
- package/dist/synth.d.ts +92 -0
- package/dist/synth.js +282 -0
- package/package.json +54 -0
- package/src/ambientLoop.ts +101 -0
- package/src/audioHarness.ts +280 -0
- package/src/filters.ts +109 -0
- package/src/formats.ts +22 -0
- package/src/graph.ts +805 -0
- package/src/index.ts +84 -0
- package/src/manifest.ts +73 -0
- package/src/mix/bus.ts +356 -0
- package/src/mix/console.ts +181 -0
- package/src/mix/defaultLayout.ts +118 -0
- package/src/mix/inserts.ts +242 -0
- package/src/mix/returns.ts +114 -0
- package/src/mix/snapshot.ts +75 -0
- package/src/positional.ts +47 -0
- package/src/registry.ts +167 -0
- package/src/rhythm/bands.ts +45 -0
- package/src/rhythm/beatGrid.ts +106 -0
- package/src/rhythm/beatMap.ts +514 -0
- package/src/rhythm/kickCore.ts +197 -0
- package/src/rhythm/kickDetector.ts +233 -0
- package/src/rhythm/renderedPulse.ts +147 -0
- package/src/session.ts +93 -0
- package/src/spatial/ambisonic.ts +358 -0
- package/src/spatial/listener.ts +249 -0
- package/src/spatial/occlusion.ts +95 -0
- package/src/spatial/source.ts +452 -0
- package/src/spatial/zones.ts +213 -0
- 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
|
+
}
|