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