@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,128 @@
1
+ export class SoundRegistry {
2
+ fetchImpl;
3
+ sources = new Map();
4
+ buffers = new Map();
5
+ origins = new Map();
6
+ /** Slots whose stand-in threw. Empty is the normal state; see `unbuilt`. */
7
+ failed = new Map();
8
+ constructor(fetchImpl = (url) => fetch(url)) {
9
+ this.fetchImpl = fetchImpl;
10
+ }
11
+ register(slot, source) {
12
+ this.sources.set(slot, source);
13
+ }
14
+ /** Which slots exist, so callers can pick among them (e.g. a daily track). */
15
+ get slots() {
16
+ return [...this.sources.keys()];
17
+ }
18
+ /**
19
+ * Slots whose stand-in threw, and what it said.
20
+ *
21
+ * Empty is the normal state. A consumer with a dev overlay should show this: a silent slot is
22
+ * otherwise indistinguishable from one nobody triggered.
23
+ */
24
+ get unbuilt() {
25
+ return this.failed;
26
+ }
27
+ get resolved() {
28
+ return this.origins;
29
+ }
30
+ /**
31
+ * Adopt already-decoded buffers, skipping every fetch and decode.
32
+ *
33
+ * For building a second graph over the same sounds — an offline render of a mix that
34
+ * is already loaded. An `AudioBuffer` is PCM and a sample rate, not a handle onto the
35
+ * context that made it, so it can be used by any context running at the same rate;
36
+ * decoding the library again would cost the whole payload a second time and, worse,
37
+ * could resolve a slot differently from the mix being reproduced.
38
+ */
39
+ adopt(from) {
40
+ for (const [slot, buffer] of from.buffers)
41
+ this.buffers.set(slot, buffer);
42
+ for (const [slot, origin] of from.origins)
43
+ this.origins.set(slot, origin);
44
+ for (const [slot, source] of from.sources) {
45
+ if (!this.sources.has(slot))
46
+ this.sources.set(slot, source);
47
+ }
48
+ }
49
+ get(slot) {
50
+ return this.buffers.get(slot);
51
+ }
52
+ /**
53
+ * Resolve every slot: the files in parallel, then the stand-ins one at a time.
54
+ *
55
+ * Slots settle independently: one missing or corrupt asset must not silence
56
+ * the rest of the game, which is the likeliest real failure once assets are
57
+ * being dropped in by hand. Every failure mode — network error, HTTP status,
58
+ * undecodable bytes — lands on the same fallback, because from the player's
59
+ * side they are the same event.
60
+ *
61
+ * **The two halves are separated because they cost completely different
62
+ * things.** Fetching is waiting, and twenty slots should wait together.
63
+ * Synthesis is arithmetic on the main thread — an ambience bed is seven
64
+ * seconds of filtered noise at the context's sample rate — and twenty of those
65
+ * settling together lands as one block of work.
66
+ *
67
+ * Which is exactly what it did. Traced on 2026-08-07 on a game that starts its
68
+ * audio at boot, the fallbacks arrived as microtask blocks of 11, 14, 14, 21
69
+ * and 34 ms while the player was already running, and the frame loop went
70
+ * 125 ms between frames because the vsync deadline kept landing inside one.
71
+ * So each stand-in gets its own task. The work is the same; what changes is
72
+ * that a frame can be drawn between any two of them.
73
+ *
74
+ * The yield is a timer rather than a microtask, and that is the whole point —
75
+ * a microtask would rejoin the block it is trying to leave. Whatever the
76
+ * browser clamps the delay to only spaces the work further.
77
+ */
78
+ async load(ctx) {
79
+ const entries = [...this.sources.entries()];
80
+ await Promise.allSettled(entries.map(([slot, source]) => this.loadFile(ctx, slot, source)));
81
+ for (const [slot, source] of entries) {
82
+ if (this.buffers.has(slot))
83
+ continue;
84
+ await new Promise((resolve) => {
85
+ setTimeout(resolve, 0);
86
+ });
87
+ /*
88
+ * **Each stand-in settles on its own, which is what the paragraph above already promised
89
+ * and what this loop did not deliver.** File failures land on `allSettled` and every one of
90
+ * them is contained; a *synth* that threw took the whole `load` down with it — so every slot
91
+ * after it in registration order was left with no buffer, and `play` returns silently on an
92
+ * undefined one. The symptom is a game where the first few sounds work and the rest do not,
93
+ * with nothing in the console after the one throw, and the boundary falling wherever the
94
+ * consumer happened to register the bad slot.
95
+ *
96
+ * Caught rather than rethrown for the same reason a missing file is: from the player's side
97
+ * a slot that cannot be built and one that cannot be fetched are the same event, and neither
98
+ * is worth the rest of the game's audio.
99
+ */
100
+ try {
101
+ this.buffers.set(slot, source.synth(ctx));
102
+ this.origins.set(slot, 'synth');
103
+ }
104
+ catch (error) {
105
+ this.failed.set(slot, error instanceof Error ? error.message : String(error));
106
+ }
107
+ }
108
+ }
109
+ /** The first candidate that loads and decodes wins; none of them is normal. */
110
+ async loadFile(ctx, slot, source) {
111
+ for (const url of source.urls) {
112
+ try {
113
+ const response = await this.fetchImpl(url);
114
+ if (!response.ok)
115
+ throw new Error(`HTTP ${response.status}`);
116
+ const bytes = await response.arrayBuffer();
117
+ const buffer = await ctx.decodeAudioData(bytes);
118
+ this.buffers.set(slot, buffer);
119
+ this.origins.set(slot, 'file');
120
+ return;
121
+ }
122
+ catch {
123
+ // Try the next format. A missing candidate is the normal case, not
124
+ // an error: most slots will only ever have one of these present.
125
+ }
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,60 @@
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
+ export declare const RHYTHM_BANDS: {
20
+ readonly sub: {
21
+ readonly lowHz: 40;
22
+ readonly highHz: 60;
23
+ };
24
+ readonly punch: {
25
+ readonly lowHz: 60;
26
+ readonly highHz: 100;
27
+ };
28
+ readonly sweet: {
29
+ readonly lowHz: 75;
30
+ readonly highHz: 110;
31
+ };
32
+ readonly bassline: {
33
+ readonly lowHz: 95;
34
+ readonly highHz: 180;
35
+ };
36
+ readonly mud: {
37
+ readonly lowHz: 180;
38
+ readonly highHz: 420;
39
+ };
40
+ readonly lowMid: {
41
+ readonly lowHz: 500;
42
+ readonly highHz: 2500;
43
+ };
44
+ readonly high: {
45
+ readonly lowHz: 5000;
46
+ readonly highHz: 12000;
47
+ };
48
+ };
49
+ export type BandName = keyof typeof RHYTHM_BANDS;
50
+ /** Energy in each band, 0–1, filled in place so nothing allocates per hop. */
51
+ export interface BandEnergies {
52
+ sub: number;
53
+ punch: number;
54
+ sweet: number;
55
+ bassline: number;
56
+ mud: number;
57
+ lowMid: number;
58
+ high: number;
59
+ }
60
+ export declare function createBandEnergies(): BandEnergies;
@@ -0,0 +1,12 @@
1
+ export const RHYTHM_BANDS = {
2
+ sub: { lowHz: 40, highHz: 60 },
3
+ punch: { lowHz: 60, highHz: 100 },
4
+ sweet: { lowHz: 75, highHz: 110 },
5
+ bassline: { lowHz: 95, highHz: 180 },
6
+ mud: { lowHz: 180, highHz: 420 },
7
+ lowMid: { lowHz: 500, highHz: 2500 },
8
+ high: { lowHz: 5000, highHz: 12000 },
9
+ };
10
+ export function createBandEnergies() {
11
+ return { sub: 0, punch: 0, sweet: 0, bassline: 0, mud: 0, lowMid: 0, high: 0 };
12
+ }
@@ -0,0 +1,32 @@
1
+ import type { BeatMap } from './beatMap.ts';
2
+ /**
3
+ * A beat map from a tempo the player supplies rather than one we detected.
4
+ *
5
+ * The reason this exists is the workflow, not the maths. On TikTok and Reels
6
+ * the trending sound is added *in the app*, replacing whatever audio the clip
7
+ * arrived with — so the thing that has to line up is not our audio against
8
+ * theirs, it is our *cuts* against their sound. A player who taps along to the
9
+ * track they intend to use gets an edit that fits it, and it works with any
10
+ * song in existence precisely because we never touch the song.
11
+ *
12
+ * The output is an ordinary `BeatMap`, so nothing downstream needs to know
13
+ * where its beats came from.
14
+ */
15
+ export declare function beatGrid(bpm: number, offsetSec: number, durationSec: number): BeatMap;
16
+ /**
17
+ * Tempo and phase, from somebody tapping along.
18
+ *
19
+ * Phase is the part that matters. A player could type a BPM; what they cannot
20
+ * type is *where the downbeat falls* in the sound they are about to add, and a
21
+ * grid at the right tempo with the wrong phase is off by up to half a beat
22
+ * everywhere — worse than not syncing at all.
23
+ */
24
+ export declare class TapTempo {
25
+ private readonly taps;
26
+ tap(nowMs: number): void;
27
+ /** Null until there is enough to be sure, and for anything implausible. */
28
+ get bpm(): number | null;
29
+ /** Seconds, from the same clock the taps were given in. */
30
+ get offsetSec(): number;
31
+ reset(): void;
32
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * A beat map from a tempo the player supplies rather than one we detected.
3
+ *
4
+ * The reason this exists is the workflow, not the maths. On TikTok and Reels
5
+ * the trending sound is added *in the app*, replacing whatever audio the clip
6
+ * arrived with — so the thing that has to line up is not our audio against
7
+ * theirs, it is our *cuts* against their sound. A player who taps along to the
8
+ * track they intend to use gets an edit that fits it, and it works with any
9
+ * song in existence precisely because we never touch the song.
10
+ *
11
+ * The output is an ordinary `BeatMap`, so nothing downstream needs to know
12
+ * where its beats came from.
13
+ */
14
+ export function beatGrid(bpm, offsetSec, durationSec) {
15
+ const safeBpm = Number.isFinite(bpm) && bpm > 0 ? bpm : 120;
16
+ const period = 60 / safeBpm;
17
+ const length = Math.max(0, Number.isFinite(durationSec) ? durationSec : 0);
18
+ /*
19
+ * Wrapped into the first beat. A player nudging the offset will run it past a
20
+ * whole beat without thinking about it, and a grid that then started a second
21
+ * in would silently lose the opening cut.
22
+ */
23
+ const raw = Number.isFinite(offsetSec) ? offsetSec : 0;
24
+ const first = ((raw % period) + period) % period;
25
+ const count = length <= 0 ? 0 : Math.max(0, Math.floor((length - first) / period) + 1);
26
+ const beats = new Float32Array(count);
27
+ const strength = new Float32Array(count);
28
+ for (let i = 0; i < count; i++) {
29
+ beats[i] = first + i * period;
30
+ // Every fourth beat is the downbeat. Not detected — asserted, because that
31
+ // is what a player tapping four to the bar means by it.
32
+ strength[i] = i % 4 === 0 ? 1 : 0.6;
33
+ }
34
+ return {
35
+ beats,
36
+ strength,
37
+ bpm: safeBpm,
38
+ bpmConfidence: 1,
39
+ energy: new Float32Array([1]),
40
+ energyHz: 10,
41
+ durationSec: length,
42
+ };
43
+ }
44
+ /** Taps this far apart are two separate attempts, not one tempo. */
45
+ const STALE_MS = 3000;
46
+ /** Below four taps there are too few intervals to reject a fumble. */
47
+ const MIN_TAPS = 4;
48
+ const MAX_TAPS = 8;
49
+ /** Outside this a "tempo" is a double-tap or somebody who wandered off. */
50
+ const MIN_BPM = 60;
51
+ const MAX_BPM = 200;
52
+ /**
53
+ * Tempo and phase, from somebody tapping along.
54
+ *
55
+ * Phase is the part that matters. A player could type a BPM; what they cannot
56
+ * type is *where the downbeat falls* in the sound they are about to add, and a
57
+ * grid at the right tempo with the wrong phase is off by up to half a beat
58
+ * everywhere — worse than not syncing at all.
59
+ */
60
+ export class TapTempo {
61
+ taps = [];
62
+ tap(nowMs) {
63
+ const previous = this.taps[this.taps.length - 1];
64
+ // A gap means they stopped and started again. Averaging across it would
65
+ // produce a tempo of a couple of beats a minute.
66
+ if (previous !== undefined && nowMs - previous > STALE_MS)
67
+ this.taps.length = 0;
68
+ this.taps.push(nowMs);
69
+ if (this.taps.length > MAX_TAPS)
70
+ this.taps.shift();
71
+ }
72
+ /** Null until there is enough to be sure, and for anything implausible. */
73
+ get bpm() {
74
+ if (this.taps.length < MIN_TAPS)
75
+ return null;
76
+ const intervals = [];
77
+ for (let i = 1; i < this.taps.length; i++) {
78
+ intervals.push((this.taps[i] ?? 0) - (this.taps[i - 1] ?? 0));
79
+ }
80
+ // Median, not mean: anybody tapping along will fumble one, and a mean turns
81
+ // a single 400 ms stumble into a double-digit BPM error.
82
+ intervals.sort((a, b) => a - b);
83
+ const median = intervals[intervals.length >> 1] ?? 0;
84
+ if (median <= 0)
85
+ return null;
86
+ const bpm = 60_000 / median;
87
+ if (bpm < MIN_BPM || bpm > MAX_BPM)
88
+ return null;
89
+ return bpm;
90
+ }
91
+ /** Seconds, from the same clock the taps were given in. */
92
+ get offsetSec() {
93
+ return (this.taps[this.taps.length - 1] ?? 0) / 1000;
94
+ }
95
+ reset() {
96
+ this.taps.length = 0;
97
+ }
98
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Offline rhythm analysis: where the kicks are in a whole track.
3
+ *
4
+ * Computed once, ahead of time, from decoded samples. That is the important
5
+ * difference from a live detector, and it buys three things a live one cannot:
6
+ *
7
+ * 1. **Cuts land on the beat.** A real-time detector necessarily fires
8
+ * *after* the transient it is detecting, and by a varying amount. Offline
9
+ * we can look ahead — pick the peak of the onset curve and then walk back
10
+ * to where the transient actually began, which is a fixed reference rather
11
+ * than "whenever the level happened to cross a threshold".
12
+ * 2. **Determinism.** Two people watching the same shared run see the same
13
+ * edit, and the same run watched twice is identical. Nothing here reads a
14
+ * clock or `Math.random`.
15
+ * 3. **Structure.** Choosing where to spend the best shot needs to see the
16
+ * whole track. Real-time analysis by definition cannot.
17
+ *
18
+ * The detection is ported from a production kick detector, whose central idea is
19
+ * *whitening*: in bass-led electronic music the low end is dominated by a sustained
20
+ * 808, so raw low energy is loud all the time and useless. Subtract a weighted
21
+ * bassline/mud/low-mid mask from the kick band and take the difference between
22
+ * a fast and a slow envelope, and only the transient survives.
23
+ */
24
+ export interface BeatMap {
25
+ /**
26
+ * Detected kick onsets in seconds, ascending. These are the hits the
27
+ * analyser is *sure* about — deliberately not every beat in the track.
28
+ */
29
+ readonly beats: Float32Array;
30
+ /** 0–1 per detected beat, for weighting cuts and flashes. */
31
+ readonly strength: Float32Array;
32
+ readonly bpm: number;
33
+ /** 0–1. Low means the hits are real but irregular — a rubato passage. */
34
+ readonly bpmConfidence: number;
35
+ /** Coarse loudness envelope, for finding drops and quiet passages. */
36
+ readonly energy: Float32Array;
37
+ readonly energyHz: number;
38
+ readonly durationSec: number;
39
+ }
40
+ /** A map with no beats in it, for when there is no music to analyse. */
41
+ export declare function emptyBeatMap(durationSec?: number): BeatMap;
42
+ export declare function analyseTrack(samples: Float32Array, sampleRate: number): BeatMap;