@driftengine/audio 3.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +11 -0
- package/dist/ambientLoop.d.ts +45 -0
- package/dist/ambientLoop.js +88 -0
- package/dist/audioHarness.d.ts +180 -0
- package/dist/audioHarness.js +244 -0
- package/dist/filters.d.ts +91 -0
- package/dist/filters.js +103 -0
- package/dist/formats.d.ts +18 -0
- package/dist/formats.js +19 -0
- package/dist/graph.d.ts +406 -0
- package/dist/graph.js +656 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +39 -0
- package/dist/manifest.d.ts +28 -0
- package/dist/manifest.js +71 -0
- package/dist/mix/bus.d.ts +203 -0
- package/dist/mix/bus.js +293 -0
- package/dist/mix/console.d.ts +96 -0
- package/dist/mix/console.js +131 -0
- package/dist/mix/defaultLayout.d.ts +37 -0
- package/dist/mix/defaultLayout.js +63 -0
- package/dist/mix/inserts.d.ts +64 -0
- package/dist/mix/inserts.js +187 -0
- package/dist/mix/returns.d.ts +38 -0
- package/dist/mix/returns.js +86 -0
- package/dist/mix/snapshot.d.ts +30 -0
- package/dist/mix/snapshot.js +55 -0
- package/dist/positional.d.ts +37 -0
- package/dist/positional.js +47 -0
- package/dist/registry.d.ts +91 -0
- package/dist/registry.js +128 -0
- package/dist/rhythm/bands.d.ts +60 -0
- package/dist/rhythm/bands.js +12 -0
- package/dist/rhythm/beatGrid.d.ts +32 -0
- package/dist/rhythm/beatGrid.js +98 -0
- package/dist/rhythm/beatMap.d.ts +42 -0
- package/dist/rhythm/beatMap.js +405 -0
- package/dist/rhythm/kickCore.d.ts +79 -0
- package/dist/rhythm/kickCore.js +166 -0
- package/dist/rhythm/kickDetector.d.ts +65 -0
- package/dist/rhythm/kickDetector.js +202 -0
- package/dist/rhythm/renderedPulse.d.ts +15 -0
- package/dist/rhythm/renderedPulse.js +138 -0
- package/dist/session.d.ts +62 -0
- package/dist/session.js +83 -0
- package/dist/spatial/ambisonic.d.ts +135 -0
- package/dist/spatial/ambisonic.js +299 -0
- package/dist/spatial/listener.d.ts +109 -0
- package/dist/spatial/listener.js +186 -0
- package/dist/spatial/occlusion.d.ts +39 -0
- package/dist/spatial/occlusion.js +92 -0
- package/dist/spatial/source.d.ts +185 -0
- package/dist/spatial/source.js +366 -0
- package/dist/spatial/zones.d.ts +129 -0
- package/dist/spatial/zones.js +166 -0
- package/dist/synth.d.ts +92 -0
- package/dist/synth.js +282 -0
- package/package.json +54 -0
- package/src/ambientLoop.ts +101 -0
- package/src/audioHarness.ts +280 -0
- package/src/filters.ts +109 -0
- package/src/formats.ts +22 -0
- package/src/graph.ts +805 -0
- package/src/index.ts +84 -0
- package/src/manifest.ts +73 -0
- package/src/mix/bus.ts +356 -0
- package/src/mix/console.ts +181 -0
- package/src/mix/defaultLayout.ts +118 -0
- package/src/mix/inserts.ts +242 -0
- package/src/mix/returns.ts +114 -0
- package/src/mix/snapshot.ts +75 -0
- package/src/positional.ts +47 -0
- package/src/registry.ts +167 -0
- package/src/rhythm/bands.ts +45 -0
- package/src/rhythm/beatGrid.ts +106 -0
- package/src/rhythm/beatMap.ts +514 -0
- package/src/rhythm/kickCore.ts +197 -0
- package/src/rhythm/kickDetector.ts +233 -0
- package/src/rhythm/renderedPulse.ts +147 -0
- package/src/session.ts +93 -0
- package/src/spatial/ambisonic.ts +358 -0
- package/src/spatial/listener.ts +249 -0
- package/src/spatial/occlusion.ts +95 -0
- package/src/spatial/source.ts +452 -0
- package/src/spatial/zones.ts +213 -0
- package/src/synth.ts +351 -0
package/dist/registry.js
ADDED
|
@@ -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;
|