@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
package/dist/graph.js
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import { AmbientLoop } from './ambientLoop.js';
|
|
2
|
+
import { KickDetector } from './rhythm/kickDetector.js';
|
|
3
|
+
import { SoundRegistry } from './registry.js';
|
|
4
|
+
import { LIFT_FLOOR_HZ, SLAM_ATTACK_SEC, SLAM_CLIP_KNEE, SLAM_CLOSED_HZ, SLAM_DECAY_SEC, SLAM_DRIVE, SLAM_DUCK, SLAM_OPEN_HZ, SLAM_SHELF_DB, SLAM_SHELF_HZ, clamp01, cutoffForSpeed, liftFrequencyHz, liftGainFor, } from './filters.js';
|
|
5
|
+
import { audioContextConstructor, claimPlaybackSession, errorName } from './session.js';
|
|
6
|
+
import { MixConsole } from './mix/console.js';
|
|
7
|
+
import { defaultLayout } from './mix/defaultLayout.js';
|
|
8
|
+
/** Smoothing for parameter moves, seconds. Long enough to never click. */
|
|
9
|
+
const RAMP = 0.08;
|
|
10
|
+
/**
|
|
11
|
+
* Fade applied before a source is stopped, seconds.
|
|
12
|
+
*
|
|
13
|
+
* Eight milliseconds. `BufferSource.stop()` lands wherever the waveform happens to be,
|
|
14
|
+
* and a step from mid-waveform to zero is a click — heard at the start line on every
|
|
15
|
+
* run, and *recorded at the clip's zero on every export*, because that is where the
|
|
16
|
+
* score is restarted — it was reported as a pop at the very beginning of every clip.
|
|
17
|
+
*
|
|
18
|
+
* Short enough that the transport still reads as stopping rather than fading, long
|
|
19
|
+
* enough that the discontinuity is gone: a click is broadband because it is
|
|
20
|
+
* instantaneous, and eight milliseconds puts its fastest component below where the ear
|
|
21
|
+
* hears a transient.
|
|
22
|
+
*/
|
|
23
|
+
const FADE_OUT = 0.008;
|
|
24
|
+
const LONG_REVERB_SECONDS = 6;
|
|
25
|
+
const LONG_REVERB_DECAY = 1.5;
|
|
26
|
+
/** Baseline delay feedback: one clear repeat, not a rhythm of its own. */
|
|
27
|
+
const DELAY_FEEDBACK = 0.34;
|
|
28
|
+
export class AudioGraph {
|
|
29
|
+
context;
|
|
30
|
+
registry;
|
|
31
|
+
/**
|
|
32
|
+
* The mix, as a tree of buses rather than as nodes held here.
|
|
33
|
+
*
|
|
34
|
+
* Everything below that used to be a field — the music and effects stages, the master filter, the
|
|
35
|
+
* lift, the slam, the three sends and their returns — is a bus or an insert now, and
|
|
36
|
+
* `defaultLayout` is where the shape they make is written down. What is left in this class is the
|
|
37
|
+
* transport: what is playing, from where, at what rate.
|
|
38
|
+
*/
|
|
39
|
+
mix;
|
|
40
|
+
layoutNodes;
|
|
41
|
+
/**
|
|
42
|
+
* The recording tap, created once.
|
|
43
|
+
*
|
|
44
|
+
* Cached because it used to be built per call and never taken down — the mix accumulated one
|
|
45
|
+
* `MediaStreamAudioDestinationNode` per export, each still pulling audio for the rest of the
|
|
46
|
+
* session. Ten exports while testing is ten of them, on the thread least able to absorb it and
|
|
47
|
+
* the one whose overrun is heard as a click.
|
|
48
|
+
*/
|
|
49
|
+
tap = null;
|
|
50
|
+
/**
|
|
51
|
+
* The player's own two levels, held here because a second graph built to render this mix has to
|
|
52
|
+
* be built at them.
|
|
53
|
+
*
|
|
54
|
+
* Mirrored rather than read back off the buses for the reason the buses themselves mirror: a
|
|
55
|
+
* level is *ramped*, and mid-ramp a parameter is somewhere between where it was and where it is
|
|
56
|
+
* going. A fade asking "back to the player's level" or a render asking "at what level" would both
|
|
57
|
+
* get whatever instant they happened to ask on.
|
|
58
|
+
*
|
|
59
|
+
* Which is also why `fadeMusic` does not write here. A fade is part of an edit, not a setting; it
|
|
60
|
+
* has to return to the setting when it is over.
|
|
61
|
+
*/
|
|
62
|
+
mixLevels;
|
|
63
|
+
/**
|
|
64
|
+
* A gain per live source, so a stem can be faded out without touching the stem's own level —
|
|
65
|
+
* which the *replacement* source is already connected to.
|
|
66
|
+
*/
|
|
67
|
+
sourceLevels = new Map();
|
|
68
|
+
/** Where scheduled work lands: an explicit instant, or null for "now". See `at`. */
|
|
69
|
+
atSec = null;
|
|
70
|
+
stemGains = [];
|
|
71
|
+
stemBuffers = [];
|
|
72
|
+
stemSources = [];
|
|
73
|
+
started = false;
|
|
74
|
+
/**
|
|
75
|
+
* Transport position, integrated rather than derived from elapsed wall time.
|
|
76
|
+
*
|
|
77
|
+
* The playback rate moves with the character, so three seconds of context time at
|
|
78
|
+
* rate 1.1 is 3.3 seconds of tape — and a caller that puts the track's beat zero
|
|
79
|
+
* on a start line needs to know where the tape actually is.
|
|
80
|
+
*/
|
|
81
|
+
positionSec = 0;
|
|
82
|
+
positionAt = 0;
|
|
83
|
+
rate = 1;
|
|
84
|
+
paused = false;
|
|
85
|
+
constructor(context, options) {
|
|
86
|
+
this.context = context;
|
|
87
|
+
this.registry = new SoundRegistry(options.fetchImpl);
|
|
88
|
+
this.mixLevels = {
|
|
89
|
+
music: clamp01(options.levels?.music ?? 1),
|
|
90
|
+
effects: clamp01(options.levels?.effects ?? 1),
|
|
91
|
+
};
|
|
92
|
+
/*
|
|
93
|
+
* The console is handed this graph's clock rather than keeping its own, so `at()` moves the
|
|
94
|
+
* whole mix and not only the transport. Two clocks would be two answers to "when", and offline
|
|
95
|
+
* the one that lost would put its moves on instant zero.
|
|
96
|
+
*/
|
|
97
|
+
this.mix = new MixConsole(context, {
|
|
98
|
+
scheduleAt: () => this.scheduleAt(),
|
|
99
|
+
random: options.random,
|
|
100
|
+
});
|
|
101
|
+
this.layoutNodes = defaultLayout(this.mix, this.mixLevels);
|
|
102
|
+
for (let i = 0; i < options.stemCount; i++) {
|
|
103
|
+
const gain = context.createGain();
|
|
104
|
+
gain.gain.value = 0;
|
|
105
|
+
gain.connect(this.layoutNodes.music.input);
|
|
106
|
+
this.stemGains.push(gain);
|
|
107
|
+
this.stemBuffers.push(null);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** The mix this graph plays into, for a caller that wants a bus of its own. */
|
|
111
|
+
get console() {
|
|
112
|
+
return this.mix;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build a graph, or return null only if the browser has no audio to give.
|
|
116
|
+
*
|
|
117
|
+
* **Null means "this browser will not do audio at all", never "not yet".** The
|
|
118
|
+
* distinction is the whole of a bug that silenced audio on mobile devices, both
|
|
119
|
+
* Android and iOS: this used to `await context.resume()`
|
|
120
|
+
* inside the try, so a browser that *rejects* that call — which is what a
|
|
121
|
+
* rejection means when autoplay is blocked — threw a perfectly good graph into
|
|
122
|
+
* the `catch` and reported no audio. The caller latches its load so it happens
|
|
123
|
+
* once, so that null was permanent: silence for the session, with a `wake()`
|
|
124
|
+
* that had nothing left to wake.
|
|
125
|
+
*
|
|
126
|
+
* A suspended context is not a failure. Its clock does not advance, so nothing
|
|
127
|
+
* scheduled on it is missed, and `wake()` exists to start it on the first
|
|
128
|
+
* gesture. The resume is still *attempted* here, because when this is called
|
|
129
|
+
* from a gesture — or on a site the browser already trusts — it starts
|
|
130
|
+
* immediately and there is no reason to wait for a tap that already happened.
|
|
131
|
+
* It is just no longer awaited, and no longer fatal.
|
|
132
|
+
*
|
|
133
|
+
* Desktop cannot show you this. Chrome grants autoplay to a site its user keeps
|
|
134
|
+
* visiting, so on the machine this game is built on the context comes up
|
|
135
|
+
* already running. It only breaks on a device that has not earned that trust,
|
|
136
|
+
* which is every phone arriving from a share link.
|
|
137
|
+
*/
|
|
138
|
+
static async create(options) {
|
|
139
|
+
try {
|
|
140
|
+
// A context handed in is used as it is: an offline one has no `resume` to call
|
|
141
|
+
// and no gesture to wait for, and rendering starts when its owner says so.
|
|
142
|
+
const given = options.context;
|
|
143
|
+
if (given !== undefined)
|
|
144
|
+
return new AudioGraph(given, options);
|
|
145
|
+
// Before the context exists, so the context is born into the right session.
|
|
146
|
+
claimPlaybackSession();
|
|
147
|
+
const Ctor = audioContextConstructor();
|
|
148
|
+
if (Ctor === undefined) {
|
|
149
|
+
options.onUnavailable?.('no-audio-context');
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const context = new Ctor();
|
|
153
|
+
const graph = new AudioGraph(context, options);
|
|
154
|
+
graph.wake();
|
|
155
|
+
return graph;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
options.onUnavailable?.(`context-threw:${errorName(error)}`);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Whether the context is actually producing sound.
|
|
164
|
+
*
|
|
165
|
+
* A context built without a user gesture is `suspended`: nodes run, sources are
|
|
166
|
+
* scheduled, and nothing is heard. A caller that has something to say about that — an
|
|
167
|
+
* intro film with a score, say — needs to be able to ask.
|
|
168
|
+
*/
|
|
169
|
+
get audible() {
|
|
170
|
+
return this.live()?.state === 'running';
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Ask the browser to start the context, if it will.
|
|
174
|
+
*
|
|
175
|
+
* Safe to call from anywhere and safe to call repeatedly: outside a gesture the promise
|
|
176
|
+
* simply rejects, which is not an error state — it is the policy working. Anything
|
|
177
|
+
* already scheduled begins when it succeeds, because a suspended context's clock does
|
|
178
|
+
* not advance, so nothing is missed in the meantime.
|
|
179
|
+
*/
|
|
180
|
+
wake() {
|
|
181
|
+
const live = this.live();
|
|
182
|
+
if (live === null || live.state === 'running')
|
|
183
|
+
return;
|
|
184
|
+
void live.resume().catch(() => {
|
|
185
|
+
// Not allowed yet. The next gesture will try again.
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
loadStem(index, buffer) {
|
|
189
|
+
this.stemBuffers[index] = buffer;
|
|
190
|
+
}
|
|
191
|
+
/** Whether the stems are running. */
|
|
192
|
+
get playing() {
|
|
193
|
+
return this.started;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Start every stem at one scheduled instant.
|
|
197
|
+
*
|
|
198
|
+
* Layers must be sample-locked: started independently they drift apart by
|
|
199
|
+
* however long each `start()` call happened to take, and a bassline a few
|
|
200
|
+
* milliseconds off its drums is heard as flamming rather than as one track.
|
|
201
|
+
*/
|
|
202
|
+
start() {
|
|
203
|
+
if (this.started)
|
|
204
|
+
return;
|
|
205
|
+
this.started = true;
|
|
206
|
+
this.positionSec = 0;
|
|
207
|
+
this.launch(0);
|
|
208
|
+
}
|
|
209
|
+
/** Whether the transport is stopped mid-track, as opposed to not yet started. */
|
|
210
|
+
get held() {
|
|
211
|
+
return this.paused;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Stop the stems where they are.
|
|
215
|
+
*
|
|
216
|
+
* The tape stops — this is a *pause*, not a duck, and the distinction is what
|
|
217
|
+
* several rounds of feedback kept correcting toward: the pause itself was right, it
|
|
218
|
+
* only ever needed a longer tail of effects to keep the music in the background.
|
|
219
|
+
* So the source stops
|
|
220
|
+
* and the sends carry what was already in flight. Reverb and delay live downstream
|
|
221
|
+
* of the stems, so cutting the source is exactly what leaves a decaying tail
|
|
222
|
+
* behind, and `setLongReverbSend` is how far that tail reaches.
|
|
223
|
+
*
|
|
224
|
+
* Idempotent: the caller is a per-frame mix that knows a *state*, not an event.
|
|
225
|
+
*/
|
|
226
|
+
hold() {
|
|
227
|
+
if (!this.started || this.paused)
|
|
228
|
+
return;
|
|
229
|
+
this.advance();
|
|
230
|
+
this.stopSources();
|
|
231
|
+
this.paused = true;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Start the stems again from where `hold` left them.
|
|
235
|
+
*
|
|
236
|
+
* From where it left them, rather than from where the tape *would* have been: a
|
|
237
|
+
* pause that catches up is a jump cut, and on a long glide it is audible as the
|
|
238
|
+
* track skipping. The cost is that airborne time puts the score behind the route's
|
|
239
|
+
* bar grid — a real trade, taken deliberately, because the hold is felt on every
|
|
240
|
+
* jump and the grid is felt once at the start line.
|
|
241
|
+
*/
|
|
242
|
+
release() {
|
|
243
|
+
if (!this.started || !this.paused)
|
|
244
|
+
return;
|
|
245
|
+
this.paused = false;
|
|
246
|
+
this.launch(this.positionSec);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Stop the stems and start them again from the top.
|
|
250
|
+
*
|
|
251
|
+
* A `BufferSource` cannot be rewound — the spec makes it one-shot — so starting
|
|
252
|
+
* over means discarding the sources and creating new ones. That is cheap: a
|
|
253
|
+
* source node is a handle onto a buffer that is already decoded and already
|
|
254
|
+
* resident, and nothing about the graph downstream of it is rebuilt.
|
|
255
|
+
*
|
|
256
|
+
* Exists because a caller needs the track's beat zero to coincide with something
|
|
257
|
+
* in its own world. Left running instead, a loop's downbeats land somewhere
|
|
258
|
+
* different on every attempt.
|
|
259
|
+
*
|
|
260
|
+
* **Returns how long until beat zero is actually heard**, in seconds, because
|
|
261
|
+
* `launch` schedules a little ahead of now and a caller lining a picture up
|
|
262
|
+
* against the music needs that number rather than an assumption. Zero when
|
|
263
|
+
* nothing started.
|
|
264
|
+
*/
|
|
265
|
+
restart() {
|
|
266
|
+
this.stopSources();
|
|
267
|
+
this.started = false;
|
|
268
|
+
this.paused = false;
|
|
269
|
+
this.start();
|
|
270
|
+
return this.startsInSec;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Seconds until the scheduled start of whatever is playing, or 0 if it is
|
|
274
|
+
* already sounding.
|
|
275
|
+
*
|
|
276
|
+
* Reads the instant `launch` scheduled, which is the only authority on when the
|
|
277
|
+
* stems begin: everything else about the transport is a consequence of it.
|
|
278
|
+
*/
|
|
279
|
+
get startsInSec() {
|
|
280
|
+
if (!this.started || this.paused)
|
|
281
|
+
return 0;
|
|
282
|
+
return Math.max(0, this.positionAt - this.scheduleAt());
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Create one source per stem, all at the same scheduled instant and the same
|
|
286
|
+
* offset into the buffer.
|
|
287
|
+
*
|
|
288
|
+
* Layers must be sample-locked: started independently they drift apart by however
|
|
289
|
+
* long each `start()` call happened to take, and a bassline a few milliseconds off
|
|
290
|
+
* its drums is heard as flamming rather than as one track.
|
|
291
|
+
*/
|
|
292
|
+
launch(offsetSec) {
|
|
293
|
+
const at = this.scheduleAt() + this.startLead();
|
|
294
|
+
for (let i = 0; i < this.stemGains.length; i++) {
|
|
295
|
+
const buffer = this.stemBuffers[i];
|
|
296
|
+
const gain = this.stemGains[i];
|
|
297
|
+
if (buffer === undefined || buffer === null || gain === undefined)
|
|
298
|
+
continue;
|
|
299
|
+
const source = this.context.createBufferSource();
|
|
300
|
+
source.buffer = buffer;
|
|
301
|
+
source.loop = true;
|
|
302
|
+
source.playbackRate.value = this.rate;
|
|
303
|
+
// Through its own level, which is what `stopSources` fades. Straight into the
|
|
304
|
+
// stem's gain would mean fading the stem — and the source replacing it.
|
|
305
|
+
const level = this.context.createGain();
|
|
306
|
+
level.gain.value = 1;
|
|
307
|
+
source.connect(level);
|
|
308
|
+
level.connect(gain);
|
|
309
|
+
this.sourceLevels.set(source, level);
|
|
310
|
+
// Wrapped, because the stems loop: an offset past the end of the buffer is a
|
|
311
|
+
// silent source, which is a track that never comes back.
|
|
312
|
+
source.start(at, buffer.duration > 0 ? offsetSec % buffer.duration : 0);
|
|
313
|
+
this.stemSources.push(source);
|
|
314
|
+
}
|
|
315
|
+
this.positionSec = offsetSec;
|
|
316
|
+
this.positionAt = at;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Stop every stem, quietly. See `FADE_OUT` for why the fade is not optional.
|
|
320
|
+
*
|
|
321
|
+
* The sources are dropped from `stemSources` immediately but stay connected until
|
|
322
|
+
* their fade has run: disconnecting a node mid-fade is the same discontinuity this
|
|
323
|
+
* exists to remove.
|
|
324
|
+
*/
|
|
325
|
+
stopSources() {
|
|
326
|
+
const at = this.scheduleAt();
|
|
327
|
+
for (const source of this.stemSources) {
|
|
328
|
+
const level = this.sourceLevels.get(source);
|
|
329
|
+
this.sourceLevels.delete(source);
|
|
330
|
+
if (level !== undefined) {
|
|
331
|
+
level.gain.cancelScheduledValues(at);
|
|
332
|
+
level.gain.setValueAtTime(level.gain.value, at);
|
|
333
|
+
level.gain.linearRampToValueAtTime(0, at + FADE_OUT);
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
source.stop(at + FADE_OUT);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
// A source that has already ended throws on stop. Nothing to do about a
|
|
340
|
+
// node we were about to discard anyway.
|
|
341
|
+
}
|
|
342
|
+
this.release_(source, level);
|
|
343
|
+
}
|
|
344
|
+
this.stemSources.length = 0;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Let go of a faded-out source once it can no longer be heard.
|
|
348
|
+
*
|
|
349
|
+
* A timer rather than `onended`, because an offline render has no wall clock to fire
|
|
350
|
+
* one on and the nodes it leaves behind are discarded with the context anyway. Live,
|
|
351
|
+
* a handful of nodes for a fifth of a second is cheaper than a listener per source.
|
|
352
|
+
*/
|
|
353
|
+
release_(source, level) {
|
|
354
|
+
if (typeof setTimeout !== 'function')
|
|
355
|
+
return;
|
|
356
|
+
setTimeout(() => {
|
|
357
|
+
try {
|
|
358
|
+
source.disconnect();
|
|
359
|
+
level?.disconnect();
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
// Already gone; the graph was torn down under us.
|
|
363
|
+
}
|
|
364
|
+
}, (FADE_OUT + 0.2) * 1000);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Schedule everything that follows at `seconds` on this context's timeline, or at
|
|
368
|
+
* "now" when null.
|
|
369
|
+
*
|
|
370
|
+
* An offline render sets it once per frame and gets a mix whose every move lands
|
|
371
|
+
* where the picture is, exactly, with no clock involved. Live callers never touch it.
|
|
372
|
+
*/
|
|
373
|
+
at(seconds) {
|
|
374
|
+
this.atSec = seconds;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* The instant scheduled work lands on.
|
|
378
|
+
*
|
|
379
|
+
* One reader, so "now" exists in one place — and so an offline render can move it.
|
|
380
|
+
*/
|
|
381
|
+
scheduleAt() {
|
|
382
|
+
return this.atSec ?? this.context.currentTime;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* How far ahead the stems are launched, seconds.
|
|
386
|
+
*
|
|
387
|
+
* Live it is a lead, so the layers start sample-locked however long the calls take.
|
|
388
|
+
* Offline there is nothing to be late for: work scheduled at an exact instant is
|
|
389
|
+
* already sample-locked, and a lead would only push beat zero off the clip's zero.
|
|
390
|
+
*/
|
|
391
|
+
startLead() {
|
|
392
|
+
return this.atSec === null ? 0.06 : 0;
|
|
393
|
+
}
|
|
394
|
+
/** The context as a live one, or null when this graph is rendering offline. */
|
|
395
|
+
live() {
|
|
396
|
+
const context = this.context;
|
|
397
|
+
return typeof context.resume === 'function' && typeof context.state === 'string'
|
|
398
|
+
? context
|
|
399
|
+
: null;
|
|
400
|
+
}
|
|
401
|
+
/** Carry the transport position up to now at the rate it has been running at. */
|
|
402
|
+
advance() {
|
|
403
|
+
const now = this.context.currentTime;
|
|
404
|
+
if (this.started && !this.paused && now > this.positionAt) {
|
|
405
|
+
this.positionSec += (now - this.positionAt) * this.rate;
|
|
406
|
+
}
|
|
407
|
+
this.positionAt = now;
|
|
408
|
+
}
|
|
409
|
+
setStemGain(index, gain) {
|
|
410
|
+
this.ramp(this.stemGains[index]?.gain, Math.max(0, gain));
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* The layout this graph plays into: its buses, its inserts and its returns.
|
|
414
|
+
*
|
|
415
|
+
* **This is where the mix went in 3.0.0.** Every setter this class used to carry — the two
|
|
416
|
+
* volumes, the lift, the slam, the master filter, the three sends and the delay — is a method on
|
|
417
|
+
* a bus or an insert now, and `PORTING.md` maps them one for one. They were removed rather than
|
|
418
|
+
* left forwarding, because a shim that works forever is a second answer to every question the
|
|
419
|
+
* console already answers, and the two would drift the first time one of them grew a clamp.
|
|
420
|
+
*/
|
|
421
|
+
get layout() {
|
|
422
|
+
return this.layoutNodes;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* The two levels a player chose, read from the buses that hold them.
|
|
426
|
+
*
|
|
427
|
+
* Derived rather than mirrored, which it was until 3.0.0. A mirror is a second place the answer
|
|
428
|
+
* is decided, and the reason the old one existed — that a level is ramped, so mid-ramp the
|
|
429
|
+
* *parameter* is between two values — is answered by the bus itself keeping its own fader
|
|
430
|
+
* setting. A duck does not move it, which is the distinction `fadeMusic` needed a paragraph for.
|
|
431
|
+
*/
|
|
432
|
+
get levels() {
|
|
433
|
+
return { music: this.layoutNodes.music.level, effects: this.layoutNodes.effects.level };
|
|
434
|
+
}
|
|
435
|
+
setPlaybackRate(rate) {
|
|
436
|
+
const clamped = Math.min(Math.max(rate, 0.05), 2);
|
|
437
|
+
// Position first, then the new rate: the seconds already elapsed were played at
|
|
438
|
+
// the *old* rate, and crediting them at the new one loses the transport's place
|
|
439
|
+
// a little on every change — which is every frame the character accelerates.
|
|
440
|
+
this.advance();
|
|
441
|
+
this.rate = clamped;
|
|
442
|
+
for (const source of this.stemSources) {
|
|
443
|
+
this.ramp(source.playbackRate, clamped);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Start a looping environmental bed, silent until the caller gives it a
|
|
448
|
+
* level. Routed through the effects stage, so the effects slider governs the
|
|
449
|
+
* world's own sound and the music slider governs only the score.
|
|
450
|
+
*
|
|
451
|
+
* Returns null for a missing buffer, so a caller can create loops
|
|
452
|
+
* unconditionally and let an unresolved slot simply be silent.
|
|
453
|
+
*/
|
|
454
|
+
createLoop(buffer) {
|
|
455
|
+
if (buffer === undefined)
|
|
456
|
+
return null;
|
|
457
|
+
const source = this.context.createBufferSource();
|
|
458
|
+
source.buffer = buffer;
|
|
459
|
+
source.loop = true;
|
|
460
|
+
const gain = this.context.createGain();
|
|
461
|
+
gain.gain.value = 0;
|
|
462
|
+
// Stereo panning is absent in a few older engines. Direction is a nicety;
|
|
463
|
+
// hearing the fire at all is not, so fall back rather than skip the loop.
|
|
464
|
+
let panner = null;
|
|
465
|
+
if (typeof this.context.createStereoPanner === 'function') {
|
|
466
|
+
panner = this.context.createStereoPanner();
|
|
467
|
+
source.connect(panner);
|
|
468
|
+
panner.connect(gain);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
source.connect(gain);
|
|
472
|
+
}
|
|
473
|
+
gain.connect(this.layoutNodes.effects.input);
|
|
474
|
+
source.start();
|
|
475
|
+
/*
|
|
476
|
+
* The loop schedules against this graph's own instant, not the context's
|
|
477
|
+
* `currentTime`. Offline they are not the same thing: `currentTime` is zero
|
|
478
|
+
* for the whole time a render is being described, so a loop reading it would
|
|
479
|
+
* pile every level change in the clip onto instant zero. One closure per
|
|
480
|
+
* loop, built here at setup and never in a frame.
|
|
481
|
+
*/
|
|
482
|
+
return new AmbientLoop(() => this.scheduleAt(), source, gain, panner);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* A live kick detector listening to the music.
|
|
486
|
+
*
|
|
487
|
+
* Tapped off the music stage rather than the master bus, so it hears the
|
|
488
|
+
* track and not the game's own sound effects — a splash landing on the beat
|
|
489
|
+
* would otherwise read as a kick and flash the world.
|
|
490
|
+
*
|
|
491
|
+
* The taps are pure observers: nothing is connected onward from them, so
|
|
492
|
+
* inserting a detector cannot change what anyone hears.
|
|
493
|
+
*/
|
|
494
|
+
createKickDetector() {
|
|
495
|
+
try {
|
|
496
|
+
const wide = this.context.createAnalyser();
|
|
497
|
+
wide.fftSize = 1024;
|
|
498
|
+
wide.smoothingTimeConstant = 0.32;
|
|
499
|
+
const lowpass = this.context.createBiquadFilter();
|
|
500
|
+
lowpass.type = 'lowpass';
|
|
501
|
+
lowpass.frequency.value = 180;
|
|
502
|
+
lowpass.Q.value = 0.707;
|
|
503
|
+
const bandpass = this.context.createBiquadFilter();
|
|
504
|
+
bandpass.type = 'bandpass';
|
|
505
|
+
bandpass.frequency.value = 62;
|
|
506
|
+
bandpass.Q.value = 1.4;
|
|
507
|
+
const kick = this.context.createAnalyser();
|
|
508
|
+
kick.fftSize = 512;
|
|
509
|
+
kick.smoothingTimeConstant = 0.08;
|
|
510
|
+
this.layoutNodes.music.input.connect(wide);
|
|
511
|
+
this.layoutNodes.music.input.connect(lowpass);
|
|
512
|
+
lowpass.connect(bandpass);
|
|
513
|
+
bandpass.connect(kick);
|
|
514
|
+
return new KickDetector({ wide, kick, bandpass, context: this.context });
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// A browser that will not give us an analyser gets a game with steady
|
|
518
|
+
// lights, which is the same game.
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* A stream of everything the player is hearing, for a clip recording.
|
|
524
|
+
*
|
|
525
|
+
* Tapped off the mix rather than replacing the destination, so recording cannot
|
|
526
|
+
* silence the game — a clip that captures perfectly while the player hears
|
|
527
|
+
* nothing is a bug they would report as "the export broke the sound".
|
|
528
|
+
*
|
|
529
|
+
* **Off `out`, which is the whole mix, and not off `master`, which is the dry
|
|
530
|
+
* path.** The send returns rejoin downstream of the master filter, so a tap on
|
|
531
|
+
* `master` hears the track and the speed filter and nothing wet at all. See
|
|
532
|
+
* `out`.
|
|
533
|
+
*
|
|
534
|
+
* **The alignment of this against the video is not ours to fix, and that was
|
|
535
|
+
* measured rather than assumed.** A probe that flashed one frame white while
|
|
536
|
+
* scheduling a click at the same instant, decoded back out of the file, found the
|
|
537
|
+
* audio leading the picture by a mean of 51 ms in one run and 85 ms in the next, with
|
|
538
|
+
* `outputLatency` reporting 0.048 then 0.024. Feeding the tap through a delay does
|
|
539
|
+
* move it — a forced 200 ms landed at +132 ms, near one for one — but there is no
|
|
540
|
+
* constant to use: correcting by an unstable reading made a run worse, from -51 to
|
|
541
|
+
* -85. `MediaRecorder` aligns its tracks by when data reached it, and nothing here can
|
|
542
|
+
* see that. The offline path exists because it never asks this question.
|
|
543
|
+
*
|
|
544
|
+
* The same tap every time. Building one per recording left the last one
|
|
545
|
+
* connected and running.
|
|
546
|
+
*/
|
|
547
|
+
captureStream() {
|
|
548
|
+
if (this.tap !== null)
|
|
549
|
+
return this.tap.stream;
|
|
550
|
+
try {
|
|
551
|
+
// Only a live context can hand out a stream; an offline render has no listener
|
|
552
|
+
// to stream to and produces its buffer instead.
|
|
553
|
+
const live = this.live();
|
|
554
|
+
if (live === null || typeof live.createMediaStreamDestination !== 'function')
|
|
555
|
+
return null;
|
|
556
|
+
const tap = live.createMediaStreamDestination();
|
|
557
|
+
this.mix.out.connect(tap);
|
|
558
|
+
this.tap = tap;
|
|
559
|
+
return tap.stream;
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Fire a one-shot. Routed so it sits under the same master filter and sends.
|
|
567
|
+
*
|
|
568
|
+
* `pan` places it across the stereo field (-1 to 1); pass the result of
|
|
569
|
+
* `stereoPan`. Omitted, the sound is centred, which is right for anything
|
|
570
|
+
* that happens *to* the player rather than somewhere near them.
|
|
571
|
+
*/
|
|
572
|
+
play(buffer, gain = 1, pan = 0) {
|
|
573
|
+
if (buffer === undefined || gain <= 0)
|
|
574
|
+
return;
|
|
575
|
+
const source = this.context.createBufferSource();
|
|
576
|
+
source.buffer = buffer;
|
|
577
|
+
const level = this.context.createGain();
|
|
578
|
+
level.gain.value = gain;
|
|
579
|
+
source.connect(level);
|
|
580
|
+
if (pan !== 0 && typeof this.context.createStereoPanner === 'function') {
|
|
581
|
+
const panner = this.context.createStereoPanner();
|
|
582
|
+
panner.pan.value = Math.min(Math.max(pan, -1), 1);
|
|
583
|
+
level.connect(panner);
|
|
584
|
+
panner.connect(this.layoutNodes.effects.input);
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
level.connect(this.layoutNodes.effects.input);
|
|
588
|
+
}
|
|
589
|
+
source.start(this.scheduleAt());
|
|
590
|
+
}
|
|
591
|
+
dispose() {
|
|
592
|
+
for (const source of this.stemSources) {
|
|
593
|
+
try {
|
|
594
|
+
source.stop();
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
// Already stopped; nothing to undo.
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
this.stemSources.length = 0;
|
|
601
|
+
void this.live()?.close();
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Every parameter move is ramped. Assigning `.value` directly steps the
|
|
605
|
+
* signal, and a step in a gain or a filter cutoff is an audible click — which
|
|
606
|
+
* at sixty updates a second becomes a buzz rather than a mix.
|
|
607
|
+
*/
|
|
608
|
+
ramp(param, value) {
|
|
609
|
+
if (param === undefined)
|
|
610
|
+
return;
|
|
611
|
+
// `scheduleAt`, not `currentTime`: offline this is the frame's own instant, which
|
|
612
|
+
// is what puts the mix on the picture. `RAMP` is unchanged in both modes on
|
|
613
|
+
// purpose — the clip has to sound like the game, and the game sounds like this.
|
|
614
|
+
const at = this.scheduleAt();
|
|
615
|
+
param.cancelScheduledValues(at);
|
|
616
|
+
param.setTargetAtTime(value, at, RAMP);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* A synthesised impulse response: exponentially decaying noise.
|
|
621
|
+
*
|
|
622
|
+
* Not a real hall — a real one is a file, and files are what the registry is
|
|
623
|
+
* for. This exists so reverb works before any asset has been recorded, on the
|
|
624
|
+
* same principle as every other sound here.
|
|
625
|
+
*/
|
|
626
|
+
/**
|
|
627
|
+
* A soft clipper, transparent until it is driven and saturating hard after.
|
|
628
|
+
*
|
|
629
|
+
* `tanh` rather than a hard corner: a hard clip of a bass note is a square wave, and
|
|
630
|
+
* a square wave's odd harmonics march all the way up the spectrum as buzz. `tanh`
|
|
631
|
+
* rounds the corner, so what comes out is the second and third harmonic — which is
|
|
632
|
+
* what "driven" sounds like as opposed to "broken".
|
|
633
|
+
*
|
|
634
|
+
* Odd-length so there is a sample exactly at zero, which keeps silence silent.
|
|
635
|
+
*/
|
|
636
|
+
function softClipCurve() {
|
|
637
|
+
const samples = 2049;
|
|
638
|
+
const curve = new Float32Array(new ArrayBuffer(2049 * 4));
|
|
639
|
+
for (let i = 0; i < samples; i++) {
|
|
640
|
+
const x = (i / (samples - 1)) * 2 - 1;
|
|
641
|
+
curve[i] = Math.tanh(x * SLAM_CLIP_KNEE) / Math.tanh(SLAM_CLIP_KNEE);
|
|
642
|
+
}
|
|
643
|
+
return curve;
|
|
644
|
+
}
|
|
645
|
+
function impulseResponse(context, seconds, decay) {
|
|
646
|
+
const rate = context.sampleRate;
|
|
647
|
+
const length = Math.max(1, Math.floor(rate * seconds));
|
|
648
|
+
const buffer = context.createBuffer(2, length, rate);
|
|
649
|
+
for (let channel = 0; channel < 2; channel++) {
|
|
650
|
+
const data = buffer.getChannelData(channel);
|
|
651
|
+
for (let i = 0; i < length; i++) {
|
|
652
|
+
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** decay;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return buffer;
|
|
656
|
+
}
|