@driftengine/audio 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +11 -0
  4. package/dist/ambientLoop.d.ts +45 -0
  5. package/dist/ambientLoop.js +88 -0
  6. package/dist/audioHarness.d.ts +180 -0
  7. package/dist/audioHarness.js +244 -0
  8. package/dist/filters.d.ts +91 -0
  9. package/dist/filters.js +103 -0
  10. package/dist/formats.d.ts +18 -0
  11. package/dist/formats.js +19 -0
  12. package/dist/graph.d.ts +406 -0
  13. package/dist/graph.js +656 -0
  14. package/dist/index.d.ts +47 -0
  15. package/dist/index.js +39 -0
  16. package/dist/manifest.d.ts +28 -0
  17. package/dist/manifest.js +71 -0
  18. package/dist/mix/bus.d.ts +203 -0
  19. package/dist/mix/bus.js +293 -0
  20. package/dist/mix/console.d.ts +96 -0
  21. package/dist/mix/console.js +131 -0
  22. package/dist/mix/defaultLayout.d.ts +37 -0
  23. package/dist/mix/defaultLayout.js +63 -0
  24. package/dist/mix/inserts.d.ts +64 -0
  25. package/dist/mix/inserts.js +187 -0
  26. package/dist/mix/returns.d.ts +38 -0
  27. package/dist/mix/returns.js +86 -0
  28. package/dist/mix/snapshot.d.ts +30 -0
  29. package/dist/mix/snapshot.js +55 -0
  30. package/dist/positional.d.ts +37 -0
  31. package/dist/positional.js +47 -0
  32. package/dist/registry.d.ts +91 -0
  33. package/dist/registry.js +128 -0
  34. package/dist/rhythm/bands.d.ts +60 -0
  35. package/dist/rhythm/bands.js +12 -0
  36. package/dist/rhythm/beatGrid.d.ts +32 -0
  37. package/dist/rhythm/beatGrid.js +98 -0
  38. package/dist/rhythm/beatMap.d.ts +42 -0
  39. package/dist/rhythm/beatMap.js +405 -0
  40. package/dist/rhythm/kickCore.d.ts +79 -0
  41. package/dist/rhythm/kickCore.js +166 -0
  42. package/dist/rhythm/kickDetector.d.ts +65 -0
  43. package/dist/rhythm/kickDetector.js +202 -0
  44. package/dist/rhythm/renderedPulse.d.ts +15 -0
  45. package/dist/rhythm/renderedPulse.js +138 -0
  46. package/dist/session.d.ts +62 -0
  47. package/dist/session.js +83 -0
  48. package/dist/spatial/ambisonic.d.ts +135 -0
  49. package/dist/spatial/ambisonic.js +299 -0
  50. package/dist/spatial/listener.d.ts +109 -0
  51. package/dist/spatial/listener.js +186 -0
  52. package/dist/spatial/occlusion.d.ts +39 -0
  53. package/dist/spatial/occlusion.js +92 -0
  54. package/dist/spatial/source.d.ts +185 -0
  55. package/dist/spatial/source.js +366 -0
  56. package/dist/spatial/zones.d.ts +129 -0
  57. package/dist/spatial/zones.js +166 -0
  58. package/dist/synth.d.ts +92 -0
  59. package/dist/synth.js +282 -0
  60. package/package.json +54 -0
  61. package/src/ambientLoop.ts +101 -0
  62. package/src/audioHarness.ts +280 -0
  63. package/src/filters.ts +109 -0
  64. package/src/formats.ts +22 -0
  65. package/src/graph.ts +805 -0
  66. package/src/index.ts +84 -0
  67. package/src/manifest.ts +73 -0
  68. package/src/mix/bus.ts +356 -0
  69. package/src/mix/console.ts +181 -0
  70. package/src/mix/defaultLayout.ts +118 -0
  71. package/src/mix/inserts.ts +242 -0
  72. package/src/mix/returns.ts +114 -0
  73. package/src/mix/snapshot.ts +75 -0
  74. package/src/positional.ts +47 -0
  75. package/src/registry.ts +167 -0
  76. package/src/rhythm/bands.ts +45 -0
  77. package/src/rhythm/beatGrid.ts +106 -0
  78. package/src/rhythm/beatMap.ts +514 -0
  79. package/src/rhythm/kickCore.ts +197 -0
  80. package/src/rhythm/kickDetector.ts +233 -0
  81. package/src/rhythm/renderedPulse.ts +147 -0
  82. package/src/session.ts +93 -0
  83. package/src/spatial/ambisonic.ts +358 -0
  84. package/src/spatial/listener.ts +249 -0
  85. package/src/spatial/occlusion.ts +95 -0
  86. package/src/spatial/source.ts +452 -0
  87. package/src/spatial/zones.ts +213 -0
  88. package/src/synth.ts +351 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Named sound slots, resolved once at load: real file if present, synthesised
