@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,197 @@
|
|
|
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 seven band levels a kick is decided from, each 0 to 1. */
|
|
30
|
+
export interface KickBands {
|
|
31
|
+
readonly sub: number;
|
|
32
|
+
readonly punch: number;
|
|
33
|
+
readonly sweet: number;
|
|
34
|
+
readonly bassline: number;
|
|
35
|
+
readonly mud: number;
|
|
36
|
+
readonly lowMid: number;
|
|
37
|
+
readonly high: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The step the constants below were tuned at: `beatMap.ts`'s own analysis hop.
|
|
42
|
+
*
|
|
43
|
+
* Every rate is derived from this, so a smoothing factor written as "per hop" keeps meaning
|
|
44
|
+
* exactly what it meant when it was chosen, and a caller stepping at any other interval gets the
|
|
45
|
+
* same curve in time rather than a different one.
|
|
46
|
+
*/
|
|
47
|
+
const REFERENCE_STEP_SEC = 0.005;
|
|
48
|
+
|
|
49
|
+
/** A per-step factor at the reference hop, as a rate per second. */
|
|
50
|
+
function ratePerSecond(perStep: number): number {
|
|
51
|
+
return -Math.log(1 - perStep) / REFERENCE_STEP_SEC;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Envelope smoothing, named for what it follows. Written per hop, applied in time. */
|
|
55
|
+
const ONSET_FAST_RATE = ratePerSecond(0.62);
|
|
56
|
+
const ONSET_SLOW_RATE = ratePerSecond(0.1);
|
|
57
|
+
const FLOOR_MEAN_RATE = ratePerSecond(0.012);
|
|
58
|
+
const FLOOR_DEV_RATE = ratePerSecond(0.04);
|
|
59
|
+
const TRANSIENT_MEAN_RATE = ratePerSecond(0.016);
|
|
60
|
+
const TRANSIENT_DEV_RATE = ratePerSecond(0.05);
|
|
61
|
+
|
|
62
|
+
/** How much of the low end a sustained bassline is allowed to explain away. */
|
|
63
|
+
const MASK_BASSLINE = 0.68;
|
|
64
|
+
const MASK_MUD = 0.22;
|
|
65
|
+
const MASK_LOW_MID = 0.08;
|
|
66
|
+
const MASK_SCALE = 0.55;
|
|
67
|
+
|
|
68
|
+
/** Floors, as a minimum and as a multiple of the signal's own deviation. */
|
|
69
|
+
const ENERGY_FLOOR_MIN = 0.0028;
|
|
70
|
+
const ENERGY_FLOOR_DEV = 1.05;
|
|
71
|
+
const ONSET_FLOOR_MIN = 0.0009;
|
|
72
|
+
const ONSET_FLOOR_DEV = 1.35;
|
|
73
|
+
|
|
74
|
+
/** Shape gates. Each rejects something loud that is not a kick. */
|
|
75
|
+
const PUNCH_OVER_BASSLINE = 0.55;
|
|
76
|
+
const SUB_OVER_MUD = 0.24;
|
|
77
|
+
const LOW_SHARE_MIN = 0.18;
|
|
78
|
+
const BASS_DOMINANCE_MIN = 0.55;
|
|
79
|
+
|
|
80
|
+
/** Ignore a rise this soon after the last one — one hit is one pulse. */
|
|
81
|
+
export const MIN_INTERVAL_MS = 74;
|
|
82
|
+
|
|
83
|
+
/** Where a kick's fundamental may sit. Outside this it is not a kick drum. */
|
|
84
|
+
export const PEAK_MIN_HZ = 45;
|
|
85
|
+
export const PEAK_MAX_HZ = 95;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How much of an envelope's remaining distance to cover in `dtSec`.
|
|
89
|
+
*
|
|
90
|
+
* Zero for a step that did not advance and one for a step long enough that whatever was held is
|
|
91
|
+
* stale, which is the right answer to a backgrounded tab: snap to what is true now rather than
|
|
92
|
+
* ease from a value that describes a minute ago.
|
|
93
|
+
*/
|
|
94
|
+
function approach(rate: number, dtSec: number): number {
|
|
95
|
+
if (!(dtSec > 0)) return 0;
|
|
96
|
+
return 1 - Math.exp(-rate * dtSec);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The running state both analysers keep, advanced one step at a time.
|
|
101
|
+
*
|
|
102
|
+
* Allocation-free after construction: `step` writes fields and returns nothing, because the live
|
|
103
|
+
* path calls it every frame.
|
|
104
|
+
*/
|
|
105
|
+
export class KickCore {
|
|
106
|
+
private onsetFast = 0;
|
|
107
|
+
private onsetSlow = 0;
|
|
108
|
+
private lowBandMean = 0;
|
|
109
|
+
private lowBandDev = 0;
|
|
110
|
+
private transientMean = 0;
|
|
111
|
+
private transientDev = 0;
|
|
112
|
+
private prevSub = 0;
|
|
113
|
+
private prevPunch = 0;
|
|
114
|
+
|
|
115
|
+
/** The kick band with the bassline's contribution taken out of it. */
|
|
116
|
+
whitened = 0;
|
|
117
|
+
/** How much of that arrived just now rather than being present. */
|
|
118
|
+
onset = 0;
|
|
119
|
+
/** Energy that *arrived* in the kick bands, rather than energy that is there. */
|
|
120
|
+
flux = 0;
|
|
121
|
+
/** The adaptive thresholds `onset` and `whitened` have to clear. */
|
|
122
|
+
onsetFloor = 0;
|
|
123
|
+
energyFloor = 0;
|
|
124
|
+
/** Whether the spectral shape is kick-like at all, before any threshold. */
|
|
125
|
+
shaped = false;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One step. `dtSec` is how long it covers, which is the hop offline and the frame time live.
|
|
129
|
+
*
|
|
130
|
+
* `peakHz` is the tracked fundamental where a caller has one; the offline path has no
|
|
131
|
+
* band-pass to track and passes the centre of the allowed range, which is the same as saying
|
|
132
|
+
* it does not use this gate.
|
|
133
|
+
*/
|
|
134
|
+
step(bands: KickBands, dtSec: number, peakHz: number = (PEAK_MIN_HZ + PEAK_MAX_HZ) / 2): void {
|
|
135
|
+
const { sub, punch, sweet, bassline, mud, lowMid, high } = bands;
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* Whitening: the kick band minus what a sustained bassline contributes to it. This is the
|
|
139
|
+
* step that turns "the low end is loud" — true for the whole track — into "something just
|
|
140
|
+
* hit", true for a few steps.
|
|
141
|
+
*/
|
|
142
|
+
const kickBand = sub * 0.35 + punch * 0.45 + sweet * 0.2;
|
|
143
|
+
const mask = bassline * MASK_BASSLINE + mud * MASK_MUD + lowMid * MASK_LOW_MID;
|
|
144
|
+
this.whitened = Math.max(0, kickBand - mask * MASK_SCALE);
|
|
145
|
+
|
|
146
|
+
this.onsetFast += (this.whitened - this.onsetFast) * approach(ONSET_FAST_RATE, dtSec);
|
|
147
|
+
this.onsetSlow += (this.whitened - this.onsetSlow) * approach(ONSET_SLOW_RATE, dtSec);
|
|
148
|
+
this.onset = Math.max(0, this.onsetFast - this.onsetSlow);
|
|
149
|
+
|
|
150
|
+
/*
|
|
151
|
+
* A decaying kick has plenty of energy present and none arriving, which is what keeps one
|
|
152
|
+
* hit one beat.
|
|
153
|
+
*/
|
|
154
|
+
this.flux = Math.max(0, sub - this.prevSub) * 0.58 + Math.max(0, punch - this.prevPunch) * 0.42;
|
|
155
|
+
this.prevSub = sub;
|
|
156
|
+
this.prevPunch = punch;
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
* Adaptive floors, so the same analyser works on tracks mastered ten decibels apart — which
|
|
160
|
+
* matters the moment somebody imports their own.
|
|
161
|
+
*/
|
|
162
|
+
this.lowBandMean += (this.whitened - this.lowBandMean) * approach(FLOOR_MEAN_RATE, dtSec);
|
|
163
|
+
this.lowBandDev +=
|
|
164
|
+
(Math.abs(this.whitened - this.lowBandMean) - this.lowBandDev) *
|
|
165
|
+
approach(FLOOR_DEV_RATE, dtSec);
|
|
166
|
+
|
|
167
|
+
const transientSignal = this.onset + this.flux * 0.18;
|
|
168
|
+
this.transientMean +=
|
|
169
|
+
(transientSignal - this.transientMean) * approach(TRANSIENT_MEAN_RATE, dtSec);
|
|
170
|
+
this.transientDev +=
|
|
171
|
+
(Math.abs(transientSignal - this.transientMean) - this.transientDev) *
|
|
172
|
+
approach(TRANSIENT_DEV_RATE, dtSec);
|
|
173
|
+
|
|
174
|
+
this.energyFloor =
|
|
175
|
+
this.lowBandMean + Math.max(ENERGY_FLOOR_MIN, this.lowBandDev * ENERGY_FLOOR_DEV);
|
|
176
|
+
this.onsetFloor =
|
|
177
|
+
this.transientMean + Math.max(ONSET_FLOOR_MIN, this.transientDev * ONSET_FLOOR_DEV);
|
|
178
|
+
|
|
179
|
+
const lowSum = sub + punch;
|
|
180
|
+
const lowShare = lowSum / Math.max(1e-6, lowSum + bassline + mud + lowMid + high);
|
|
181
|
+
const percussiveBody = lowMid * 0.72 + high * 0.58;
|
|
182
|
+
const bassDominance = (sub * 0.62 + punch * 0.38) / Math.max(1e-6, percussiveBody + mud * 0.45);
|
|
183
|
+
|
|
184
|
+
this.shaped =
|
|
185
|
+
punch > bassline * PUNCH_OVER_BASSLINE &&
|
|
186
|
+
sub > mud * SUB_OVER_MUD &&
|
|
187
|
+
lowShare > LOW_SHARE_MIN &&
|
|
188
|
+
bassDominance > BASS_DOMINANCE_MIN &&
|
|
189
|
+
peakHz >= PEAK_MIN_HZ &&
|
|
190
|
+
peakHz <= PEAK_MAX_HZ;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Whether this step is a hit, ignoring how recently the last one was. */
|
|
194
|
+
get rising(): boolean {
|
|
195
|
+
return this.shaped && this.onset > this.onsetFloor && this.whitened > this.energyFloor;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { RHYTHM_BANDS } from './bands.ts';
|
|
2
|
+
import { KickCore, MIN_INTERVAL_MS, PEAK_MAX_HZ, PEAK_MIN_HZ } from './kickCore.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Live kick detection, for things that react in the moment.
|
|
6
|
+
*
|
|
7
|
+
* The offline analyser in `beatMap.ts` is the one an edit is cut against,
|
|
8
|
+
* because it can look ahead. This is its counterpart for the running game,
|
|
9
|
+
* where being a few tens of milliseconds behind the transient is imperceptible
|
|
10
|
+
* and re-analysing a whole track every frame would be absurd.
|
|
11
|
+
*
|
|
12
|
+
* Same bands, same whitening, same gates — so "a kick" means one thing in both
|
|
13
|
+
* places. A replay whose cuts disagreed with the lights in its own footage
|
|
14
|
+
* would be the most confusing possible bug.
|
|
15
|
+
*
|
|
16
|
+
* That is now shared code rather than a claim: `kickCore.ts` holds the decision and both files
|
|
17
|
+
* call it. It was a claim until 2026-08-17, and the two had drifted apart in two ways while the
|
|
18
|
+
* constants still looked identical — see that module's header for what they were.
|
|
19
|
+
*
|
|
20
|
+
* Ported from a production music-analysis detector. Every buffer is allocated once;
|
|
21
|
+
* `update` runs per frame and must not allocate.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** How fast a pulse falls back to rest, per second. */
|
|
25
|
+
const PULSE_DECAY = 11;
|
|
26
|
+
|
|
27
|
+
/** Envelope of a pulse of `peak` strength, `seconds` after it fired. */
|
|
28
|
+
export function kickPulseAfter(peak: number, seconds: number): number {
|
|
29
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return peak;
|
|
30
|
+
const value = peak * Math.exp(-PULSE_DECAY * seconds);
|
|
31
|
+
return value < 1e-3 ? 0 : value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface KickDetectorNodes {
|
|
35
|
+
/** Full-spectrum analyser on the playing source. */
|
|
36
|
+
readonly wide: AnalyserNode;
|
|
37
|
+
/** Analyser at the end of a low-pass → tracking band-pass chain. */
|
|
38
|
+
readonly kick: AnalyserNode;
|
|
39
|
+
/** The tracking band-pass, whose centre follows the detected fundamental. */
|
|
40
|
+
readonly bandpass: BiquadFilterNode;
|
|
41
|
+
readonly context: BaseAudioContext;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PEAK_FOLLOW = 0.25;
|
|
45
|
+
const PEAK_FOLLOW_TIME = 0.02;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A decibel magnitude as linear amplitude.
|
|
49
|
+
*
|
|
50
|
+
* `getFloatFrequencyData` reports silence as `-Infinity`, which is 0 rather than a very small
|
|
51
|
+
* number, and every real bin is a negative decibel value.
|
|
52
|
+
*/
|
|
53
|
+
function decibelsToAmplitude(db: number): number {
|
|
54
|
+
return Number.isFinite(db) ? Math.pow(10, db / 20) : 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class KickDetector {
|
|
58
|
+
/*
|
|
59
|
+
* **Decibels, not the byte view, and this is a correctness matter rather than a precision
|
|
60
|
+
* one.** `getByteFrequencyData` maps the spectrum from `minDecibels` to `maxDecibels` onto 0
|
|
61
|
+
* to 255, which defaults to a window of -100 to -30 dB. Two things follow, and both were live
|
|
62
|
+
* for as long as this detector has existed. Everything above -30 dBFS pins at 255, which on a
|
|
63
|
+
* loud master is most of the low end for most of the track, so the bands this decided from
|
|
64
|
+
* were clipped flat and `energy` measured 1.000 in every frame of a real track. And what came
|
|
65
|
+
* back was a decibel scale where the shared arithmetic is tuned for linear amplitude: on a dB
|
|
66
|
+
* scale a near-silent bin reads about 0.28 rather than about 0.0001, so the masking, the
|
|
67
|
+
* floors and every ratio gate were comparing quantities they were never meant for. Gates like
|
|
68
|
+
* `punch > bassline * 0.55` are nearly always true once both sides are compressed into the
|
|
69
|
+
* same narrow band, which is a detector with its safeguards switched off.
|
|
70
|
+
*
|
|
71
|
+
* `getFloatFrequencyData` is the same data in dB with no window and no clipping, and
|
|
72
|
+
* `band()` converts it back to linear amplitude, which is what `beatMap.ts` measures and what
|
|
73
|
+
* `kickCore.ts` is tuned against.
|
|
74
|
+
*
|
|
75
|
+
* Explicitly backed by an ArrayBuffer: the analyser methods refuse a view that might be over
|
|
76
|
+
* shared memory, which is what a bare typed array widens to.
|
|
77
|
+
*/
|
|
78
|
+
private readonly wideFreq: Float32Array<ArrayBuffer>;
|
|
79
|
+
private readonly kickFreq: Float32Array<ArrayBuffer>;
|
|
80
|
+
private readonly ranges: Record<string, readonly [number, number]>;
|
|
81
|
+
private readonly kickBand: readonly [number, number];
|
|
82
|
+
|
|
83
|
+
private peakHz = 62;
|
|
84
|
+
/** The shared decision, stepped once per frame. See `kickCore.ts`. */
|
|
85
|
+
private readonly core = new KickCore();
|
|
86
|
+
private lastHitMs = Number.NEGATIVE_INFINITY;
|
|
87
|
+
private lastUpdateMs = 0;
|
|
88
|
+
/** Reused so `core.step` is handed one object rather than a fresh literal every frame. */
|
|
89
|
+
private readonly bands = { sub: 0, punch: 0, sweet: 0, bassline: 0, mud: 0, lowMid: 0, high: 0 };
|
|
90
|
+
|
|
91
|
+
/** 0–1, spiking on a kick and decaying back. What a light should follow. */
|
|
92
|
+
pulse = 0;
|
|
93
|
+
/** Smoothed low-end presence, for anything that wants level rather than hits. */
|
|
94
|
+
energy = 0;
|
|
95
|
+
/**
|
|
96
|
+
* 0–1, how much low end is arriving that the bassline does not explain.
|
|
97
|
+
*
|
|
98
|
+
* **The one to drive a visual from, and `energy` is usually not.** `energy` is the raw sum of
|
|
99
|
+
* the two lowest bands with a gain on it, which is honest about what it says and useless on
|
|
100
|
+
* anything mastered loud: on a bass-heavy master it reaches its own ceiling and stays there,
|
|
101
|
+
* so a consumer reading it draws a constant. Measured on a phonk track through this detector:
|
|
102
|
+
* `energy` sat at 1.000 in every frame of a two-minute sample.
|
|
103
|
+
*
|
|
104
|
+
* This is the whitened signal the detector already computes for its own gating — the level
|
|
105
|
+
* left after the bassline, the mud and the low mids are masked out of it — which is the part
|
|
106
|
+
* that actually moves with the kick rather than with how loud the track is. It keeps its
|
|
107
|
+
* dynamics on the same material where `energy` has none, and it is deliberately not scaled:
|
|
108
|
+
* it usually runs well under 0.25, and how far a consumer opens that up is a decision about
|
|
109
|
+
* its own picture rather than about the music.
|
|
110
|
+
*/
|
|
111
|
+
level = 0;
|
|
112
|
+
|
|
113
|
+
constructor(private readonly nodes: KickDetectorNodes) {
|
|
114
|
+
this.wideFreq = new Float32Array(nodes.wide.frequencyBinCount);
|
|
115
|
+
this.kickFreq = new Float32Array(nodes.kick.frequencyBinCount);
|
|
116
|
+
|
|
117
|
+
const nyquist = nodes.context.sampleRate / 2;
|
|
118
|
+
const bin = (hz: number, length: number): number =>
|
|
119
|
+
Math.max(0, Math.min(length, Math.floor((hz / nyquist) * length)));
|
|
120
|
+
|
|
121
|
+
const ranges: Record<string, readonly [number, number]> = {};
|
|
122
|
+
for (const [name, band] of Object.entries(RHYTHM_BANDS)) {
|
|
123
|
+
const from = bin(band.lowHz, this.wideFreq.length);
|
|
124
|
+
ranges[name] = [from, Math.max(from + 1, bin(band.highHz, this.wideFreq.length))];
|
|
125
|
+
}
|
|
126
|
+
this.ranges = ranges;
|
|
127
|
+
this.kickBand = [
|
|
128
|
+
bin(PEAK_MIN_HZ, this.kickFreq.length),
|
|
129
|
+
Math.max(1, bin(PEAK_MAX_HZ, this.kickFreq.length)),
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One frame. Reads the analysers and advances the pulse. */
|
|
134
|
+
update(nowMs: number): void {
|
|
135
|
+
const dtSec = this.lastUpdateMs === 0 ? 1 / 60 : (nowMs - this.lastUpdateMs) / 1000;
|
|
136
|
+
this.lastUpdateMs = nowMs;
|
|
137
|
+
|
|
138
|
+
this.nodes.wide.getFloatFrequencyData(this.wideFreq);
|
|
139
|
+
this.nodes.kick.getFloatFrequencyData(this.kickFreq);
|
|
140
|
+
|
|
141
|
+
const bands = this.bands;
|
|
142
|
+
bands.sub = this.band('sub');
|
|
143
|
+
bands.punch = this.band('punch');
|
|
144
|
+
bands.sweet = this.band('sweet');
|
|
145
|
+
bands.bassline = this.band('bassline');
|
|
146
|
+
bands.mud = this.band('mud');
|
|
147
|
+
bands.lowMid = this.band('lowMid');
|
|
148
|
+
bands.high = this.band('high');
|
|
149
|
+
|
|
150
|
+
/*
|
|
151
|
+
* Follow the track's actual kick fundamental rather than assuming one.
|
|
152
|
+
* A 55 Hz 808 and an 80 Hz acoustic kick are both kicks, and a fixed
|
|
153
|
+
* band-pass tuned between them hears neither well.
|
|
154
|
+
*/
|
|
155
|
+
this.trackPeak();
|
|
156
|
+
|
|
157
|
+
/*
|
|
158
|
+
* The decision itself, shared with `beatMap.ts` — see `kickCore.ts`. It takes `dtSec`, which
|
|
159
|
+
* is what makes this detector behave the same whether a consumer calls it sixty times a
|
|
160
|
+
* second or a hundred and forty-four. It used to be a per-call lerp, so the same track was
|
|
161
|
+
* detected differently on different displays, and differently again from the offline
|
|
162
|
+
* analyser that the cuts of a replay are made against.
|
|
163
|
+
*/
|
|
164
|
+
this.core.step(bands, dtSec, this.peakHz);
|
|
165
|
+
|
|
166
|
+
const hit = this.core.rising && nowMs - this.lastHitMs > MIN_INTERVAL_MS;
|
|
167
|
+
const onset = this.core.onset;
|
|
168
|
+
const onsetFloor = this.core.onsetFloor;
|
|
169
|
+
const lowSum = bands.sub + bands.punch;
|
|
170
|
+
|
|
171
|
+
// Decay first, then let a hit override — so a hit always starts from full
|
|
172
|
+
// rather than from whatever was left of the previous one.
|
|
173
|
+
this.pulse = kickPulseAfter(this.pulse, dtSec);
|
|
174
|
+
if (hit) {
|
|
175
|
+
this.lastHitMs = nowMs;
|
|
176
|
+
this.pulse = Math.min(1, 0.55 + (onset - onsetFloor) / Math.max(1e-6, onsetFloor * 3));
|
|
177
|
+
}
|
|
178
|
+
this.energy = Math.min(1, lowSum * 1.6);
|
|
179
|
+
/* Clamped only so the documented range holds; the whitening is what keeps it well below 1
|
|
180
|
+
on real material, and that is the property a consumer is reading it for. */
|
|
181
|
+
this.level = Math.min(1, this.core.whitened);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The band's RMS amplitude, which is what `beatMap.ts` measures and what `kickCore.ts` is
|
|
186
|
+
* tuned against.
|
|
187
|
+
*
|
|
188
|
+
* **The sum of squares, not the mean of the magnitudes, and the difference is not cosmetic.**
|
|
189
|
+
* `beatMap` runs a filter bank over the samples and takes the root mean square of what comes
|
|
190
|
+
* out, so its numbers are the amplitude actually present in that band. A mean of per-bin
|
|
191
|
+
* magnitudes is that amplitude divided by however many bins the band happens to span, which
|
|
192
|
+
* changes with the FFT size and is a different quantity from the one every floor and gate here
|
|
193
|
+
* was tuned on. Measured on a real track through the site: a mean gave one detected kick in
|
|
194
|
+
* eight seconds, because the absolute floors sat far above a signal that had been divided by
|
|
195
|
+
* the width of its own band. Parseval is the relation that puts the two back on one scale.
|
|
196
|
+
*/
|
|
197
|
+
private band(name: string): number {
|
|
198
|
+
const range = this.ranges[name];
|
|
199
|
+
if (range === undefined) return 0;
|
|
200
|
+
let sum = 0;
|
|
201
|
+
for (let i = range[0]; i < range[1]; i++) {
|
|
202
|
+
const amplitude = decibelsToAmplitude(this.wideFreq[i] ?? -Infinity);
|
|
203
|
+
sum += amplitude * amplitude;
|
|
204
|
+
}
|
|
205
|
+
return Math.sqrt(sum);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Slide the band-pass toward whichever bin in the kick range is loudest. */
|
|
209
|
+
private trackPeak(): void {
|
|
210
|
+
let peakValue = -Infinity;
|
|
211
|
+
let peakIndex = this.kickBand[0];
|
|
212
|
+
for (let i = this.kickBand[0]; i < this.kickBand[1]; i++) {
|
|
213
|
+
/* Compared in decibels, which is monotonic in amplitude, so the loudest bin is the same
|
|
214
|
+
one either way and there is nothing to convert for an argmax. */
|
|
215
|
+
const v = this.kickFreq[i] ?? -Infinity;
|
|
216
|
+
if (v > peakValue) {
|
|
217
|
+
peakValue = v;
|
|
218
|
+
peakIndex = i;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!Number.isFinite(peakValue)) return;
|
|
222
|
+
|
|
223
|
+
const nyquist = this.nodes.context.sampleRate / 2;
|
|
224
|
+
const hz = (peakIndex / Math.max(1, this.kickFreq.length)) * nyquist;
|
|
225
|
+
const clamped = Math.min(Math.max(hz, PEAK_MIN_HZ), PEAK_MAX_HZ);
|
|
226
|
+
this.peakHz += (clamped - this.peakHz) * PEAK_FOLLOW;
|
|
227
|
+
this.nodes.bandpass.frequency.setTargetAtTime(
|
|
228
|
+
this.peakHz,
|
|
229
|
+
this.nodes.context.currentTime,
|
|
230
|
+
PEAK_FOLLOW_TIME,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { RHYTHM_BANDS } from './bands.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The kick envelope of an already-rendered mix, readable at any instant.
|
|
5
|
+
*
|
|
6
|
+
* `KickDetector` reacts to a live analyser, which is the only thing possible when the
|
|
7
|
+
* music has not been written yet. An offline clip render has the opposite problem and
|
|
8
|
+
* the better one: the score is a finished `AudioBuffer` before the first frame is drawn,
|
|
9
|
+
* so the pulse for frame `f` can be *read* at exactly `f/60` rather than chased.
|
|
10
|
+
*
|
|
11
|
+
* Which matters because without it an offline clip's lights would sit still while the
|
|
12
|
+
* live game's pulse with the music. A replay and an export have to carry the same
|
|
13
|
+
* timing, the same effects and the same flashing as the live run.
|
|
14
|
+
*
|
|
15
|
+
* The envelope is computed once, at a fixed hop, and read by interpolation — so the
|
|
16
|
+
* cost is one pass over the score instead of an analysis per frame, and two exports of
|
|
17
|
+
* the same run flash identically.
|
|
18
|
+
*
|
|
19
|
+
* Game-agnostic: a buffer in, a level out.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Analysis hop, seconds. About 5 ms, so a kick's attack lands in one or two windows. */
|
|
23
|
+
const HOP_SEC = 0.005;
|
|
24
|
+
/**
|
|
25
|
+
* How fast the envelope may fall, per second of level.
|
|
26
|
+
*
|
|
27
|
+
* A kick's *attack* is what a light answers to, so the reading rises instantly and
|
|
28
|
+
* decays: without that the flash ends the moment the transient does, which reads as a
|
|
29
|
+
* flicker rather than as a pulse. Six is a little under a fifth of a second to fall from
|
|
30
|
+
* full, which is the length the live detector's own envelope settles to.
|
|
31
|
+
*/
|
|
32
|
+
const DECAY_PER_SEC = 6;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resonance of each band-pass stage.
|
|
36
|
+
*
|
|
37
|
+
* Two is narrow enough to matter and wide enough to keep a kick's whole attack. The
|
|
38
|
+
* *cascade* is what does the work: one stage leaves a 140 Hz bassline about 12 dB down,
|
|
39
|
+
* which is not separation. Two measure 20 dB — an amplitude ratio of ten — so a bassline
|
|
40
|
+
* louder than the kick stops swamping it.
|
|
41
|
+
*/
|
|
42
|
+
const STAGE_Q = 2;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Band-limited energy per hop, over the whole signal in one pass.
|
|
46
|
+
*
|
|
47
|
+
* **One pass, with the filter state carried across hops.** The first version filtered
|
|
48
|
+
* each window from a fresh state, which makes every window boundary an impulse — and an
|
|
49
|
+
* impulse into a resonant filter rings at the filter's own frequency, so a pure 140 Hz
|
|
50
|
+
* bassline produced 66 Hz energy out of nothing. Measured: the bassline read 0.47 of the
|
|
51
|
+
* clip's peak between kicks, where it should have read a tenth of that. A filter is a
|
|
52
|
+
* thing with memory and cutting its memory up destroys what it is for.
|
|
53
|
+
*
|
|
54
|
+
* Centred on the geometric mean of `sub.lowHz` and `sweet.highHz`, so "a kick" means
|
|
55
|
+
* here what `RHYTHM_BANDS` means everywhere else in the engine — the same numbers drive
|
|
56
|
+
* the offline edit's cuts, and a light disagreeing with a cut about what a kick is would
|
|
57
|
+
* be a clip fighting itself.
|
|
58
|
+
*/
|
|
59
|
+
function kickEnergyPerHop(samples: Float32Array, rate: number, hop: number): Float32Array {
|
|
60
|
+
const centreHz = Math.sqrt(RHYTHM_BANDS.sub.lowHz * RHYTHM_BANDS.sweet.highHz);
|
|
61
|
+
const f = 2 * Math.sin((Math.PI * centreHz) / rate);
|
|
62
|
+
const q = 1 / STAGE_Q;
|
|
63
|
+
const count = Math.max(1, Math.ceil(samples.length / hop));
|
|
64
|
+
const levels = new Float32Array(count);
|
|
65
|
+
|
|
66
|
+
let lowA = 0;
|
|
67
|
+
let bandA = 0;
|
|
68
|
+
let lowB = 0;
|
|
69
|
+
let bandB = 0;
|
|
70
|
+
let sum = 0;
|
|
71
|
+
let bin = 0;
|
|
72
|
+
let taken = 0;
|
|
73
|
+
for (let i = 0; i < samples.length; i++) {
|
|
74
|
+
const input = samples[i] ?? 0;
|
|
75
|
+
const highA = input - lowA - q * bandA;
|
|
76
|
+
bandA += f * highA;
|
|
77
|
+
lowA += f * bandA;
|
|
78
|
+
// The second stage reads the first's band output: two poles become four.
|
|
79
|
+
const highB = bandA - lowB - q * bandB;
|
|
80
|
+
bandB += f * highB;
|
|
81
|
+
lowB += f * bandB;
|
|
82
|
+
sum += bandB * bandB;
|
|
83
|
+
taken++;
|
|
84
|
+
if (taken === hop) {
|
|
85
|
+
levels[bin] = Math.sqrt(sum / hop);
|
|
86
|
+
bin++;
|
|
87
|
+
sum = 0;
|
|
88
|
+
taken = 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (taken > 0 && bin < count) levels[bin] = Math.sqrt(sum / taken);
|
|
92
|
+
return levels;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class RenderedPulse {
|
|
96
|
+
private readonly levels: Float32Array;
|
|
97
|
+
private readonly hopSec: number;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param buffer The rendered mix. Channel 0 is read; a kick is not a stereo event.
|
|
101
|
+
*/
|
|
102
|
+
constructor(buffer: AudioBuffer) {
|
|
103
|
+
const rate = buffer.sampleRate;
|
|
104
|
+
const samples = buffer.getChannelData(0);
|
|
105
|
+
const hop = Math.max(1, Math.round(HOP_SEC * rate));
|
|
106
|
+
const raw = kickEnergyPerHop(samples, rate, hop);
|
|
107
|
+
const count = raw.length;
|
|
108
|
+
|
|
109
|
+
let peak = 0;
|
|
110
|
+
for (const level of raw) {
|
|
111
|
+
if (level > peak) peak = level;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* Normalised against the loudest moment in this clip, not against an absolute
|
|
116
|
+
* figure. A quiet track would otherwise never flash and a loud one would sit at
|
|
117
|
+
* full — and the light is a *reaction to the music*, which is relative by nature.
|
|
118
|
+
*/
|
|
119
|
+
const scale = peak > 1e-6 ? 1 / peak : 0;
|
|
120
|
+
const fall = (DECAY_PER_SEC * hop) / rate;
|
|
121
|
+
let envelope = 0;
|
|
122
|
+
for (let i = 0; i < count; i++) {
|
|
123
|
+
const level = (raw[i] ?? 0) * scale;
|
|
124
|
+
// Rise instantly, fall slowly: a light answers a kick's attack, not its energy.
|
|
125
|
+
envelope = level > envelope ? level : Math.max(level, envelope - fall);
|
|
126
|
+
raw[i] = envelope;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.levels = raw;
|
|
130
|
+
this.hopSec = hop / rate;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The pulse at `seconds`, 0–1.
|
|
135
|
+
*
|
|
136
|
+
* Interpolated between hops so a light moves smoothly at any frame rate rather than
|
|
137
|
+
* stepping at the analysis rate.
|
|
138
|
+
*/
|
|
139
|
+
at(seconds: number): number {
|
|
140
|
+
const position = Math.max(0, seconds) / this.hopSec;
|
|
141
|
+
const index = Math.floor(position);
|
|
142
|
+
const first = this.levels[Math.min(index, this.levels.length - 1)] ?? 0;
|
|
143
|
+
const second = this.levels[Math.min(index + 1, this.levels.length - 1)] ?? first;
|
|
144
|
+
const fraction = position - index;
|
|
145
|
+
return first + (second - first) * fraction;
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting an AudioContext, and keeping it when a device tries to take it away.
|
|
3
|
+
*
|
|
4
|
+
* Separate from the graph because it is a question about the browser rather than about this
|
|
5
|
+
* engine's mix. An application building its own audio outside `AudioGraph` needs exactly
|
|
6
|
+
* these two and none of the rest, which is why both are already on the package barrel.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The page's audio session, where the browser has one to give.
|
|
11
|
+
*
|
|
12
|
+
* WebKit-only, and not in the DOM lib, so it is described here structurally rather
|
|
13
|
+
* than asserted onto `Navigator`.
|
|
14
|
+
*/
|
|
15
|
+
interface AudioSessionLike {
|
|
16
|
+
type: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Claim the **playback** session, so a phone in a pocket is still audible.
|
|
21
|
+
*
|
|
22
|
+
* This is the whole of "no audio at all" on an iPhone that plays every other site,
|
|
23
|
+
* and it is a policy rather than a fault anywhere below. iOS files
|
|
24
|
+
* Web Audio under the *ambient* session, and an ambient session is exactly what the
|
|
25
|
+
* hardware ringer switch silences — while a `<video>` or an `<audio>` element is
|
|
26
|
+
* filed under playback and is not. So a page built out of `AudioContext`, which is
|
|
27
|
+
* every page this engine draws, is the one kind that goes completely quiet on a
|
|
28
|
+
* phone whose owner flicked one switch, with nothing wrong in the graph, the gesture
|
|
29
|
+
* or the files. Everything else on the web keeps working, which is what makes it
|
|
30
|
+
* read as a bug in the game.
|
|
31
|
+
*
|
|
32
|
+
* `playback` is the honest declaration for a game whose sound is a pillar: this
|
|
33
|
+
* page's audio *is* the point of it. It also means the page interrupts whatever else
|
|
34
|
+
* the phone was playing — which is the correct trade for a soundtrack that starts
|
|
35
|
+
* with the world, and the reason this is not claimed until a graph is actually being
|
|
36
|
+
* built.
|
|
37
|
+
*
|
|
38
|
+
* Safari-only today (Chrome and Firefox expose no `audioSession`), and every failure
|
|
39
|
+
* mode here is a no-op: a browser without the property, or one that refuses the
|
|
40
|
+
* value, is left exactly as it was.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Tell WebKit that this page's audio is the point of it, before any context exists.
|
|
44
|
+
*
|
|
45
|
+
* **Exported for a second consumer that cannot use `AudioGraph`.** This class is a stem
|
|
46
|
+
* player for a game soundtrack; an application that decodes one file and runs it through
|
|
47
|
+
* its own chain needs this line and not the graph, and a copy of it in another repository
|
|
48
|
+
* is a second version of an iOS behaviour that will be wrong the next time WebKit moves.
|
|
49
|
+
*
|
|
50
|
+
* The ordering is the part a copy gets wrong and the part a diff cannot show: claimed
|
|
51
|
+
* **before** the context is constructed, so the context is born into the right session.
|
|
52
|
+
* See `autoplay.test.ts` for what it is for: iOS puts Web Audio in the *ambient* session,
|
|
53
|
+
* which the ringer switch silences, so a page built out of `AudioContext` is the one kind
|
|
54
|
+
* that goes quiet in a pocket.
|
|
55
|
+
*/
|
|
56
|
+
export function claimPlaybackSession(): void {
|
|
57
|
+
if (typeof navigator === 'undefined') return;
|
|
58
|
+
const session = (navigator as Navigator & { audioSession?: AudioSessionLike }).audioSession;
|
|
59
|
+
if (session === undefined || session === null) return;
|
|
60
|
+
try {
|
|
61
|
+
session.type = 'playback';
|
|
62
|
+
} catch {
|
|
63
|
+
// A property that exists but will not take the value is not a failure to
|
|
64
|
+
// report: the graph below is built either way, at whatever volume the phone
|
|
65
|
+
// is willing to give it.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The live-context constructor this browser has, prefixed or not.
|
|
71
|
+
*
|
|
72
|
+
* `webkitAudioContext` is the only one on iOS before 14.5, and a browser old enough
|
|
73
|
+
* to need it is exactly the browser nobody testing this owns.
|
|
74
|
+
*/
|
|
75
|
+
export function audioContextConstructor(): typeof AudioContext | undefined {
|
|
76
|
+
if (typeof AudioContext !== 'undefined') return AudioContext;
|
|
77
|
+
const prefixed = (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
78
|
+
return prefixed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A name for a thrown thing, for the one telemetry string that reports silence. */
|
|
82
|
+
export function errorName(error: unknown): string {
|
|
83
|
+
return error instanceof Error ? error.name : 'unknown';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The long tail: seconds of impulse, and how fast it decays inside them.
|
|
88
|
+
*
|
|
89
|
+
* Long enough to carry a whole airborne moment — a jump is under a second, a glide
|
|
90
|
+
* several — and decaying slowly enough that the music is *spread out* rather than
|
|
91
|
+
* merely echoed. Beyond about eight seconds it stops sounding like a space and
|
|
92
|
+
* starts sounding like a stuck effect.
|
|
93
|
+
*/
|