@driftengine/audio 3.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +11 -0
- package/dist/ambientLoop.d.ts +45 -0
- package/dist/ambientLoop.js +88 -0
- package/dist/audioHarness.d.ts +180 -0
- package/dist/audioHarness.js +244 -0
- package/dist/filters.d.ts +91 -0
- package/dist/filters.js +103 -0
- package/dist/formats.d.ts +18 -0
- package/dist/formats.js +19 -0
- package/dist/graph.d.ts +406 -0
- package/dist/graph.js +656 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +39 -0
- package/dist/manifest.d.ts +28 -0
- package/dist/manifest.js +71 -0
- package/dist/mix/bus.d.ts +203 -0
- package/dist/mix/bus.js +293 -0
- package/dist/mix/console.d.ts +96 -0
- package/dist/mix/console.js +131 -0
- package/dist/mix/defaultLayout.d.ts +37 -0
- package/dist/mix/defaultLayout.js +63 -0
- package/dist/mix/inserts.d.ts +64 -0
- package/dist/mix/inserts.js +187 -0
- package/dist/mix/returns.d.ts +38 -0
- package/dist/mix/returns.js +86 -0
- package/dist/mix/snapshot.d.ts +30 -0
- package/dist/mix/snapshot.js +55 -0
- package/dist/positional.d.ts +37 -0
- package/dist/positional.js +47 -0
- package/dist/registry.d.ts +91 -0
- package/dist/registry.js +128 -0
- package/dist/rhythm/bands.d.ts +60 -0
- package/dist/rhythm/bands.js +12 -0
- package/dist/rhythm/beatGrid.d.ts +32 -0
- package/dist/rhythm/beatGrid.js +98 -0
- package/dist/rhythm/beatMap.d.ts +42 -0
- package/dist/rhythm/beatMap.js +405 -0
- package/dist/rhythm/kickCore.d.ts +79 -0
- package/dist/rhythm/kickCore.js +166 -0
- package/dist/rhythm/kickDetector.d.ts +65 -0
- package/dist/rhythm/kickDetector.js +202 -0
- package/dist/rhythm/renderedPulse.d.ts +15 -0
- package/dist/rhythm/renderedPulse.js +138 -0
- package/dist/session.d.ts +62 -0
- package/dist/session.js +83 -0
- package/dist/spatial/ambisonic.d.ts +135 -0
- package/dist/spatial/ambisonic.js +299 -0
- package/dist/spatial/listener.d.ts +109 -0
- package/dist/spatial/listener.js +186 -0
- package/dist/spatial/occlusion.d.ts +39 -0
- package/dist/spatial/occlusion.js +92 -0
- package/dist/spatial/source.d.ts +185 -0
- package/dist/spatial/source.js +366 -0
- package/dist/spatial/zones.d.ts +129 -0
- package/dist/spatial/zones.js +166 -0
- package/dist/synth.d.ts +92 -0
- package/dist/synth.js +282 -0
- package/package.json +54 -0
- package/src/ambientLoop.ts +101 -0
- package/src/audioHarness.ts +280 -0
- package/src/filters.ts +109 -0
- package/src/formats.ts +22 -0
- package/src/graph.ts +805 -0
- package/src/index.ts +84 -0
- package/src/manifest.ts +73 -0
- package/src/mix/bus.ts +356 -0
- package/src/mix/console.ts +181 -0
- package/src/mix/defaultLayout.ts +118 -0
- package/src/mix/inserts.ts +242 -0
- package/src/mix/returns.ts +114 -0
- package/src/mix/snapshot.ts +75 -0
- package/src/positional.ts +47 -0
- package/src/registry.ts +167 -0
- package/src/rhythm/bands.ts +45 -0
- package/src/rhythm/beatGrid.ts +106 -0
- package/src/rhythm/beatMap.ts +514 -0
- package/src/rhythm/kickCore.ts +197 -0
- package/src/rhythm/kickDetector.ts +233 -0
- package/src/rhythm/renderedPulse.ts +147 -0
- package/src/session.ts +93 -0
- package/src/spatial/ambisonic.ts +358 -0
- package/src/spatial/listener.ts +249 -0
- package/src/spatial/occlusion.ts +95 -0
- package/src/spatial/source.ts +452 -0
- package/src/spatial/zones.ts +213 -0
- package/src/synth.ts +351 -0
|
@@ -0,0 +1,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
|
+
}
|