3
+ * buffer if not.
4
+ *
5
+ * Synthesis is a placeholder, never the destination. Addressing every sound by
6
+ * slot means dropping a file into the assets folder replaces it — no code
7
+ * change, no graph rebuild, no registration step. Until then the game is
8
+ * audible rather than silent, so development is never blocked on assets
9
+ * existing.
10
+ *
11
+ * Game-agnostic on purpose: this knows slots and buffers, not that one of them
12
+ * is a footstep.
13
+ */
14
+ export type SoundSlot = string;
15
+
16
+ export interface SoundSource {
17
+ /**
18
+ * Candidate locations, tried in order; the first that loads wins.
19
+ *
20
+ * A list rather than one path so a slot can accept whatever format the sound
21
+ * actually arrives in. Asking a composer to transcode before they can hear
22
+ * their track in the game is friction with nothing on the other side of it —
23
+ * the browser decodes all of these natively.
24
+ */
25
+ urls: readonly string[];
26
+ /** Built only when no candidate is present or usable. */
27
+ synth: (ctx: BaseAudioContext) => AudioBuffer;
28
+ }
29
+
30
+ /** How a slot ended up being filled. Useful in dev to see what is still synth. */
31
+ export type SoundOrigin = 'file' | 'synth';
32
+
33
+ /** Injectable so the resolution rules can be tested without a network. */
34
+ export type FetchLike = (url: string) => Promise<Response>;
35
+
36
+ export class SoundRegistry {
37
+ private readonly sources = new Map<SoundSlot, SoundSource>();
38
+ private readonly buffers = new Map<SoundSlot, AudioBuffer>();
39
+ private readonly origins = new Map<SoundSlot, SoundOrigin>();
40
+ /** Slots whose stand-in threw. Empty is the normal state; see `unbuilt`. */
41
+ private readonly failed = new Map<SoundSlot, string>();
42
+
43
+ constructor(private readonly fetchImpl: FetchLike = (url) => fetch(url)) {}
44
+
45
+ register(slot: SoundSlot, source: SoundSource): void {
46
+ this.sources.set(slot, source);
47
+ }
48
+
49
+ /** Which slots exist, so callers can pick among them (e.g. a daily track). */
50
+ get slots(): readonly SoundSlot[] {
51
+ return [...this.sources.keys()];
52
+ }
53
+
54
+ /**
55
+ * Slots whose stand-in threw, and what it said.
56
+ *
57
+ * Empty is the normal state. A consumer with a dev overlay should show this: a silent slot is
58
+ * otherwise indistinguishable from one nobody triggered.
59
+ */
60
+ get unbuilt(): ReadonlyMap<SoundSlot, string> {
61
+ return this.failed;
62
+ }
63
+
64
+ get resolved(): ReadonlyMap<SoundSlot, SoundOrigin> {
65
+ return this.origins;
66
+ }
67
+
68
+ /**
69
+ * Adopt already-decoded buffers, skipping every fetch and decode.
70
+ *
71
+ * For building a second graph over the same sounds — an offline render of a mix that
72
+ * is already loaded. An `AudioBuffer` is PCM and a sample rate, not a handle onto the
73
+ * context that made it, so it can be used by any context running at the same rate;
74
+ * decoding the library again would cost the whole payload a second time and, worse,
75
+ * could resolve a slot differently from the mix being reproduced.
76
+ */
77
+ adopt(from: SoundRegistry): void {
78
+ for (const [slot, buffer] of from.buffers) this.buffers.set(slot, buffer);
79
+ for (const [slot, origin] of from.origins) this.origins.set(slot, origin);
80
+ for (const [slot, source] of from.sources) {
81
+ if (!this.sources.has(slot)) this.sources.set(slot, source);
82
+ }
83
+ }
84
+
85
+ get(slot: SoundSlot): AudioBuffer | undefined {
86
+ return this.buffers.get(slot);
87
+ }
88
+
89
+ /**
90
+ * Resolve every slot: the files in parallel, then the stand-ins one at a time.
91
+ *
92
+ * Slots settle independently: one missing or corrupt asset must not silence
93
+ * the rest of the game, which is the likeliest real failure once assets are
94
+ * being dropped in by hand. Every failure mode — network error, HTTP status,
95
+ * undecodable bytes — lands on the same fallback, because from the player's
96
+ * side they are the same event.
97
+ *
98
+ * **The two halves are separated because they cost completely different
99
+ * things.** Fetching is waiting, and twenty slots should wait together.
100
+ * Synthesis is arithmetic on the main thread — an ambience bed is seven
101
+ * seconds of filtered noise at the context's sample rate — and twenty of those
102
+ * settling together lands as one block of work.
103
+ *
104
+ * Which is exactly what it did. Traced on 2026-08-07 on a game that starts its
105
+ * audio at boot, the fallbacks arrived as microtask blocks of 11, 14, 14, 21
106
+ * and 34 ms while the player was already running, and the frame loop went
107
+ * 125 ms between frames because the vsync deadline kept landing inside one.
108
+ * So each stand-in gets its own task. The work is the same; what changes is
109
+ * that a frame can be drawn between any two of them.
110
+ *
111
+ * The yield is a timer rather than a microtask, and that is the whole point —
112
+ * a microtask would rejoin the block it is trying to leave. Whatever the
113
+ * browser clamps the delay to only spaces the work further.
114
+ */
115
+ async load(ctx: BaseAudioContext): Promise<void> {
116
+ const entries = [...this.sources.entries()];
117
+ await Promise.allSettled(entries.map(([slot, source]) => this.loadFile(ctx, slot, source)));
118
+
119
+ for (const [slot, source] of entries) {
120
+ if (this.buffers.has(slot)) continue;
121
+ await new Promise<void>((resolve) => {
122
+ setTimeout(resolve, 0);
123
+ });
124
+ /*
125
+ * **Each stand-in settles on its own, which is what the paragraph above already promised
126
+ * and what this loop did not deliver.** File failures land on `allSettled` and every one of
127
+ * them is contained; a *synth* that threw took the whole `load` down with it — so every slot
128
+ * after it in registration order was left with no buffer, and `play` returns silently on an
129
+ * undefined one. The symptom is a game where the first few sounds work and the rest do not,
130
+ * with nothing in the console after the one throw, and the boundary falling wherever the
131
+ * consumer happened to register the bad slot.
132
+ *
133
+ * Caught rather than rethrown for the same reason a missing file is: from the player's side
134
+ * a slot that cannot be built and one that cannot be fetched are the same event, and neither
135
+ * is worth the rest of the game's audio.
136
+ */
137
+ try {
138
+ this.buffers.set(slot, source.synth(ctx));
139
+ this.origins.set(slot, 'synth');
140
+ } catch (error) {
141
+ this.failed.set(slot, error instanceof Error ? error.message : String(error));
142
+ }
143
+ }
144
+ }
145
+
146
+ /** The first candidate that loads and decodes wins; none of them is normal. */
147
+ private async loadFile(
148
+ ctx: BaseAudioContext,
149
+ slot: SoundSlot,
150
+ source: SoundSource,
151
+ ): Promise<void> {
152
+ for (const url of source.urls) {
153
+ try {
154
+ const response = await this.fetchImpl(url);
155
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
156
+ const bytes = await response.arrayBuffer();
157
+ const buffer = await ctx.decodeAudioData(bytes);
158
+ this.buffers.set(slot, buffer);
159
+ this.origins.set(slot, 'file');
160
+ return;
161
+ } catch {
162
+ // Try the next format. A missing candidate is the normal case, not
163
+ // an error: most slots will only ever have one of these present.
164
+ }
165
+ }
166
+ }
167
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The frequency bands every rhythm detector in the engine reads.
3
+ *
4
+ * One definition, shared by the offline analyser and the live detector, so
5
+ * "a kick" means the same thing whether it is being found ahead of time for an
6
+ * edit or reacted to in the moment for a light. Two sets of numbers that
7
+ * drifted apart would produce a replay whose cuts disagreed with the lights in
8
+ * the very footage they are cutting.
9
+ *
10
+ * The split is finer than "bass / mid / treble" because the hard problem is not
11
+ * finding low energy, it is telling a *kick* apart from a sustained bassline
12
+ * sitting in nearly the same octave. `sub`/`punch`/`sweet` are the kick;
13
+ * `bassline`/`mud` are what has to be subtracted from it.
14
+ */
15
+ export interface Band {
16
+ readonly lowHz: number;
17
+ readonly highHz: number;
18
+ }
19
+
20
+ export const RHYTHM_BANDS = {
21
+ sub: { lowHz: 40, highHz: 60 },
22
+ punch: { lowHz: 60, highHz: 100 },
23
+ sweet: { lowHz: 75, highHz: 110 },
24
+ bassline: { lowHz: 95, highHz: 180 },
25
+ mud: { lowHz: 180, highHz: 420 },
26
+ lowMid: { lowHz: 500, highHz: 2500 },
27
+ high: { lowHz: 5000, highHz: 12000 },
28
+ } as const satisfies Record<string, Band>;
29
+
30
+ export type BandName = keyof typeof RHYTHM_BANDS;
31
+
32
+ /** Energy in each band, 0–1, filled in place so nothing allocates per hop. */
33
+ export interface BandEnergies {
34
+ sub: number;
35
+ punch: number;
36
+ sweet: number;
37
+ bassline: number;
38
+ mud: number;
39
+ lowMid: number;
40
+ high: number;
41
+ }
42
+
43
+ export function createBandEnergies(): BandEnergies {
44
+ return { sub: 0, punch: 0, sweet: 0, bassline: 0, mud: 0, lowMid: 0, high: 0 };
45
+ }
@@ -0,0 +1,106 @@
1
+ import type { BeatMap } from './beatMap.ts';
2
+
3
+ /**
4
+ * A beat map from a tempo the player supplies rather than one we detected.
5
+ *
6
+ * The reason this exists is the workflow, not the maths. On TikTok and Reels
7
+ * the trending sound is added *in the app*, replacing whatever audio the clip
8
+ * arrived with — so the thing that has to line up is not our audio against
9
+ * theirs, it is our *cuts* against their sound. A player who taps along to the
10
+ * track they intend to use gets an edit that fits it, and it works with any
11
+ * song in existence precisely because we never touch the song.
12
+ *
13
+ * The output is an ordinary `BeatMap`, so nothing downstream needs to know
14
+ * where its beats came from.
15
+ */
16
+ export function beatGrid(bpm: number, offsetSec: number, durationSec: number): BeatMap {
17
+ const safeBpm = Number.isFinite(bpm) && bpm > 0 ? bpm : 120;
18
+ const period = 60 / safeBpm;
19
+ const length = Math.max(0, Number.isFinite(durationSec) ? durationSec : 0);
20
+
21
+ /*
22
+ * Wrapped into the first beat. A player nudging the offset will run it past a
23
+ * whole beat without thinking about it, and a grid that then started a second
24
+ * in would silently lose the opening cut.
25
+ */
26
+ const raw = Number.isFinite(offsetSec) ? offsetSec : 0;
27
+ const first = ((raw % period) + period) % period;
28
+
29
+ const count = length <= 0 ? 0 : Math.max(0, Math.floor((length - first) / period) + 1);
30
+ const beats = new Float32Array(count);
31
+ const strength = new Float32Array(count);
32
+ for (let i = 0; i < count; i++) {
33
+ beats[i] = first + i * period;
34
+ // Every fourth beat is the downbeat. Not detected — asserted, because that
35
+ // is what a player tapping four to the bar means by it.
36
+ strength[i] = i % 4 === 0 ? 1 : 0.6;
37
+ }
38
+
39
+ return {
40
+ beats,
41
+ strength,
42
+ bpm: safeBpm,
43
+ bpmConfidence: 1,
44
+ energy: new Float32Array([1]),
45
+ energyHz: 10,
46
+ durationSec: length,
47
+ };
48
+ }
49
+
50
+ /** Taps this far apart are two separate attempts, not one tempo. */
51
+ const STALE_MS = 3000;
52
+ /** Below four taps there are too few intervals to reject a fumble. */
53
+ const MIN_TAPS = 4;
54
+ const MAX_TAPS = 8;
55
+ /** Outside this a "tempo" is a double-tap or somebody who wandered off. */
56
+ const MIN_BPM = 60;
57
+ const MAX_BPM = 200;
58
+
59
+ /**
60
+ * Tempo and phase, from somebody tapping along.
61
+ *
62
+ * Phase is the part that matters. A player could type a BPM; what they cannot
63
+ * type is *where the downbeat falls* in the sound they are about to add, and a
64
+ * grid at the right tempo with the wrong phase is off by up to half a beat
65
+ * everywhere — worse than not syncing at all.
66
+ */
67
+ export class TapTempo {
68
+ private readonly taps: number[] = [];
69
+
70
+ tap(nowMs: number): void {
71
+ const previous = this.taps[this.taps.length - 1];
72
+ // A gap means they stopped and started again. Averaging across it would
73
+ // produce a tempo of a couple of beats a minute.
74
+ if (previous !== undefined && nowMs - previous > STALE_MS) this.taps.length = 0;
75
+ this.taps.push(nowMs);
76
+ if (this.taps.length > MAX_TAPS) this.taps.shift();
77
+ }
78
+
79
+ /** Null until there is enough to be sure, and for anything implausible. */
80
+ get bpm(): number | null {
81
+ if (this.taps.length < MIN_TAPS) return null;
82
+
83
+ const intervals: number[] = [];
84
+ for (let i = 1; i < this.taps.length; i++) {
85
+ intervals.push((this.taps[i] ?? 0) - (this.taps[i - 1] ?? 0));
86
+ }
87
+ // Median, not mean: anybody tapping along will fumble one, and a mean turns
88
+ // a single 400 ms stumble into a double-digit BPM error.
89
+ intervals.sort((a, b) => a - b);
90
+ const median = intervals[intervals.length >> 1] ?? 0;
91
+ if (median <= 0) return null;
92
+
93
+ const bpm = 60_000 / median;
94
+ if (bpm < MIN_BPM || bpm > MAX_BPM) return null;
95
+ return bpm;
96
+ }
97
+
98
+ /** Seconds, from the same clock the taps were given in. */
99
+ get offsetSec(): number {
100
+ return (this.taps[this.taps.length - 1] ?? 0) / 1000;
101
+ }
102
+
103
+ reset(): void {
104
+ this.taps.length = 0;
105
+ }
106
+ }