@driftengine/audio 3.63.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 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.0 KB gzipped on top of core.** Measured by `scripts/size-gate.test.mjs`, which fails if it drifts more than
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, fetchImpl = (target) => fetch(target)) {
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 ───────────┼→ master ─ [master lowpass] ─ out ─→ destination
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.
@@ -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, which is what lets the identity gate pass
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;
@@ -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, cutoffForSpeed, liftFrequencyHz, liftGainFor, } from '../filters.js';
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 = cutoffForSpeed(0, 1);
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), 20000), scheduleAt);
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, which is what lets the identity gate pass
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 = '4x';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/audio",
3
- "version": "3.63.0",
3
+ "version": "4.0.0",
4
4
  "description": "Layered stems, synthesis, rhythm analysis and positional placement",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
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;
@@ -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 ───────────┼→ master ─ [master lowpass] ─ out ─→ destination
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.
@@ -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 = cutoffForSpeed(0, 1);
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), 20000), scheduleAt);
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, which is what lets the identity gate pass
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 = '4x';
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 {