@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,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
|
+
}
|