@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,118 @@
1
+ import type { MixLevels } from '../graph.ts';
2
+ import type { MixBus } from './bus.ts';
3
+ import type { MixConsole } from './console.ts';
4
+ import {
5
+ liftInsert,
6
+ masterFilterInsert,
7
+ slamInsert,
8
+ type LiftInsert,
9
+ type MasterFilterInsert,
10
+ type SlamInsert,
11
+ } from './inserts.ts';
12
+ import { convolverInsert, delayInsert, type DelayInsert } from './returns.ts';
13
+
14
+ /**
15
+ * The mix as it has always been, expressed in buses and inserts.
16
+ *
17
+ * ```
18
+ * music ─ [lift] ─ tap ─ [slam] ─┐
19
+ * └─ sends from tap ───────────┼→ master ─ [master lowpass] ─ out ─→ destination
20
+ * effects ─────────────────────── ┘ ↑
21
+ * reverb · longReverb · delay ────────────────────────────────────────┘
22
+ * ```
23
+ *
24
+ * **This is a transcription and it is meant to stay one.** Every level, every frequency and every
25
+ * connection order is the one `AudioGraph`'s constructor had, because the gate on this file is that
26
+ * an offline render through it is sample-identical to a render frozen before it existed. An
27
+ * improvement to the mix cannot be made here without breaking that gate — deliberately, so that a
28
+ * change to how the mix sounds arrives in its own commit where a diff attributes it.
29
+ *
30
+ * **The three returns are parented to nothing and wired straight to `out`.** They join the mix
31
+ * downstream of the master filter, which is what the mix has always done: a reverb tail put through
32
+ * a low-pass the dry signal has already passed reads as a duller room rather than as a room.
33
+ */
34
+ export interface DefaultLayout {
35
+ readonly music: MixBus;
36
+ readonly effects: MixBus;
37
+ readonly reverb: MixBus;
38
+ readonly longReverb: MixBus;
39
+ readonly delay: MixBus;
40
+ readonly lift: LiftInsert;
41
+ readonly slam: SlamInsert;
42
+ readonly masterFilter: MasterFilterInsert;
43
+ readonly delayLine: DelayInsert;
44
+ }
45
+
46
+ /**
47
+ * The long tail: seconds of impulse, and how fast it decays inside them.
48
+ *
49
+ * Long enough to carry a whole airborne moment — a jump is under a second, a glide several — and
50
+ * decaying slowly enough that the music is *spread out* rather than merely echoed. Beyond about
51
+ * eight seconds it stops sounding like a space and starts sounding like a stuck effect.
52
+ */
53
+ const LONG_REVERB_SECONDS = 6;
54
+ const LONG_REVERB_DECAY = 1.5;
55
+ /** The short one: the room the music is played in, rather than where it goes. */
56
+ const SHORT_REVERB_SECONDS = 2.4;
57
+ const SHORT_REVERB_DECAY = 2.6;
58
+ /** Baseline delay feedback: one clear repeat, not a rhythm of its own. */
59
+ const DELAY_FEEDBACK = 0.34;
60
+
61
+ export function defaultLayout(mix: MixConsole, levels: MixLevels): DefaultLayout {
62
+ const masterFilter = masterFilterInsert(mix.context, () => mix.scheduleAt());
63
+ mix.master.insert(masterFilter);
64
+
65
+ /*
66
+ * Music and effects have their own bus because players expect to turn them down independently —
67
+ * muting the score while keeping the game audible is the single most-used audio setting there is.
68
+ */
69
+ const music = mix.bus('music', { level: levels.music });
70
+ const lift = liftInsert(mix.context, () => mix.scheduleAt());
71
+ music.insert(lift);
72
+ const slam = slamInsert(mix.context, () => mix.scheduleAt());
73
+ /*
74
+ * Below the tap, so the sends never hear it: a six-second convolution of a clipped bass hit is a
75
+ * mess, and it would still be arriving three gates later.
76
+ */
77
+ music.insert(slam, { postSend: true });
78
+ // And its wet band is taken from the music as it arrives, upstream of the lift's high-pass. See
79
+ // `SlamInsert.wetInput` for why that is the one wiring mistake this stage can make.
80
+ music.feedFromInput(slam.wetInput);
81
+
82
+ const effects = mix.bus('effects', { level: levels.effects });
83
+
84
+ /*
85
+ * **The sends are fed from the music alone.** They hung off the shared bus first, which put
86
+ * reverb and delay on every sound the game made when only the score should carry them. A footstep
87
+ * with a six-second tail on it is not atmosphere, it is a bug, and the effects that carry the
88
+ * world's own sound need to stay dry and immediate to be legible.
89
+ */
90
+ const reverb = mix.bus('reverb', { parent: null });
91
+ reverb.insert(
92
+ convolverInsert(mix.context, SHORT_REVERB_SECONDS, SHORT_REVERB_DECAY, () => mix.random()),
93
+ );
94
+ reverb.output.connect(mix.out);
95
+
96
+ /*
97
+ * A second, much longer reverb, on its own return rather than a swappable impulse: building one
98
+ * allocates and costs milliseconds, and doing that on a jump would put both on the input path.
99
+ * Two convolvers cost two returns and nothing else.
100
+ */
101
+ const longReverb = mix.bus('longReverb', { parent: null });
102
+ longReverb.insert(
103
+ convolverInsert(mix.context, LONG_REVERB_SECONDS, LONG_REVERB_DECAY, () => mix.random()),
104
+ );
105
+ longReverb.output.connect(mix.out);
106
+
107
+ const delay = mix.bus('delay', { parent: null });
108
+ const delayLine = delayInsert(mix.context, () => mix.scheduleAt(), { feedback: DELAY_FEEDBACK });
109
+ delay.insert(delayLine);
110
+ delay.output.connect(mix.out);
111
+
112
+ // At zero, so an idle graph is exactly the dry mix. Every send is opened by a caller.
113
+ music.send(reverb, 0);
114
+ music.send(longReverb, 0);
115
+ music.send(delay, 0);
116
+
117
+ return { music, effects, reverb, longReverb, delay, lift, slam, masterFilter, delayLine };
118
+ }
@@ -0,0 +1,242 @@
1
+ import type { ScheduleClock } from '../ambientLoop.ts';
2
+ import {
3
+ LIFT_FLOOR_HZ,
4
+ SLAM_ATTACK_SEC,
5
+ SLAM_CLIP_KNEE,
6
+ SLAM_CLOSED_HZ,
7
+ SLAM_DECAY_SEC,
8
+ SLAM_DRIVE,
9
+ SLAM_DUCK,
10
+ SLAM_OPEN_HZ,
11
+ SLAM_SHELF_DB,
12
+ SLAM_SHELF_HZ,
13
+ clamp01,
14
+ cutoffForSpeed,
15
+ liftFrequencyHz,
16
+ liftGainFor,
17
+ } from '../filters.ts';
18
+ import type { MixInsert } from './bus.ts';
19
+
20
+ /**
21
+ * The stages a bus can carry, as things rather than as lines in one constructor.
22
+ *
23
+ * **These are transcriptions and they are meant to stay transcriptions.** Every node, every
24
+ * constant and every connection order came out of `AudioGraph`'s constructor unchanged, because
25
+ * the gate on the layout that uses them is that a render through it is sample-identical to one
26
+ * frozen before any of this existed. An improvement made in passing here is a gate failure
27
+ * somebody spends an hour attributing to the wrong thing.
28
+ */
29
+
30
+ const RAMP = 0.08;
31
+
32
+ /** Ramp a parameter on the mix's clock. The same shape every stage here uses. */
33
+ function ramp(param: AudioParam, value: number, scheduleAt: ScheduleClock): void {
34
+ const at = scheduleAt();
35
+ param.cancelScheduledValues(at);
36
+ param.setTargetAtTime(value, at, RAMP);
37
+ }
38
+
39
+ export interface MasterFilterInsert extends MixInsert {
40
+ setCutoff(hz: number): void;
41
+ setResonance(amount: number): void;
42
+ readonly filter: BiquadFilterNode;
43
+ }
44
+
45
+ /**
46
+ * The master low-pass: everything the player hears, including the world's own sound.
47
+ *
48
+ * Deliberately across the whole mix rather than the score alone — going under water muffles the
49
+ * world, not only the music.
50
+ */
51
+ export function masterFilterInsert(
52
+ context: BaseAudioContext,
53
+ scheduleAt: ScheduleClock,
54
+ ): MasterFilterInsert {
55
+ const filter = context.createBiquadFilter();
56
+ filter.type = 'lowpass';
57
+ filter.frequency.value = cutoffForSpeed(0, 1);
58
+ filter.Q.value = 0.7;
59
+ return {
60
+ input: filter,
61
+ output: filter,
62
+ filter,
63
+ setCutoff(hz: number): void {
64
+ ramp(filter.frequency, Math.min(Math.max(hz, 40), 20000), scheduleAt);
65
+ },
66
+ /*
67
+ * Not a second filter in the chain: the master low-pass is already there and already ramped, so
68
+ * resonance raises its Q instead. A sweep then *colours* the music — the same whistle a DJ
69
+ * filter makes — where an added layer would only sit on top of it. Zero restores the flat
70
+ * response exactly.
71
+ */
72
+ setResonance(amount: number): void {
73
+ ramp(filter.Q, 0.0001 + Math.max(0, Math.min(amount, 1)) * 12, scheduleAt);
74
+ },
75
+ };
76
+ }
77
+
78
+ export interface LiftInsert extends MixInsert {
79
+ setAmount(amount: number): void;
80
+ }
81
+
82
+ /**
83
+ * The lift: a high-pass and a duck, in series.
84
+ *
85
+ * This is the airborne effect proper, and it is a different idea from adding reverb on top of a
86
+ * full-level track. The reference is the lift familiar from dance production, which does three
87
+ * things at once — thins the low end, pulls the level down, and lets the wet through — and the
88
+ * *thinning* is what reads as leaving the ground. Weight lives in the bass; take it away and the
89
+ * track is suspended.
90
+ *
91
+ * Which is also why an earlier attempt to duck read as "the music going away" instead: it closed
92
+ * the *low-pass*, and losing the treble is what distance sounds like, not what height sounds like.
93
+ *
94
+ * A bus's sends listen from the tap, and the layout puts this above it, so the tail is thin too.
95
+ * Reverb fed from the unfiltered signal would put the bass back in the one place it cannot be
96
+ * pushed out of again.
97
+ */
98
+ export function liftInsert(context: BaseAudioContext, scheduleAt: ScheduleClock): LiftInsert {
99
+ const highpass = context.createBiquadFilter();
100
+ highpass.type = 'highpass';
101
+ highpass.frequency.value = LIFT_FLOOR_HZ;
102
+ highpass.Q.value = 0.7;
103
+ const gain = context.createGain();
104
+ highpass.connect(gain);
105
+ return {
106
+ input: highpass,
107
+ output: gain,
108
+ /*
109
+ * One call for the whole airborne effect, because its three parts have to move together: the
110
+ * low end thins, the dry level ducks, and what is left is mostly the sends. Splitting them
111
+ * across three game-side calls is how they end up disagreeing, and a half-ducked, un-thinned
112
+ * track is just quieter music.
113
+ */
114
+ setAmount(amount: number): void {
115
+ ramp(highpass.frequency, liftFrequencyHz(amount), scheduleAt);
116
+ ramp(gain.gain, liftGainFor(amount), scheduleAt);
117
+ },
118
+ };
119
+ }
120
+
121
+ export interface SlamInsert extends MixInsert {
122
+ /**
123
+ * Where the driven band is taken from, which is **not** this insert's series input.
124
+ *
125
+ * The layout connects the bus's own pre-insert signal here. Any low-cut ahead of the tap has to
126
+ * be bypassed or the effect disappears exactly when the player is in the air: the lift *is* a
127
+ * high-pass, so a slam taken from after it would be boosting a shelf on a band that had already
128
+ * been removed, and would be at its weakest where half the gates are taken.
129
+ */
130
+ readonly wetInput: AudioNode;
131
+ /** The dry path's duck and the wet path's blend, exposed so a test can assert an idle stage. */
132
+ readonly dryGain: GainNode;
133
+ readonly wetGain: GainNode;
134
+ strike(amount: number): void;
135
+ }
136
+
137
+ /**
138
+ * The slam: a parallel band of driven low end, blended in for a fraction of a second and gone.
139
+ *
140
+ * For the instant a body passes a marker in the world: boost the low end of the score alone, and
141
+ * hard-clip it a little. Which is parallel bass saturation, a production move rather than a game
142
+ * one, and it lands in exactly the register a bass-led score leaves room in.
143
+ *
144
+ * **Parallel rather than in-line, and that is the load-bearing decision.** A shaper sitting in the
145
+ * music path colours the score for the whole run — a track mastered near full scale is already
146
+ * touching any knee low enough to be useful — so the effect would stop being an event and become
147
+ * the sound of the game. With a dry path at unity and a wet path at zero, an idle graph is
148
+ * sample-identical to one without this stage in it, which is what lets the identity gate pass
149
+ * across a rewrite that moved it into a bus.
150
+ */
151
+ export function slamInsert(context: BaseAudioContext, scheduleAt: ScheduleClock): SlamInsert {
152
+ const dry = context.createGain();
153
+ dry.gain.value = 1;
154
+ const output = context.createGain();
155
+ dry.connect(output);
156
+
157
+ const shelf = context.createBiquadFilter();
158
+ shelf.type = 'lowshelf';
159
+ shelf.frequency.value = SLAM_SHELF_HZ;
160
+ shelf.gain.value = 0;
161
+ /*
162
+ * And a low-pass across the driven band: boosted bass plus a low-pass reads better than
163
+ * saturation on its own, because saturation alone is a *timbre* change that a listener has to be
164
+ * paying attention to the score to notice. Closing a filter is a change in the whole shape of the
165
+ * sound, and it is the move every dance record uses to mark a moment precisely because it works
166
+ * on somebody who is not listening for it.
167
+ */
168
+ const lowpass = context.createBiquadFilter();
169
+ lowpass.type = 'lowpass';
170
+ lowpass.frequency.value = SLAM_OPEN_HZ;
171
+ lowpass.Q.value = 1.1;
172
+ const drive = context.createGain();
173
+ drive.gain.value = 1;
174
+ const shaper = context.createWaveShaper();
175
+ shaper.curve = softClipCurve();
176
+ // The clip generates harmonics well above the band it came from; without oversampling those
177
+ // alias back down as grit that does not belong to the hit.
178
+ shaper.oversample = '4x';
179
+ const wet = context.createGain();
180
+ wet.gain.value = 0;
181
+
182
+ shelf.connect(lowpass);
183
+ lowpass.connect(drive);
184
+ drive.connect(shaper);
185
+ shaper.connect(wet);
186
+ wet.connect(output);
187
+
188
+ return {
189
+ input: dry,
190
+ output,
191
+ wetInput: shelf,
192
+ dryGain: dry,
193
+ wetGain: wet,
194
+ /**
195
+ * Slam the low end for an instant, hard enough to clip.
196
+ *
197
+ * Everything about the shape says *event*: twelve milliseconds of attack so it lands on the
198
+ * tick rather than swelling into it, then an exponential decay back to nothing over about a
199
+ * third of a second. Scheduled on the audio clock rather than eased from the frame loop, so the
200
+ * envelope is sample-accurate and a dropped frame cannot stretch it.
201
+ */
202
+ strike(amount: number): void {
203
+ const hit = clamp01(amount);
204
+ if (hit < 0.05) return;
205
+ const at = scheduleAt();
206
+ const strikeParam = (param: AudioParam, idle: number, peak: number): void => {
207
+ param.cancelScheduledValues(at);
208
+ // From wherever the last slam left it, so gates a stride apart stack rather than each one
209
+ // restarting the envelope from silence.
210
+ param.setValueAtTime(param.value, at);
211
+ param.linearRampToValueAtTime(peak, at + SLAM_ATTACK_SEC);
212
+ param.setTargetAtTime(idle, at + SLAM_ATTACK_SEC, SLAM_DECAY_SEC);
213
+ };
214
+ strikeParam(wet.gain, 0, hit);
215
+ strikeParam(dry.gain, 1, 1 - hit * SLAM_DUCK);
216
+ strikeParam(shelf.gain, 0, hit * SLAM_SHELF_DB);
217
+ strikeParam(drive.gain, 1, 1 + hit * SLAM_DRIVE);
218
+ // Down to the sub band and back open. The filter is most of the effect now.
219
+ strikeParam(lowpass.frequency, SLAM_OPEN_HZ, SLAM_CLOSED_HZ);
220
+ },
221
+ };
222
+ }
223
+
224
+ /**
225
+ * A soft clipper, transparent until it is driven and saturating hard after.
226
+ *
227
+ * `tanh` rather than a hard corner: a hard clip of a bass note is a square wave, and a square
228
+ * wave's odd harmonics march all the way up the spectrum as buzz. `tanh` rounds the corner, so what
229
+ * comes out is the second and third harmonic — which is what "driven" sounds like as opposed to
230
+ * "broken".
231
+ *
232
+ * Odd-length so there is a sample exactly at zero, which keeps silence silent.
233
+ */
234
+ function softClipCurve(): Float32Array<ArrayBuffer> {
235
+ const samples = 2049;
236
+ const curve = new Float32Array(new ArrayBuffer(2049 * 4));
237
+ for (let i = 0; i < samples; i++) {
238
+ const x = (i / (samples - 1)) * 2 - 1;
239
+ curve[i] = Math.tanh(x * SLAM_CLIP_KNEE) / Math.tanh(SLAM_CLIP_KNEE);
240
+ }
241
+ return curve;
242
+ }
@@ -0,0 +1,114 @@
1
+ import type { ScheduleClock } from '../ambientLoop.ts';
2
+ import type { MixInsert } from './bus.ts';
3
+
4
+ /**
5
+ * The stages a *return* bus carries: a reverb, and an echo.
6
+ *
7
+ * Separate from `inserts.ts` because they answer a different question. Those are stages a source
8
+ * bus puts in its own path; these are what a bus at the end of a send is made of, and a consumer
9
+ * building a return reaches for exactly one of them.
10
+ */
11
+
12
+ const RAMP = 0.08;
13
+
14
+ /**
15
+ * A synthesised impulse response: exponentially decaying noise.
16
+ *
17
+ * Not a real hall — a real one is a file, and files are what the registry is for. This exists so
18
+ * reverb works before any asset has been recorded, on the same principle as every other sound here.
19
+ *
20
+ * **`random` is a parameter rather than `Math.random`**, and that is what makes a reverb tail
21
+ * assertable at all: an unseeded generator builds a different hall every construction, so no render
22
+ * carrying wet signal can be compared to another. Defaulting it here would have hidden that.
23
+ */
24
+ export function impulseResponse(
25
+ context: BaseAudioContext,
26
+ seconds: number,
27
+ decay: number,
28
+ random: () => number,
29
+ ): AudioBuffer {
30
+ const rate = context.sampleRate;
31
+ const length = Math.max(1, Math.floor(rate * seconds));
32
+ const buffer = context.createBuffer(2, length, rate);
33
+ for (let channel = 0; channel < 2; channel++) {
34
+ const data = buffer.getChannelData(channel);
35
+ for (let i = 0; i < length; i++) {
36
+ data[i] = (random() * 2 - 1) * (1 - i / length) ** decay;
37
+ }
38
+ }
39
+ return buffer;
40
+ }
41
+
42
+ /**
43
+ * A convolver, built once at registration.
44
+ *
45
+ * **Never on entry to anything.** Building an impulse allocates a stereo buffer and fills it sample
46
+ * by sample, which costs milliseconds; doing that when a player crosses a threshold would put both
47
+ * the allocation and the cost on the input path at the exact moment something is supposed to happen.
48
+ */
49
+ export function convolverInsert(
50
+ context: BaseAudioContext,
51
+ seconds: number,
52
+ decay: number,
53
+ random: () => number,
54
+ ): MixInsert {
55
+ const convolver = context.createConvolver();
56
+ convolver.buffer = impulseResponse(context, seconds, decay, random);
57
+ return { input: convolver, output: convolver };
58
+ }
59
+
60
+ export interface DelayInsert extends MixInsert {
61
+ setTime(seconds: number): void;
62
+ setFeedback(amount: number): void;
63
+ readonly timeSec: number;
64
+ }
65
+
66
+ /**
67
+ * A delay line that feeds itself, at a level that must decay.
68
+ *
69
+ * The interval is worth exposing rather than fixing at a pleasant-sounding constant, because an
70
+ * echo either lands *with* the music or against it, and which one depends on the tempo of whatever
71
+ * is playing: a 0.28 s repeat under a 150 BPM track falls between the beats and reads as smear,
72
+ * where a half-beat repeat reads as the room the track is in.
73
+ */
74
+ export function delayInsert(
75
+ context: BaseAudioContext,
76
+ scheduleAt: ScheduleClock,
77
+ { time = 0.28, feedback = 0.34 } = {},
78
+ ): DelayInsert {
79
+ const delay = context.createDelay(1);
80
+ delay.delayTime.value = time;
81
+ const loop = context.createGain();
82
+ loop.gain.value = feedback;
83
+ delay.connect(loop);
84
+ loop.connect(delay);
85
+
86
+ const ramp = (param: AudioParam, value: number): void => {
87
+ const at = scheduleAt();
88
+ param.cancelScheduledValues(at);
89
+ param.setTargetAtTime(value, at, RAMP);
90
+ };
91
+
92
+ return {
93
+ input: delay,
94
+ output: delay,
95
+ get timeSec(): number {
96
+ return delay.delayTime.value;
97
+ },
98
+ setTime(seconds: number): void {
99
+ if (!Number.isFinite(seconds)) return;
100
+ // Clamped to the line's own capacity.
101
+ ramp(delay.delayTime, Math.min(Math.max(seconds, 0.02), 0.98));
102
+ },
103
+ /**
104
+ * How much of each repeat feeds the next.
105
+ *
106
+ * Clamped below 1, because a feedback path that does not decay is a drone that grows until it
107
+ * clips — and one the caller cannot undo by turning the send down, since the energy is already
108
+ * circulating.
109
+ */
110
+ setFeedback(amount: number): void {
111
+ ramp(loop.gain, Math.min(Math.max(amount, 0), 0.88));
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,75 @@
1
+ import type { MixBus } from './bus.ts';
2
+ import type { MixConsole } from './console.ts';
3
+
4
+ /**
5
+ * A mix, remembered: every fader, every mute and every send, at one instant.
6
+ *
7
+ * **Insert parameters are deliberately absent**, and that is the whole design decision here. The
8
+ * master filter's cutoff is written every frame from whatever the game is doing; a snapshot that
9
+ * captured it would fight that writer, and the winner would be whichever wrote last — which is
10
+ * heard as a filter that sometimes sticks and cannot be reproduced on purpose.
11
+ *
12
+ * Cost: "underwater" as a snapshot carries its levels and its sends but not its filter, so a
13
+ * consumer wanting both recalls the snapshot and sets the cutoff itself. What would make this wrong
14
+ * is an insert parameter no per-frame code ever touches, which is when capturing it costs nothing;
15
+ * this clause is what to revisit if one arrives.
16
+ */
17
+ export interface MixSnapshot {
18
+ readonly levels: ReadonlyMap<string, number>;
19
+ readonly mutes: ReadonlyMap<string, boolean>;
20
+ /** Per bus name, per return bus name, the amount that bus was sending. */
21
+ readonly sends: ReadonlyMap<string, ReadonlyMap<string, number>>;
22
+ }
23
+
24
+ export function captureSnapshot(mix: MixConsole): MixSnapshot {
25
+ const levels = new Map<string, number>();
26
+ const mutes = new Map<string, boolean>();
27
+ const sends = new Map<string, ReadonlyMap<string, number>>();
28
+ for (const bus of mix.all) {
29
+ levels.set(bus.name, bus.level);
30
+ mutes.set(bus.name, bus.muted);
31
+ const perTarget = new Map<string, number>();
32
+ for (const target of bus.sendTargets) perTarget.set(target.name, bus.sendAmount(target));
33
+ if (perTarget.size > 0) sends.set(bus.name, perTarget);
34
+ }
35
+ return { levels, mutes, sends };
36
+ }
37
+
38
+ /**
39
+ * Move the mix back to a captured one, over `seconds`.
40
+ *
41
+ * **A bus the snapshot never saw is left alone**, rather than reset to a default. A snapshot is a
42
+ * record of what was, not an assertion about what must be, and silencing a bus that did not exist
43
+ * when it was taken is a recall destroying state it knows nothing about — a reverb zone registered
44
+ * after a mix was captured, say, going silent the first time anybody recalls it.
45
+ */
46
+ export function recallSnapshot(mix: MixConsole, snapshot: MixSnapshot, seconds: number): void {
47
+ for (const [name, level] of snapshot.levels) {
48
+ const bus = mix.find(name);
49
+ if (bus === undefined) continue;
50
+ applyLevel(bus, level, seconds);
51
+ bus.setMute(snapshot.mutes.get(name) ?? false);
52
+ }
53
+ for (const [name, perTarget] of snapshot.sends) {
54
+ const bus = mix.find(name);
55
+ if (bus === undefined) continue;
56
+ for (const [targetName, amount] of perTarget) {
57
+ const target = mix.find(targetName);
58
+ if (target !== undefined) bus.send(target, amount);
59
+ }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * `seconds` is the crossfade, and zero means immediately.
65
+ *
66
+ * A bus's own `setLevel` ramps at the mix's standard smoothing, which is right for a fader and
67
+ * wrong for a scene change: a snapshot recalled over three seconds has to take three seconds.
68
+ */
69
+ function applyLevel(bus: MixBus, level: number, seconds: number): void {
70
+ if (seconds <= 0) {
71
+ bus.setLevel(level);
72
+ return;
73
+ }
74
+ bus.fadeLevel(level, seconds);
75
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Placing a sound in the world: how loud a source is from here, and which side
3
+ * of the head it is on.
4
+ *
5
+ * Two small functions rather than a panner graph per emitter. A full HRTF node
6
+ * is the general answer and the right one for a game where a footstep behind
7
+ * you is information; for environmental sources — a fire, a waterfall, a storm
8
+ * column — what a player actually reads is "how close" and "which way", and
9
+ * these produce exactly those two numbers at no per-frame node cost.
10
+ *
11
+ * Both are pure, so a game can compute a whole scene's mix inside its render
12
+ * pass without touching the audio thread until the values have settled.
13
+ */
14
+
15
+ /**
16
+ * Level for a source `distance` away, reaching exactly zero at `radius`.
17
+ *
18
+ * Zero at the edge is the part that matters. Physical falloff is `1/d²`, which
19
+ * never quite arrives — and a route carrying a dozen braziers then sums into a
20
+ * permanent hiss the player can neither identify nor walk away from. A curve
21
+ * that ends is worth more here than one that is correct.
22
+ *
23
+ * `curve` shapes the approach: 1 is linear, 2 concentrates the change near the
24
+ * source, which is where a player's own movement makes it legible.
25
+ */
26
+ export function distanceGain(distance: number, radius: number, curve = 2): number {
27
+ if (!(radius > 0) || !Number.isFinite(distance)) return 0;
28
+ const t = 1 - Math.min(Math.max(distance / radius, 0), 1);
29
+ return curve === 1 ? t : t ** curve;
30
+ }
31
+
32
+ /**
33
+ * Where a source sits across the stereo field, from a listener facing `yaw`.
34
+ *
35
+ * -1 hard left, 0 centre or directly ahead/behind, 1 hard right. Yaw 0 faces
36
+ * −Z, matching the convention the rest of the engine's cameras and controls
37
+ * use, so the listener's right is `(cos yaw, 0, sin yaw)`.
38
+ *
39
+ * Horizontal only. Height is deliberately ignored: stereo cannot express it,
40
+ * and folding it in would quietly pull a source overhead toward the centre for
41
+ * no reason a listener could interpret.
42
+ */
43
+ export function stereoPan(dx: number, dz: number, yaw: number): number {
44
+ const horizontal = Math.hypot(dx, dz);
45
+ if (horizontal < 1e-4) return 0;
46
+ return (dx * Math.cos(yaw) + dz * Math.sin(yaw)) / horizontal;
47
+ }