@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,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Web Audio context that answers everything, for tests that need a graph and not a browser.
|
|
3
|
+
*
|
|
4
|
+
* **Not a `.test.ts` file, and that is the point.** Vitest registers a test when the file declaring
|
|
5
|
+
* it is imported, so a test file importing a stub from beside another file's tests would re-run
|
|
6
|
+
* every test in that file too. Split out so importing the harness costs nothing but the harness —
|
|
7
|
+
* the same reasoning, and the same shape, as `rendererHarness.ts` in core.
|
|
8
|
+
*
|
|
9
|
+
* **It exists because there were three of these.** `autoplay.test.ts`, `graph.test.ts` and
|
|
10
|
+
* `mixOutput.test.ts` each carried their own `StubNode` and their own `param()`, and they had
|
|
11
|
+
* already drifted: one recorded what was connected to it and two did not, one had an
|
|
12
|
+
* `fftSize` of 512 and another 2048, one could decode audio and the others could not. `AGENTS.md`'s
|
|
13
|
+
* 2026-08-17 rule is exactly this — two implementations of one decision drift, and they drift
|
|
14
|
+
* invisibly when the constants look identical.
|
|
15
|
+
*
|
|
16
|
+
* **A stub is not a contract and nothing here is tested directly.** What it must do is let the real
|
|
17
|
+
* graph build and let a test read back what the graph did. Where a member exists only so a
|
|
18
|
+
* constructor does not throw, it does nothing and says so.
|
|
19
|
+
*/
|
|
20
|
+
export function stubParam() {
|
|
21
|
+
const ramps = [];
|
|
22
|
+
return {
|
|
23
|
+
value: 0,
|
|
24
|
+
ramps,
|
|
25
|
+
setValueAtTime: (value, at) => ramps.push({ value, at }),
|
|
26
|
+
linearRampToValueAtTime: (value, at) => ramps.push({ value, at }),
|
|
27
|
+
cancelScheduledValues: () => undefined,
|
|
28
|
+
setTargetAtTime: (value, at) => ramps.push({ value, at }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A node that remembers what was connected to it. Enough graph to build against.
|
|
33
|
+
*
|
|
34
|
+
* `inputs` is what makes a topology assertable at all: the browser's own graph is write-only, so a
|
|
35
|
+
* test asking "is the send taken from the output or the input" has nowhere else to look.
|
|
36
|
+
*/
|
|
37
|
+
export class StubNode {
|
|
38
|
+
inputs = [];
|
|
39
|
+
/** Instants `stop` was asked for, so a test can see whether a fade preceded one. */
|
|
40
|
+
stops = [];
|
|
41
|
+
/** Instants `start` was asked for, so a test can see an offline launch has no lead. */
|
|
42
|
+
starts = [];
|
|
43
|
+
type = '';
|
|
44
|
+
buffer = null;
|
|
45
|
+
loop = false;
|
|
46
|
+
gain = stubParam();
|
|
47
|
+
frequency = stubParam();
|
|
48
|
+
Q = stubParam();
|
|
49
|
+
delayTime = stubParam();
|
|
50
|
+
playbackRate = stubParam();
|
|
51
|
+
pan = stubParam();
|
|
52
|
+
fftSize = 2048;
|
|
53
|
+
smoothingTimeConstant = 0;
|
|
54
|
+
frequencyBinCount = 1024;
|
|
55
|
+
/** A buffer source answers this; the spatial layer drives it for doppler. */
|
|
56
|
+
detune = stubParam();
|
|
57
|
+
/**
|
|
58
|
+
* The channel plumbing a multi-channel source needs.
|
|
59
|
+
*
|
|
60
|
+
* `channelInterpretation` is the one that matters and is the one a test asserts: the default
|
|
61
|
+
* up-mixes or down-mixes by *meaning*, and W, Y, Z, X are not left, right, centre and low
|
|
62
|
+
* frequency, so a field read that way comes out as a blur that still plays.
|
|
63
|
+
*/
|
|
64
|
+
channelCount = 2;
|
|
65
|
+
channelCountMode = 'max';
|
|
66
|
+
channelInterpretation = 'speakers';
|
|
67
|
+
/* What a panner answers. */
|
|
68
|
+
panningModel = 'equalpower';
|
|
69
|
+
distanceModel = 'inverse';
|
|
70
|
+
refDistance = 1;
|
|
71
|
+
maxDistance = 10000;
|
|
72
|
+
rolloffFactor = 1;
|
|
73
|
+
positionX = stubParam();
|
|
74
|
+
positionY = stubParam();
|
|
75
|
+
positionZ = stubParam();
|
|
76
|
+
/**
|
|
77
|
+
* `output` is accepted and ignored, which is enough for what these tests ask.
|
|
78
|
+
*
|
|
79
|
+
* A splitter's outputs go to different nodes, and what a test here checks is *which node* was
|
|
80
|
+
* connected rather than from which output — the coefficient gains are one per (speaker, channel)
|
|
81
|
+
* pair, so the pair is already carried by the node's identity.
|
|
82
|
+
*/
|
|
83
|
+
connect(target, output) {
|
|
84
|
+
void output;
|
|
85
|
+
target.inputs.push(this);
|
|
86
|
+
return target;
|
|
87
|
+
}
|
|
88
|
+
disconnect() { }
|
|
89
|
+
start(at = 0) {
|
|
90
|
+
this.starts.push(at);
|
|
91
|
+
}
|
|
92
|
+
stop(at = 0) {
|
|
93
|
+
this.stops.push(at);
|
|
94
|
+
}
|
|
95
|
+
getByteFrequencyData() { }
|
|
96
|
+
getFloatFrequencyData() { }
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The listener, in both shapes a browser might offer it.
|
|
100
|
+
*
|
|
101
|
+
* Both are present deliberately, because the code under test picks one and the choice is a feature
|
|
102
|
+
* detection rather than a preference: a test that only had the modern form could not tell whether
|
|
103
|
+
* the legacy branch was ever written. `positions` records the legacy calls so a test can assert
|
|
104
|
+
* which branch ran.
|
|
105
|
+
*/
|
|
106
|
+
export class StubListener {
|
|
107
|
+
positionX = stubParam();
|
|
108
|
+
positionY = stubParam();
|
|
109
|
+
positionZ = stubParam();
|
|
110
|
+
forwardX = stubParam();
|
|
111
|
+
forwardY = stubParam();
|
|
112
|
+
forwardZ = stubParam();
|
|
113
|
+
upX = stubParam();
|
|
114
|
+
upY = stubParam();
|
|
115
|
+
upZ = stubParam();
|
|
116
|
+
positions = [];
|
|
117
|
+
orientations = [];
|
|
118
|
+
setPosition(x, y, z) {
|
|
119
|
+
this.positions.push([x, y, z]);
|
|
120
|
+
}
|
|
121
|
+
setOrientation(fx, fy, fz, ux, uy, uz) {
|
|
122
|
+
this.orientations.push([fx, fy, fz, ux, uy, uz]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export class StubContext {
|
|
126
|
+
options;
|
|
127
|
+
destination = new StubNode();
|
|
128
|
+
/** Every buffer source built, in order, so a test can inspect the transport. */
|
|
129
|
+
sources = [];
|
|
130
|
+
listener = new StubListener();
|
|
131
|
+
/** Every panner built, in order, so a test can inspect what was placed in the world. */
|
|
132
|
+
panners = [];
|
|
133
|
+
/**
|
|
134
|
+
* Every convolver built, in order.
|
|
135
|
+
*
|
|
136
|
+
* Counted because "a second routing rather than a second convolver" is a claim about how many of
|
|
137
|
+
* them exist, and a convolver is the most expensive node in this graph. A test that asserted the
|
|
138
|
+
* send worked would not notice one being built per source.
|
|
139
|
+
*/
|
|
140
|
+
convolvers = [];
|
|
141
|
+
outputLatency = 0;
|
|
142
|
+
tap = { stream: {}, ...new StubNode() };
|
|
143
|
+
state;
|
|
144
|
+
currentTime = 0;
|
|
145
|
+
sampleRate = 48000;
|
|
146
|
+
/** How many times a resume was attempted, for the tests about a context that will not start. */
|
|
147
|
+
resumeCalls = 0;
|
|
148
|
+
constructor(options = {}) {
|
|
149
|
+
this.options = options;
|
|
150
|
+
this.state = options.state ?? 'running';
|
|
151
|
+
if (options.stereoPanner === false) {
|
|
152
|
+
this.createStereoPanner = undefined;
|
|
153
|
+
}
|
|
154
|
+
if (options.listenerParams === false) {
|
|
155
|
+
for (const name of ['positionX', 'forwardX', 'upX']) {
|
|
156
|
+
this.listener[name] = undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
createGain() {
|
|
161
|
+
return new StubNode();
|
|
162
|
+
}
|
|
163
|
+
createBiquadFilter() {
|
|
164
|
+
// `type` is assigned by the graph right after creation; tests read it back to find the
|
|
165
|
+
// master low-pass without knowing the construction order.
|
|
166
|
+
return new StubNode();
|
|
167
|
+
}
|
|
168
|
+
createConvolver() {
|
|
169
|
+
const convolver = new StubNode();
|
|
170
|
+
this.convolvers.push(convolver);
|
|
171
|
+
return convolver;
|
|
172
|
+
}
|
|
173
|
+
createWaveShaper() {
|
|
174
|
+
return new StubNode();
|
|
175
|
+
}
|
|
176
|
+
createDelay() {
|
|
177
|
+
return new StubNode();
|
|
178
|
+
}
|
|
179
|
+
createAnalyser() {
|
|
180
|
+
return new StubNode();
|
|
181
|
+
}
|
|
182
|
+
createPanner() {
|
|
183
|
+
const panner = new StubNode();
|
|
184
|
+
this.panners.push(panner);
|
|
185
|
+
return panner;
|
|
186
|
+
}
|
|
187
|
+
createBufferSource() {
|
|
188
|
+
const source = new StubNode();
|
|
189
|
+
this.sources.push(source);
|
|
190
|
+
return source;
|
|
191
|
+
}
|
|
192
|
+
createStereoPanner() {
|
|
193
|
+
return new StubNode();
|
|
194
|
+
}
|
|
195
|
+
createMediaStreamDestination() {
|
|
196
|
+
return this.tap;
|
|
197
|
+
}
|
|
198
|
+
createChannelSplitter() {
|
|
199
|
+
return new StubNode();
|
|
200
|
+
}
|
|
201
|
+
createChannelMerger() {
|
|
202
|
+
return new StubNode();
|
|
203
|
+
}
|
|
204
|
+
/*
|
|
205
|
+
* `numberOfChannels` is answered because a decoder that refuses a buffer of the wrong shape has
|
|
206
|
+
* to be able to see the shape. It was absent, so every such refusal fired against `undefined` —
|
|
207
|
+
* which happened to be right and for the wrong reason.
|
|
208
|
+
*/
|
|
209
|
+
createBuffer(channels, length) {
|
|
210
|
+
const data = new Float32Array(length);
|
|
211
|
+
return { numberOfChannels: channels, length, getChannelData: () => data };
|
|
212
|
+
}
|
|
213
|
+
async decodeAudioData() {
|
|
214
|
+
return {};
|
|
215
|
+
}
|
|
216
|
+
async resume() {
|
|
217
|
+
this.resumeCalls++;
|
|
218
|
+
if (this.options.refuseResume === true) {
|
|
219
|
+
throw new DOMException('play() blocked', 'NotAllowedError');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async close() { }
|
|
223
|
+
}
|
|
224
|
+
/** The context most tests want: running, complete, and recording what was done to it. */
|
|
225
|
+
export function stubContext(options) {
|
|
226
|
+
return new StubContext(options);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Install a stub as the page's `AudioContext` and hand back the one that gets built.
|
|
230
|
+
*
|
|
231
|
+
* The graph constructs its own context through the global, so a test that wants to look at the
|
|
232
|
+
* nodes has to intercept the construction rather than pass one in. Returns a getter rather than
|
|
233
|
+
* the context, because nothing exists until the code under test asks for it.
|
|
234
|
+
*/
|
|
235
|
+
export function installStubAudioContext(options) {
|
|
236
|
+
let built = null;
|
|
237
|
+
globalThis.AudioContext = class {
|
|
238
|
+
constructor() {
|
|
239
|
+
built = new StubContext(options);
|
|
240
|
+
return built;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
return () => built;
|
|
244
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curves: how a cutoff follows speed, and what the lift and slam controls do.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions of a number, with the constants they are made of beside them. Separated
|
|
5
|
+
* from the graph because none of them touches an AudioContext, a node or any class state,
|
|
6
|
+
* and reading what a control does should not mean paging through the mix that applies it.
|
|
7
|
+
*
|
|
8
|
+
* The constants are exported rather than split: `AudioGraph` reads several of them directly,
|
|
9
|
+
* and tearing a documented block in half so each half sits nearer its caller would put the
|
|
10
|
+
* numbers in one file and the arithmetic in another.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Master low-pass cutoff in Hz for a given speed.
|
|
14
|
+
*
|
|
15
|
+
* Exponential in frequency because hearing is: a linear sweep spends most of
|
|
16
|
+
* its travel in a range that sounds like almost nothing is happening. This is
|
|
17
|
+
* the "filter opens as you hit full sprint" moment, so it has to track the
|
|
18
|
+
* *feeling* of accelerating rather than the number.
|
|
19
|
+
*/
|
|
20
|
+
export declare function cutoffForSpeed(speed: number, maxSpeed: number): number;
|
|
21
|
+
export declare function clamp01(value: number): number;
|
|
22
|
+
/**
|
|
23
|
+
* The lift, at rest and at full.
|
|
24
|
+
*
|
|
25
|
+
* 20 Hz is below hearing, so at rest the high-pass is not there. 340 Hz takes the kick
|
|
26
|
+
* and the bass line out while leaving the body of the track — far enough that the ground
|
|
27
|
+
* goes with it, short of the telephone-speaker sound a build-up filter reaches for.
|
|
28
|
+
*
|
|
29
|
+
* The duck is what makes room for the wet: at 0.62 the dry track sits back far enough
|
|
30
|
+
* for a six-second tail to be the loudest thing in the air, which is the whole point of
|
|
31
|
+
* having one.
|
|
32
|
+
*/
|
|
33
|
+
export declare const LIFT_FLOOR_HZ = 20;
|
|
34
|
+
/**
|
|
35
|
+
* The gate slam: how it is shaped, and how hard it hits.
|
|
36
|
+
*
|
|
37
|
+
* `SLAM_SHELF_HZ` is where the boost stops being bass and starts being body — a
|
|
38
|
+
* shelf much above this pulls the kick's click and the bass line's fundamentals up
|
|
39
|
+
* together, which is a volume jump rather than a low-end one.
|
|
40
|
+
*
|
|
41
|
+
* `SLAM_ATTACK_SEC` is deliberately shorter than a frame at 60 Hz. The hit has to
|
|
42
|
+
* land on the tick the character crosses the plane; anything that swells into it reads
|
|
43
|
+
* as the music doing something rather than the gate doing something.
|
|
44
|
+
*
|
|
45
|
+
* `SLAM_DUCK` pulls the dry path down as the wet comes up, so what changes is the
|
|
46
|
+
* *character* of the moment and not simply the volume of it. Without it the slam is
|
|
47
|
+
* a loudness spike, and a loudness spike on every gate is exhausting by the third.
|
|
48
|
+
*/
|
|
49
|
+
export declare const SLAM_SHELF_HZ = 120;
|
|
50
|
+
export declare const SLAM_SHELF_DB = 15;
|
|
51
|
+
export declare const SLAM_DRIVE = 0.7;
|
|
52
|
+
/**
|
|
53
|
+
* Where the wet path's low-pass sits at rest and at the bottom of a slam.
|
|
54
|
+
*
|
|
55
|
+
* Open is above hearing, so the filter is a no-op between gates and the wet path is
|
|
56
|
+
* whatever the shelf made of it. Closed is 240 Hz — bass and the very bottom of the
|
|
57
|
+
* mid, which is the band a kick and a bass line live in and nothing else does.
|
|
58
|
+
*/
|
|
59
|
+
export declare const SLAM_OPEN_HZ = 20000;
|
|
60
|
+
export declare const SLAM_CLOSED_HZ = 240;
|
|
61
|
+
/**
|
|
62
|
+
* How far the dry path ducks under the wet.
|
|
63
|
+
*
|
|
64
|
+
* Raised with the low-pass: the point of the pair is that for an instant the score is
|
|
65
|
+
* *only* its bottom end, and that cannot happen while the unfiltered track is still
|
|
66
|
+
* playing underneath at full level.
|
|
67
|
+
*/
|
|
68
|
+
export declare const SLAM_DUCK = 0.72;
|
|
69
|
+
export declare const SLAM_ATTACK_SEC = 0.012;
|
|
70
|
+
export declare const SLAM_DECAY_SEC = 0.11;
|
|
71
|
+
/**
|
|
72
|
+
* How hard the soft clipper bites, as the `tanh` input scale.
|
|
73
|
+
*
|
|
74
|
+
* At 2.4 a signal at full scale comes back at about 0.4 of the way to a square wave
|
|
75
|
+
* — audibly driven, still recognisably the track. The curve is normalised so unity
|
|
76
|
+
* in is unity out, which keeps the wet path's own level honest.
|
|
77
|
+
*/
|
|
78
|
+
export declare const SLAM_CLIP_KNEE = 2.4;
|
|
79
|
+
/**
|
|
80
|
+
* Where the high-pass sits at a given lift, in hertz.
|
|
81
|
+
*
|
|
82
|
+
* **Geometric, not linear.** Pitch is logarithmic: a linear sweep from 20 Hz to 340
|
|
83
|
+
* spends half its travel between 20 and 180, which is almost entirely below the notes a
|
|
84
|
+
* track is made of — so the first half of a jump would do nothing and the second half
|
|
85
|
+
* would lurch. Geometrically, half a lift is the geometric mean (82 Hz) and every equal
|
|
86
|
+
* step of the knob is an equal musical interval, which is what makes the effect read as a
|
|
87
|
+
* sweep rather than as a switch.
|
|
88
|
+
*/
|
|
89
|
+
export declare function liftFrequencyHz(amount: number): number;
|
|
90
|
+
/** How far the dry track ducks at a given lift: 1 on the ground, `LIFT_DUCK` in the air. */
|
|
91
|
+
export declare function liftGainFor(amount: number): number;
|
package/dist/filters.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curves: how a cutoff follows speed, and what the lift and slam controls do.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions of a number, with the constants they are made of beside them. Separated
|
|
5
|
+
* from the graph because none of them touches an AudioContext, a node or any class state,
|
|
6
|
+
* and reading what a control does should not mean paging through the mix that applies it.
|
|
7
|
+
*
|
|
8
|
+
* The constants are exported rather than split: `AudioGraph` reads several of them directly,
|
|
9
|
+
* and tearing a documented block in half so each half sits nearer its caller would put the
|
|
10
|
+
* numbers in one file and the arithmetic in another.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Master low-pass cutoff in Hz for a given speed.
|
|
14
|
+
*
|
|
15
|
+
* Exponential in frequency because hearing is: a linear sweep spends most of
|
|
16
|
+
* its travel in a range that sounds like almost nothing is happening. This is
|
|
17
|
+
* the "filter opens as you hit full sprint" moment, so it has to track the
|
|
18
|
+
* *feeling* of accelerating rather than the number.
|
|
19
|
+
*/
|
|
20
|
+
export function cutoffForSpeed(speed, maxSpeed) {
|
|
21
|
+
const safeMax = maxSpeed > 1e-6 ? maxSpeed : 1;
|
|
22
|
+
const t = Math.min(Math.max(speed / safeMax, 0), 1);
|
|
23
|
+
return 320 * (18000 / 320) ** t;
|
|
24
|
+
}
|
|
25
|
+
export function clamp01(value) {
|
|
26
|
+
return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The lift, at rest and at full.
|
|
30
|
+
*
|
|
31
|
+
* 20 Hz is below hearing, so at rest the high-pass is not there. 340 Hz takes the kick
|
|
32
|
+
* and the bass line out while leaving the body of the track — far enough that the ground
|
|
33
|
+
* goes with it, short of the telephone-speaker sound a build-up filter reaches for.
|
|
34
|
+
*
|
|
35
|
+
* The duck is what makes room for the wet: at 0.62 the dry track sits back far enough
|
|
36
|
+
* for a six-second tail to be the loudest thing in the air, which is the whole point of
|
|
37
|
+
* having one.
|
|
38
|
+
*/
|
|
39
|
+
export const LIFT_FLOOR_HZ = 20;
|
|
40
|
+
/**
|
|
41
|
+
* The gate slam: how it is shaped, and how hard it hits.
|
|
42
|
+
*
|
|
43
|
+
* `SLAM_SHELF_HZ` is where the boost stops being bass and starts being body — a
|
|
44
|
+
* shelf much above this pulls the kick's click and the bass line's fundamentals up
|
|
45
|
+
* together, which is a volume jump rather than a low-end one.
|
|
46
|
+
*
|
|
47
|
+
* `SLAM_ATTACK_SEC` is deliberately shorter than a frame at 60 Hz. The hit has to
|
|
48
|
+
* land on the tick the character crosses the plane; anything that swells into it reads
|
|
49
|
+
* as the music doing something rather than the gate doing something.
|
|
50
|
+
*
|
|
51
|
+
* `SLAM_DUCK` pulls the dry path down as the wet comes up, so what changes is the
|
|
52
|
+
* *character* of the moment and not simply the volume of it. Without it the slam is
|
|
53
|
+
* a loudness spike, and a loudness spike on every gate is exhausting by the third.
|
|
54
|
+
*/
|
|
55
|
+
export const SLAM_SHELF_HZ = 120;
|
|
56
|
+
export const SLAM_SHELF_DB = 15;
|
|
57
|
+
export const SLAM_DRIVE = 0.7;
|
|
58
|
+
/**
|
|
59
|
+
* Where the wet path's low-pass sits at rest and at the bottom of a slam.
|
|
60
|
+
*
|
|
61
|
+
* Open is above hearing, so the filter is a no-op between gates and the wet path is
|
|
62
|
+
* whatever the shelf made of it. Closed is 240 Hz — bass and the very bottom of the
|
|
63
|
+
* mid, which is the band a kick and a bass line live in and nothing else does.
|
|
64
|
+
*/
|
|
65
|
+
export const SLAM_OPEN_HZ = 20000;
|
|
66
|
+
export const SLAM_CLOSED_HZ = 240;
|
|
67
|
+
/**
|
|
68
|
+
* How far the dry path ducks under the wet.
|
|
69
|
+
*
|
|
70
|
+
* Raised with the low-pass: the point of the pair is that for an instant the score is
|
|
71
|
+
* *only* its bottom end, and that cannot happen while the unfiltered track is still
|
|
72
|
+
* playing underneath at full level.
|
|
73
|
+
*/
|
|
74
|
+
export const SLAM_DUCK = 0.72;
|
|
75
|
+
export const SLAM_ATTACK_SEC = 0.012;
|
|
76
|
+
export const SLAM_DECAY_SEC = 0.11;
|
|
77
|
+
/**
|
|
78
|
+
* How hard the soft clipper bites, as the `tanh` input scale.
|
|
79
|
+
*
|
|
80
|
+
* At 2.4 a signal at full scale comes back at about 0.4 of the way to a square wave
|
|
81
|
+
* — audibly driven, still recognisably the track. The curve is normalised so unity
|
|
82
|
+
* in is unity out, which keeps the wet path's own level honest.
|
|
83
|
+
*/
|
|
84
|
+
export const SLAM_CLIP_KNEE = 2.4;
|
|
85
|
+
const LIFT_CEILING_HZ = 340;
|
|
86
|
+
const LIFT_DUCK = 0.62;
|
|
87
|
+
/**
|
|
88
|
+
* Where the high-pass sits at a given lift, in hertz.
|
|
89
|
+
*
|
|
90
|
+
* **Geometric, not linear.** Pitch is logarithmic: a linear sweep from 20 Hz to 340
|
|
91
|
+
* spends half its travel between 20 and 180, which is almost entirely below the notes a
|
|
92
|
+
* track is made of — so the first half of a jump would do nothing and the second half
|
|
93
|
+
* would lurch. Geometrically, half a lift is the geometric mean (82 Hz) and every equal
|
|
94
|
+
* step of the knob is an equal musical interval, which is what makes the effect read as a
|
|
95
|
+
* sweep rather than as a switch.
|
|
96
|
+
*/
|
|
97
|
+
export function liftFrequencyHz(amount) {
|
|
98
|
+
return LIFT_FLOOR_HZ * (LIFT_CEILING_HZ / LIFT_FLOOR_HZ) ** clamp01(amount);
|
|
99
|
+
}
|
|
100
|
+
/** How far the dry track ducks at a given lift: 1 on the ground, `LIFT_DUCK` in the air. */
|
|
101
|
+
export function liftGainFor(amount) {
|
|
102
|
+
return 1 - (1 - LIFT_DUCK) * clamp01(amount);
|
|
103
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio formats a slot will accept, in preference order.
|
|
3
|
+
*
|
|
4
|
+
* Opus first because it is the smallest at equal quality and payload budgets
|
|
5
|
+
* are always tight, but nothing requires it: whatever format a sound was made
|
|
6
|
+
* in can be dropped straight in. Every one of these decodes natively in the
|
|
7
|
+
* browsers a WebGL2 game already requires, so demanding a transcode would buy
|
|
8
|
+
* nothing and cost the person making the sound a round trip every time they
|
|
9
|
+
* wanted to hear it in context.
|
|
10
|
+
*/
|
|
11
|
+
export declare const AUDIO_FORMATS: readonly ['opus', 'mp3', 'ogg', 'm4a', 'wav'];
|
|
12
|
+
export type AudioFormat = (typeof AUDIO_FORMATS)[number];
|
|
13
|
+
/**
|
|
14
|
+
* Every filename a named slot answers to. Feed the result to
|
|
15
|
+
* `SoundSource.urls`, which tries them in order and takes the first that
|
|
16
|
+
* loads.
|
|
17
|
+
*/
|
|
18
|
+
export declare function audioCandidateUrls(name: string, baseDir?: string): string[];
|
package/dist/formats.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio formats a slot will accept, in preference order.
|
|
3
|
+
*
|
|
4
|
+
* Opus first because it is the smallest at equal quality and payload budgets
|
|
5
|
+
* are always tight, but nothing requires it: whatever format a sound was made
|
|
6
|
+
* in can be dropped straight in. Every one of these decodes natively in the
|
|
7
|
+
* browsers a WebGL2 game already requires, so demanding a transcode would buy
|
|
8
|
+
* nothing and cost the person making the sound a round trip every time they
|
|
9
|
+
* wanted to hear it in context.
|
|
10
|
+
*/
|
|
11
|
+
export const AUDIO_FORMATS = ['opus', 'mp3', 'ogg', 'm4a', 'wav'];
|
|
12
|
+
/**
|
|
13
|
+
* Every filename a named slot answers to. Feed the result to
|
|
14
|
+
* `SoundSource.urls`, which tries them in order and takes the first that
|
|
15
|
+
* loads.
|
|
16
|
+
*/
|
|
17
|
+
export function audioCandidateUrls(name, baseDir = '/audio') {
|
|
18
|
+
return AUDIO_FORMATS.map((ext) => `${baseDir}/${name}.${ext}`);
|
|
19
|
+
}
|