@driftengine/audio 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +11 -0
  4. package/dist/ambientLoop.d.ts +45 -0
  5. package/dist/ambientLoop.js +88 -0
  6. package/dist/audioHarness.d.ts +180 -0
  7. package/dist/audioHarness.js +244 -0
  8. package/dist/filters.d.ts +91 -0
  9. package/dist/filters.js +103 -0
  10. package/dist/formats.d.ts +18 -0
  11. package/dist/formats.js +19 -0
  12. package/dist/graph.d.ts +406 -0
  13. package/dist/graph.js +656 -0
  14. package/dist/index.d.ts +47 -0
  15. package/dist/index.js +39 -0
  16. package/dist/manifest.d.ts +28 -0
  17. package/dist/manifest.js +71 -0
  18. package/dist/mix/bus.d.ts +203 -0
  19. package/dist/mix/bus.js +293 -0
  20. package/dist/mix/console.d.ts +96 -0
  21. package/dist/mix/console.js +131 -0
  22. package/dist/mix/defaultLayout.d.ts +37 -0
  23. package/dist/mix/defaultLayout.js +63 -0
  24. package/dist/mix/inserts.d.ts +64 -0
  25. package/dist/mix/inserts.js +187 -0
  26. package/dist/mix/returns.d.ts +38 -0
  27. package/dist/mix/returns.js +86 -0
  28. package/dist/mix/snapshot.d.ts +30 -0
  29. package/dist/mix/snapshot.js +55 -0
  30. package/dist/positional.d.ts +37 -0
  31. package/dist/positional.js +47 -0
  32. package/dist/registry.d.ts +91 -0
  33. package/dist/registry.js +128 -0
  34. package/dist/rhythm/bands.d.ts +60 -0
  35. package/dist/rhythm/bands.js +12 -0
  36. package/dist/rhythm/beatGrid.d.ts +32 -0
  37. package/dist/rhythm/beatGrid.js +98 -0
  38. package/dist/rhythm/beatMap.d.ts +42 -0
  39. package/dist/rhythm/beatMap.js +405 -0
  40. package/dist/rhythm/kickCore.d.ts +79 -0
  41. package/dist/rhythm/kickCore.js +166 -0
  42. package/dist/rhythm/kickDetector.d.ts +65 -0
  43. package/dist/rhythm/kickDetector.js +202 -0
  44. package/dist/rhythm/renderedPulse.d.ts +15 -0
  45. package/dist/rhythm/renderedPulse.js +138 -0
  46. package/dist/session.d.ts +62 -0
  47. package/dist/session.js +83 -0
  48. package/dist/spatial/ambisonic.d.ts +135 -0
  49. package/dist/spatial/ambisonic.js +299 -0
  50. package/dist/spatial/listener.d.ts +109 -0
  51. package/dist/spatial/listener.js +186 -0
  52. package/dist/spatial/occlusion.d.ts +39 -0
  53. package/dist/spatial/occlusion.js +92 -0
  54. package/dist/spatial/source.d.ts +185 -0
  55. package/dist/spatial/source.js +366 -0
  56. package/dist/spatial/zones.d.ts +129 -0
  57. package/dist/spatial/zones.js +166 -0
  58. package/dist/synth.d.ts +92 -0
  59. package/dist/synth.js +282 -0
  60. package/package.json +54 -0
  61. package/src/ambientLoop.ts +101 -0
  62. package/src/audioHarness.ts +280 -0
  63. package/src/filters.ts +109 -0
  64. package/src/formats.ts +22 -0
  65. package/src/graph.ts +805 -0
  66. package/src/index.ts +84 -0
  67. package/src/manifest.ts +73 -0
  68. package/src/mix/bus.ts +356 -0
  69. package/src/mix/console.ts +181 -0
  70. package/src/mix/defaultLayout.ts +118 -0
  71. package/src/mix/inserts.ts +242 -0
  72. package/src/mix/returns.ts +114 -0
  73. package/src/mix/snapshot.ts +75 -0
  74. package/src/positional.ts +47 -0
  75. package/src/registry.ts +167 -0
  76. package/src/rhythm/bands.ts +45 -0
  77. package/src/rhythm/beatGrid.ts +106 -0
  78. package/src/rhythm/beatMap.ts +514 -0
  79. package/src/rhythm/kickCore.ts +197 -0
  80. package/src/rhythm/kickDetector.ts +233 -0
  81. package/src/rhythm/renderedPulse.ts +147 -0
  82. package/src/session.ts +93 -0
  83. package/src/spatial/ambisonic.ts +358 -0
  84. package/src/spatial/listener.ts +249 -0
  85. package/src/spatial/occlusion.ts +95 -0
  86. package/src/spatial/source.ts +452 -0
  87. package/src/spatial/zones.ts +213 -0
  88. package/src/synth.ts +351 -0
@@ -0,0 +1,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
+ */
@@ -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
+ */