@driftengine/audio 3.62.0 → 4.0.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/NOTICE +20 -0
- package/README.md +1 -1
- package/dist/filters.d.ts +10 -0
- package/dist/filters.js +10 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/manifest.js +3 -1
- package/dist/mix/defaultLayout.d.ts +4 -4
- package/dist/mix/defaultLayout.js +6 -1
- package/dist/mix/inserts.d.ts +22 -2
- package/dist/mix/inserts.js +26 -6
- package/dist/registry.js +1 -0
- package/dist/session.js +2 -0
- package/package.json +1 -1
- package/src/filters.ts +11 -0
- package/src/index.ts +1 -1
- package/src/manifest.ts +1 -0
- package/src/mix/defaultLayout.ts +10 -5
- package/src/mix/inserts.ts +48 -6
- package/src/registry.ts +1 -0
- package/src/session.ts +2 -0
package/NOTICE
CHANGED
|
@@ -7,3 +7,23 @@ This product includes software developed at Drift Technologies
|
|
|
7
7
|
Licensed under the Apache License, Version 2.0. Section 4(d) of that licence
|
|
8
8
|
requires this NOTICE to be reproduced in any derivative work you distribute,
|
|
9
9
|
in the source, the documentation, or a display generated by the work.
|
|
10
|
+
|
|
11
|
+
Models run by DriftCapture
|
|
12
|
+
--------------------------
|
|
13
|
+
DriftCapture runs the models below. Their weights are not in this repository
|
|
14
|
+
and are not distributed with it: tools/capture-weights/ fetches each at the
|
|
15
|
+
revision its manifest pins, verifies it, and converts it, and a game that
|
|
16
|
+
ships one ships it under that model's own licence. Each model's definition in
|
|
17
|
+
this engine is written from its project's published code.
|
|
18
|
+
|
|
19
|
+
- Depth Anything 3 Small and Depth Anything 3 Base. Apache License 2.0.
|
|
20
|
+
Copyright (c) 2025 ByteDance Ltd. and/or its affiliates.
|
|
21
|
+
- Depth Anything V2 Small. Apache License 2.0. The University of Hong Kong
|
|
22
|
+
and TikTok; converted to the Transformers layout by The HuggingFace Team.
|
|
23
|
+
- MobileSAM. Apache License 2.0. Its image encoder is TinyViT, Copyright (c)
|
|
24
|
+
2022 Microsoft; its prompt encoder and mask decoder are Segment Anything's,
|
|
25
|
+
Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
26
|
+
- SAM 2.1 tiny. Apache License 2.0. Copyright (c) Meta Platforms, Inc. and
|
|
27
|
+
affiliates.
|
|
28
|
+
- OWLv2 base patch16. Apache License 2.0. Copyright 2023 Google AI and The
|
|
29
|
+
HuggingFace Team.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Layered stems, synthesis, rhythm and kick detection, distance and stereo placeme
|
|
|
4
4
|
sources, reverb zones a **source** can be inside as well as the listener, and first-order
|
|
5
5
|
ambisonic soundfields decoded through six virtual speakers.
|
|
6
6
|
|
|
7
|
-
**Cost: 6.
|
|
7
|
+
**Cost: 6.1 KB gzipped on top of core.** Measured by `scripts/size-gate.test.mjs`, which fails if it drifts more than
|
|
8
8
|
3% — the number is a fact about the build rather than a claim in a document.
|
|
9
9
|
|
|
10
10
|
Part of [DriftEngine](../../README.md). See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md)
|
package/dist/filters.d.ts
CHANGED
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
* *feeling* of accelerating rather than the number.
|
|
19
19
|
*/
|
|
20
20
|
export declare function cutoffForSpeed(speed: number, maxSpeed: number): number;
|
|
21
|
+
/**
|
|
22
|
+
* Where the master low-pass stands until somebody moves it: open, at the top of its range.
|
|
23
|
+
*
|
|
24
|
+
* **It stood at `cutoffForSpeed(0, 1)` — 320 Hz — until 2026-09-19**, so every mix was heard
|
|
25
|
+
* through a wall until its caller drove the speed curve above: one game's "the filter opens as you
|
|
26
|
+
* sprint" standing in as every consumer's default. A game that wants its world muffled at rest
|
|
27
|
+
* says so with `setCutoff(cutoffForSpeed(0, max))`, and one that never thought about it hears its
|
|
28
|
+
* mix.
|
|
29
|
+
*/
|
|
30
|
+
export declare const MASTER_OPEN_HZ = 20000;
|
|
21
31
|
export declare function clamp01(value: number): number;
|
|
22
32
|
/**
|
|
23
33
|
* The lift, at rest and at full.
|
package/dist/filters.js
CHANGED
|
@@ -22,6 +22,16 @@ export function cutoffForSpeed(speed, maxSpeed) {
|
|
|
22
22
|
const t = Math.min(Math.max(speed / safeMax, 0), 1);
|
|
23
23
|
return 320 * (18000 / 320) ** t;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Where the master low-pass stands until somebody moves it: open, at the top of its range.
|
|
27
|
+
*
|
|
28
|
+
* **It stood at `cutoffForSpeed(0, 1)` — 320 Hz — until 2026-09-19**, so every mix was heard
|
|
29
|
+
* through a wall until its caller drove the speed curve above: one game's "the filter opens as you
|
|
30
|
+
* sprint" standing in as every consumer's default. A game that wants its world muffled at rest
|
|
31
|
+
* says so with `setCutoff(cutoffForSpeed(0, max))`, and one that never thought about it hears its
|
|
32
|
+
* mix.
|
|
33
|
+
*/
|
|
34
|
+
export const MASTER_OPEN_HZ = 20000;
|
|
25
35
|
export function clamp01(value) {
|
|
26
36
|
return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0;
|
|
27
37
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { SoundRegistry } from './registry.ts';
|
|
9
9
|
export { AudioGraph } from './graph.ts';
|
|
10
|
-
export { cutoffForSpeed } from './filters.ts';
|
|
10
|
+
export { MASTER_OPEN_HZ, cutoffForSpeed } from './filters.ts';
|
|
11
11
|
export { audioContextConstructor, claimPlaybackSession } from './session.ts';
|
|
12
12
|
export { AUDIO_FORMATS, audioCandidateUrls } from './formats.ts';
|
|
13
13
|
export { fetchAudioManifest, parseAudioManifest } from './manifest.ts';
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { SoundRegistry } from './registry.js';
|
|
9
9
|
export { AudioGraph } from './graph.js';
|
|
10
|
-
export { cutoffForSpeed } from './filters.js';
|
|
10
|
+
export { MASTER_OPEN_HZ, cutoffForSpeed } from './filters.js';
|
|
11
11
|
/* The two lines that make Web Audio audible on an iPhone, for a consumer whose audio is one
|
|
12
12
|
decoded file through its own chain rather than a stem player. `AudioGraph` already calls both
|
|
13
13
|
and in this order; anything building its own context has to do the same. */
|
package/dist/manifest.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* that some of them are music.
|
|
16
16
|
*/
|
|
17
17
|
/** Every name in a manifest, or an empty list if there is not one. */
|
|
18
|
-
export async function fetchAudioManifest(url,
|
|
18
|
+
export async function fetchAudioManifest(url,
|
|
19
|
+
// platform: browser default — `FetchLike` is the seam and this is its default
|
|
20
|
+
fetchImpl = (target) => fetch(target)) {
|
|
19
21
|
let parsed;
|
|
20
22
|
try {
|
|
21
23
|
const response = await fetchImpl(url);
|
|
@@ -7,10 +7,10 @@ import { type DelayInsert } from './returns.ts';
|
|
|
7
7
|
* The mix as it has always been, expressed in buses and inserts.
|
|
8
8
|
*
|
|
9
9
|
* ```
|
|
10
|
-
* music ─ [lift] ─ tap ─ [slam] ─┐
|
|
11
|
-
* └─ sends from tap
|
|
12
|
-
* effects
|
|
13
|
-
* reverb · longReverb · delay
|
|
10
|
+
* music ─ [align] ─ [lift] ─ tap ─ [slam] ─┐
|
|
11
|
+
* └─ sends from tap ─────────────────────┼→ master ─ [master lowpass] ─ out ─→ destination
|
|
12
|
+
* effects ───────────────────────────────── ┘ ↑
|
|
13
|
+
* reverb · longReverb · delay ──────────────────────────────────────────────────┘
|
|
14
14
|
* ```
|
|
15
15
|
*
|
|
16
16
|
* **This is a transcription and it is meant to stay one.** Every level, every frequency and every
|
|
@@ -22,9 +22,14 @@ export function defaultLayout(mix, levels) {
|
|
|
22
22
|
* muting the score while keeping the game audible is the single most-used audio setting there is.
|
|
23
23
|
*/
|
|
24
24
|
const music = mix.bus('music', { level: levels.music });
|
|
25
|
+
const slam = slamInsert(mix.context, () => mix.scheduleAt());
|
|
26
|
+
/*
|
|
27
|
+
* The slam's wet arm is late by its shaper's latency and meets the dry arm and every send at the
|
|
28
|
+
* output, so all of them wait for it here, at the head of the bus. See `SlamInsert.align`.
|
|
29
|
+
*/
|
|
30
|
+
music.insert(slam.align);
|
|
25
31
|
const lift = liftInsert(mix.context, () => mix.scheduleAt());
|
|
26
32
|
music.insert(lift);
|
|
27
|
-
const slam = slamInsert(mix.context, () => mix.scheduleAt());
|
|
28
33
|
/*
|
|
29
34
|
* Below the tap, so the sends never hear it: a six-second convolution of a clipped bass hit is a
|
|
30
35
|
* mess, and it would still be arriving three gates later.
|
package/dist/mix/inserts.d.ts
CHANGED
|
@@ -42,6 +42,26 @@ export interface SlamInsert extends MixInsert {
|
|
|
42
42
|
* been removed, and would be at its weakest where half the gates are taken.
|
|
43
43
|
*/
|
|
44
44
|
readonly wetInput: AudioNode;
|
|
45
|
+
/**
|
|
46
|
+
* The wet arm's latency, given to everything the wet arm is summed with: a stage for the head of
|
|
47
|
+
* the bus, fed from the node `wetInput` is, ahead of the dry arm and of every send.
|
|
48
|
+
*
|
|
49
|
+
* The wet arm's shaper is oversampled, and an oversampler is a pair of resampling filters that
|
|
50
|
+
* delay what passes by an amount no specification fixes — measured 2026-09-19, 192 samples at 4x
|
|
51
|
+
* in Chrome and 128 in `node-web-audio-api`. With nothing on the other side waiting for it, a
|
|
52
|
+
* strike combed against the very score it was blended into: 1.6 dB of a full strike cancelled in
|
|
53
|
+
* Chrome, and a different comb on each implementation. So this is the same oversampling around a
|
|
54
|
+
* curve that changes nothing, and it is as late as the wet arm wherever it runs, with nothing
|
|
55
|
+
* measured and nothing looked up.
|
|
56
|
+
*
|
|
57
|
+
* **What it costs.** The bus it heads is late by that latency, about four milliseconds, against
|
|
58
|
+
* every bus it does not head; it is band-limited as the oversampler band-limits, which is
|
|
59
|
+
* −98 dB of difference from a plain delay up to 20 kHz, measured in Chrome; and the bus pays for
|
|
60
|
+
* a second oversampler, about 7 ms of render per two seconds of mono there. What would make it
|
|
61
|
+
* wrong: an implementation whose oversampling latency depends on the curve, or a shaper that
|
|
62
|
+
* reports its latency, which would let a plain `DelayNode` do this for less.
|
|
63
|
+
*/
|
|
64
|
+
readonly align: MixInsert;
|
|
45
65
|
/** The dry path's duck and the wet path's blend, exposed so a test can assert an idle stage. */
|
|
46
66
|
readonly dryGain: GainNode;
|
|
47
67
|
readonly wetGain: GainNode;
|
|
@@ -58,7 +78,7 @@ export interface SlamInsert extends MixInsert {
|
|
|
58
78
|
* music path colours the score for the whole run — a track mastered near full scale is already
|
|
59
79
|
* touching any knee low enough to be useful — so the effect would stop being an event and become
|
|
60
80
|
* the sound of the game. With a dry path at unity and a wet path at zero, an idle graph is
|
|
61
|
-
* sample-identical to one without this stage in it
|
|
62
|
-
* across a rewrite that moved it into a bus.
|
|
81
|
+
* sample-identical to one without this stage in it but for `align`, which is what lets the identity
|
|
82
|
+
* gate pass across a rewrite that moved it into a bus.
|
|
63
83
|
*/
|
|
64
84
|
export declare function slamInsert(context: BaseAudioContext, scheduleAt: ScheduleClock): SlamInsert;
|
package/dist/mix/inserts.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
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,
|
|
1
|
+
import { LIFT_FLOOR_HZ, MASTER_OPEN_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, liftFrequencyHz, liftGainFor, } from '../filters.js';
|
|
2
2
|
/**
|
|
3
3
|
* The stages a bus can carry, as things rather than as lines in one constructor.
|
|
4
4
|
*
|
|
@@ -24,14 +24,14 @@ function ramp(param, value, scheduleAt) {
|
|
|
24
24
|
export function masterFilterInsert(context, scheduleAt) {
|
|
25
25
|
const filter = context.createBiquadFilter();
|
|
26
26
|
filter.type = 'lowpass';
|
|
27
|
-
filter.frequency.value =
|
|
27
|
+
filter.frequency.value = MASTER_OPEN_HZ;
|
|
28
28
|
filter.Q.value = 0.7;
|
|
29
29
|
return {
|
|
30
30
|
input: filter,
|
|
31
31
|
output: filter,
|
|
32
32
|
filter,
|
|
33
33
|
setCutoff(hz) {
|
|
34
|
-
ramp(filter.frequency, Math.min(Math.max(hz, 40),
|
|
34
|
+
ramp(filter.frequency, Math.min(Math.max(hz, 40), MASTER_OPEN_HZ), scheduleAt);
|
|
35
35
|
},
|
|
36
36
|
/*
|
|
37
37
|
* Not a second filter in the chain: the master low-pass is already there and already ramped, so
|
|
@@ -82,6 +82,15 @@ export function liftInsert(context, scheduleAt) {
|
|
|
82
82
|
},
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
|
+
/** The wet arm's oversampling, which `align` repeats on the other side. */
|
|
86
|
+
const SLAM_OVERSAMPLE = '4x';
|
|
87
|
+
/**
|
|
88
|
+
* How far from zero `align` passes a signal unchanged: 18 dB above full scale, for a bus that sums
|
|
89
|
+
* stems each mastered near it. What it gives up is the floor — the curve is looked up at a position
|
|
90
|
+
* scaled down by this, which rounds, measured at −123 dB on a sine at half scale in Chrome; the
|
|
91
|
+
* range is a power of two so the scaling itself is exact.
|
|
92
|
+
*/
|
|
93
|
+
const ALIGN_RANGE = 8;
|
|
85
94
|
/**
|
|
86
95
|
* The slam: a parallel band of driven low end, blended in for a fraction of a second and gone.
|
|
87
96
|
*
|
|
@@ -93,10 +102,20 @@ export function liftInsert(context, scheduleAt) {
|
|
|
93
102
|
* music path colours the score for the whole run — a track mastered near full scale is already
|
|
94
103
|
* touching any knee low enough to be useful — so the effect would stop being an event and become
|
|
95
104
|
* the sound of the game. With a dry path at unity and a wet path at zero, an idle graph is
|
|
96
|
-
* sample-identical to one without this stage in it
|
|
97
|
-
* across a rewrite that moved it into a bus.
|
|
105
|
+
* sample-identical to one without this stage in it but for `align`, which is what lets the identity
|
|
106
|
+
* gate pass across a rewrite that moved it into a bus.
|
|
98
107
|
*/
|
|
99
108
|
export function slamInsert(context, scheduleAt) {
|
|
109
|
+
/*
|
|
110
|
+
* A two-point curve is a straight line from end to end, so scaled down by the range going in and
|
|
111
|
+
* back up by it coming out, it is the identity for anything within ±`ALIGN_RANGE`.
|
|
112
|
+
*/
|
|
113
|
+
const alignInput = context.createGain();
|
|
114
|
+
alignInput.gain.value = 1 / ALIGN_RANGE;
|
|
115
|
+
const align = context.createWaveShaper();
|
|
116
|
+
align.curve = new Float32Array([-ALIGN_RANGE, ALIGN_RANGE]);
|
|
117
|
+
align.oversample = SLAM_OVERSAMPLE;
|
|
118
|
+
alignInput.connect(align);
|
|
100
119
|
const dry = context.createGain();
|
|
101
120
|
dry.gain.value = 1;
|
|
102
121
|
const output = context.createGain();
|
|
@@ -122,7 +141,7 @@ export function slamInsert(context, scheduleAt) {
|
|
|
122
141
|
shaper.curve = softClipCurve();
|
|
123
142
|
// The clip generates harmonics well above the band it came from; without oversampling those
|
|
124
143
|
// alias back down as grit that does not belong to the hit.
|
|
125
|
-
shaper.oversample =
|
|
144
|
+
shaper.oversample = SLAM_OVERSAMPLE;
|
|
126
145
|
const wet = context.createGain();
|
|
127
146
|
wet.gain.value = 0;
|
|
128
147
|
shelf.connect(lowpass);
|
|
@@ -134,6 +153,7 @@ export function slamInsert(context, scheduleAt) {
|
|
|
134
153
|
input: dry,
|
|
135
154
|
output,
|
|
136
155
|
wetInput: shelf,
|
|
156
|
+
align: { input: alignInput, output: align },
|
|
137
157
|
dryGain: dry,
|
|
138
158
|
wetGain: wet,
|
|
139
159
|
/**
|
package/dist/registry.js
CHANGED
|
@@ -5,6 +5,7 @@ export class SoundRegistry {
|
|
|
5
5
|
origins = new Map();
|
|
6
6
|
/** Slots whose stand-in threw. Empty is the normal state; see `unbuilt`. */
|
|
7
7
|
failed = new Map();
|
|
8
|
+
// platform: browser default — `FetchLike` is the seam and this is its default
|
|
8
9
|
constructor(fetchImpl = (url) => fetch(url)) {
|
|
9
10
|
this.fetchImpl = fetchImpl;
|
|
10
11
|
}
|
package/dist/session.js
CHANGED
|
@@ -43,8 +43,10 @@
|
|
|
43
43
|
* that goes quiet in a pocket.
|
|
44
44
|
*/
|
|
45
45
|
export function claimPlaybackSession() {
|
|
46
|
+
// platform: feature probe — absent is a defined state, and the line below reads it
|
|
46
47
|
if (typeof navigator === 'undefined')
|
|
47
48
|
return;
|
|
49
|
+
// platform: feature probe — guarded by the line above; absent is a defined state
|
|
48
50
|
const session = navigator.audioSession;
|
|
49
51
|
if (session === undefined || session === null)
|
|
50
52
|
return;
|
package/package.json
CHANGED
package/src/filters.ts
CHANGED
|
@@ -24,6 +24,17 @@ export function cutoffForSpeed(speed: number, maxSpeed: number): number {
|
|
|
24
24
|
return 320 * (18000 / 320) ** t;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Where the master low-pass stands until somebody moves it: open, at the top of its range.
|
|
29
|
+
*
|
|
30
|
+
* **It stood at `cutoffForSpeed(0, 1)` — 320 Hz — until 2026-09-19**, so every mix was heard
|
|
31
|
+
* through a wall until its caller drove the speed curve above: one game's "the filter opens as you
|
|
32
|
+
* sprint" standing in as every consumer's default. A game that wants its world muffled at rest
|
|
33
|
+
* says so with `setCutoff(cutoffForSpeed(0, max))`, and one that never thought about it hears its
|
|
34
|
+
* mix.
|
|
35
|
+
*/
|
|
36
|
+
export const MASTER_OPEN_HZ = 20000;
|
|
37
|
+
|
|
27
38
|
export function clamp01(value: number): number {
|
|
28
39
|
return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0;
|
|
29
40
|
}
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
export { SoundRegistry } from './registry.ts';
|
|
10
10
|
export { AudioGraph } from './graph.ts';
|
|
11
|
-
export { cutoffForSpeed } from './filters.ts';
|
|
11
|
+
export { MASTER_OPEN_HZ, cutoffForSpeed } from './filters.ts';
|
|
12
12
|
/* The two lines that make Web Audio audible on an iPhone, for a consumer whose audio is one
|
|
13
13
|
decoded file through its own chain rather than a stem player. `AudioGraph` already calls both
|
|
14
14
|
and in this order; anything building its own context has to do the same. */
|
package/src/manifest.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { FetchLike } from './registry.ts';
|
|
|
20
20
|
/** Every name in a manifest, or an empty list if there is not one. */
|
|
21
21
|
export async function fetchAudioManifest(
|
|
22
22
|
url: string,
|
|
23
|
+
// platform: browser default — `FetchLike` is the seam and this is its default
|
|
23
24
|
fetchImpl: FetchLike = (target) => fetch(target),
|
|
24
25
|
): Promise<string[]> {
|
|
25
26
|
let parsed: unknown;
|
package/src/mix/defaultLayout.ts
CHANGED
|
@@ -15,10 +15,10 @@ import { convolverInsert, delayInsert, type DelayInsert } from './returns.ts';
|
|
|
15
15
|
* The mix as it has always been, expressed in buses and inserts.
|
|
16
16
|
*
|
|
17
17
|
* ```
|
|
18
|
-
* music ─ [lift] ─ tap ─ [slam] ─┐
|
|
19
|
-
* └─ sends from tap
|
|
20
|
-
* effects
|
|
21
|
-
* reverb · longReverb · delay
|
|
18
|
+
* music ─ [align] ─ [lift] ─ tap ─ [slam] ─┐
|
|
19
|
+
* └─ sends from tap ─────────────────────┼→ master ─ [master lowpass] ─ out ─→ destination
|
|
20
|
+
* effects ───────────────────────────────── ┘ ↑
|
|
21
|
+
* reverb · longReverb · delay ──────────────────────────────────────────────────┘
|
|
22
22
|
* ```
|
|
23
23
|
*
|
|
24
24
|
* **This is a transcription and it is meant to stay one.** Every level, every frequency and every
|
|
@@ -67,9 +67,14 @@ export function defaultLayout(mix: MixConsole, levels: MixLevels): DefaultLayout
|
|
|
67
67
|
* muting the score while keeping the game audible is the single most-used audio setting there is.
|
|
68
68
|
*/
|
|
69
69
|
const music = mix.bus('music', { level: levels.music });
|
|
70
|
+
const slam = slamInsert(mix.context, () => mix.scheduleAt());
|
|
71
|
+
/*
|
|
72
|
+
* The slam's wet arm is late by its shaper's latency and meets the dry arm and every send at the
|
|
73
|
+
* output, so all of them wait for it here, at the head of the bus. See `SlamInsert.align`.
|
|
74
|
+
*/
|
|
75
|
+
music.insert(slam.align);
|
|
70
76
|
const lift = liftInsert(mix.context, () => mix.scheduleAt());
|
|
71
77
|
music.insert(lift);
|
|
72
|
-
const slam = slamInsert(mix.context, () => mix.scheduleAt());
|
|
73
78
|
/*
|
|
74
79
|
* Below the tap, so the sends never hear it: a six-second convolution of a clipped bass hit is a
|
|
75
80
|
* mess, and it would still be arriving three gates later.
|
package/src/mix/inserts.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ScheduleClock } from '../ambientLoop.ts';
|
|
2
2
|
import {
|
|
3
3
|
LIFT_FLOOR_HZ,
|
|
4
|
+
MASTER_OPEN_HZ,
|
|
4
5
|
SLAM_ATTACK_SEC,
|
|
5
6
|
SLAM_CLIP_KNEE,
|
|
6
7
|
SLAM_CLOSED_HZ,
|
|
@@ -11,7 +12,6 @@ import {
|
|
|
11
12
|
SLAM_SHELF_DB,
|
|
12
13
|
SLAM_SHELF_HZ,
|
|
13
14
|
clamp01,
|
|
14
|
-
cutoffForSpeed,
|
|
15
15
|
liftFrequencyHz,
|
|
16
16
|
liftGainFor,
|
|
17
17
|
} from '../filters.ts';
|
|
@@ -54,14 +54,14 @@ export function masterFilterInsert(
|
|
|
54
54
|
): MasterFilterInsert {
|
|
55
55
|
const filter = context.createBiquadFilter();
|
|
56
56
|
filter.type = 'lowpass';
|
|
57
|
-
filter.frequency.value =
|
|
57
|
+
filter.frequency.value = MASTER_OPEN_HZ;
|
|
58
58
|
filter.Q.value = 0.7;
|
|
59
59
|
return {
|
|
60
60
|
input: filter,
|
|
61
61
|
output: filter,
|
|
62
62
|
filter,
|
|
63
63
|
setCutoff(hz: number): void {
|
|
64
|
-
ramp(filter.frequency, Math.min(Math.max(hz, 40),
|
|
64
|
+
ramp(filter.frequency, Math.min(Math.max(hz, 40), MASTER_OPEN_HZ), scheduleAt);
|
|
65
65
|
},
|
|
66
66
|
/*
|
|
67
67
|
* Not a second filter in the chain: the master low-pass is already there and already ramped, so
|
|
@@ -128,12 +128,42 @@ export interface SlamInsert extends MixInsert {
|
|
|
128
128
|
* been removed, and would be at its weakest where half the gates are taken.
|
|
129
129
|
*/
|
|
130
130
|
readonly wetInput: AudioNode;
|
|
131
|
+
/**
|
|
132
|
+
* The wet arm's latency, given to everything the wet arm is summed with: a stage for the head of
|
|
133
|
+
* the bus, fed from the node `wetInput` is, ahead of the dry arm and of every send.
|
|
134
|
+
*
|
|
135
|
+
* The wet arm's shaper is oversampled, and an oversampler is a pair of resampling filters that
|
|
136
|
+
* delay what passes by an amount no specification fixes — measured 2026-09-19, 192 samples at 4x
|
|
137
|
+
* in Chrome and 128 in `node-web-audio-api`. With nothing on the other side waiting for it, a
|
|
138
|
+
* strike combed against the very score it was blended into: 1.6 dB of a full strike cancelled in
|
|
139
|
+
* Chrome, and a different comb on each implementation. So this is the same oversampling around a
|
|
140
|
+
* curve that changes nothing, and it is as late as the wet arm wherever it runs, with nothing
|
|
141
|
+
* measured and nothing looked up.
|
|
142
|
+
*
|
|
143
|
+
* **What it costs.** The bus it heads is late by that latency, about four milliseconds, against
|
|
144
|
+
* every bus it does not head; it is band-limited as the oversampler band-limits, which is
|
|
145
|
+
* −98 dB of difference from a plain delay up to 20 kHz, measured in Chrome; and the bus pays for
|
|
146
|
+
* a second oversampler, about 7 ms of render per two seconds of mono there. What would make it
|
|
147
|
+
* wrong: an implementation whose oversampling latency depends on the curve, or a shaper that
|
|
148
|
+
* reports its latency, which would let a plain `DelayNode` do this for less.
|
|
149
|
+
*/
|
|
150
|
+
readonly align: MixInsert;
|
|
131
151
|
/** The dry path's duck and the wet path's blend, exposed so a test can assert an idle stage. */
|
|
132
152
|
readonly dryGain: GainNode;
|
|
133
153
|
readonly wetGain: GainNode;
|
|
134
154
|
strike(amount: number): void;
|
|
135
155
|
}
|
|
136
156
|
|
|
157
|
+
/** The wet arm's oversampling, which `align` repeats on the other side. */
|
|
158
|
+
const SLAM_OVERSAMPLE: OverSampleType = '4x';
|
|
159
|
+
/**
|
|
160
|
+
* How far from zero `align` passes a signal unchanged: 18 dB above full scale, for a bus that sums
|
|
161
|
+
* stems each mastered near it. What it gives up is the floor — the curve is looked up at a position
|
|
162
|
+
* scaled down by this, which rounds, measured at −123 dB on a sine at half scale in Chrome; the
|
|
163
|
+
* range is a power of two so the scaling itself is exact.
|
|
164
|
+
*/
|
|
165
|
+
const ALIGN_RANGE = 8;
|
|
166
|
+
|
|
137
167
|
/**
|
|
138
168
|
* The slam: a parallel band of driven low end, blended in for a fraction of a second and gone.
|
|
139
169
|
*
|
|
@@ -145,10 +175,21 @@ export interface SlamInsert extends MixInsert {
|
|
|
145
175
|
* music path colours the score for the whole run — a track mastered near full scale is already
|
|
146
176
|
* touching any knee low enough to be useful — so the effect would stop being an event and become
|
|
147
177
|
* 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
|
|
149
|
-
* across a rewrite that moved it into a bus.
|
|
178
|
+
* sample-identical to one without this stage in it but for `align`, which is what lets the identity
|
|
179
|
+
* gate pass across a rewrite that moved it into a bus.
|
|
150
180
|
*/
|
|
151
181
|
export function slamInsert(context: BaseAudioContext, scheduleAt: ScheduleClock): SlamInsert {
|
|
182
|
+
/*
|
|
183
|
+
* A two-point curve is a straight line from end to end, so scaled down by the range going in and
|
|
184
|
+
* back up by it coming out, it is the identity for anything within ±`ALIGN_RANGE`.
|
|
185
|
+
*/
|
|
186
|
+
const alignInput = context.createGain();
|
|
187
|
+
alignInput.gain.value = 1 / ALIGN_RANGE;
|
|
188
|
+
const align = context.createWaveShaper();
|
|
189
|
+
align.curve = new Float32Array([-ALIGN_RANGE, ALIGN_RANGE]);
|
|
190
|
+
align.oversample = SLAM_OVERSAMPLE;
|
|
191
|
+
alignInput.connect(align);
|
|
192
|
+
|
|
152
193
|
const dry = context.createGain();
|
|
153
194
|
dry.gain.value = 1;
|
|
154
195
|
const output = context.createGain();
|
|
@@ -175,7 +216,7 @@ export function slamInsert(context: BaseAudioContext, scheduleAt: ScheduleClock)
|
|
|
175
216
|
shaper.curve = softClipCurve();
|
|
176
217
|
// The clip generates harmonics well above the band it came from; without oversampling those
|
|
177
218
|
// alias back down as grit that does not belong to the hit.
|
|
178
|
-
shaper.oversample =
|
|
219
|
+
shaper.oversample = SLAM_OVERSAMPLE;
|
|
179
220
|
const wet = context.createGain();
|
|
180
221
|
wet.gain.value = 0;
|
|
181
222
|
|
|
@@ -189,6 +230,7 @@ export function slamInsert(context: BaseAudioContext, scheduleAt: ScheduleClock)
|
|
|
189
230
|
input: dry,
|
|
190
231
|
output,
|
|
191
232
|
wetInput: shelf,
|
|
233
|
+
align: { input: alignInput, output: align },
|
|
192
234
|
dryGain: dry,
|
|
193
235
|
wetGain: wet,
|
|
194
236
|
/**
|
package/src/registry.ts
CHANGED
|
@@ -40,6 +40,7 @@ export class SoundRegistry {
|
|
|
40
40
|
/** Slots whose stand-in threw. Empty is the normal state; see `unbuilt`. */
|
|
41
41
|
private readonly failed = new Map<SoundSlot, string>();
|
|
42
42
|
|
|
43
|
+
// platform: browser default — `FetchLike` is the seam and this is its default
|
|
43
44
|
constructor(private readonly fetchImpl: FetchLike = (url) => fetch(url)) {}
|
|
44
45
|
|
|
45
46
|
register(slot: SoundSlot, source: SoundSource): void {
|
package/src/session.ts
CHANGED
|
@@ -54,7 +54,9 @@ interface AudioSessionLike {
|
|
|
54
54
|
* that goes quiet in a pocket.
|
|
55
55
|
*/
|
|
56
56
|
export function claimPlaybackSession(): void {
|
|
57
|
+
// platform: feature probe — absent is a defined state, and the line below reads it
|
|
57
58
|
if (typeof navigator === 'undefined') return;
|
|
59
|
+
// platform: feature probe — guarded by the line above; absent is a defined state
|
|
58
60
|
const session = (navigator as Navigator & { audioSession?: AudioSessionLike }).audioSession;
|
|
59
61
|
if (session === undefined || session === null) return;
|
|
60
62
|
try {
|