@hyperframes/core 0.8.158 → 0.8.160
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/dist/audioAnalysis.d.ts +119 -0
- package/dist/audioAnalysis.js +466 -0
- package/dist/audioAnalysisLadder.d.ts +52 -0
- package/dist/audioAnalysisLadder.js +78 -0
- package/dist/audioCarve.js +1 -36
- package/dist/audioRhythm.d.ts +50 -0
- package/dist/audioRhythm.js +311 -0
- package/dist/audioSections.d.ts +21 -0
- package/dist/audioSections.js +109 -0
- package/dist/audioSnap.d.ts +18 -0
- package/dist/audioSnap.js +41 -0
- package/dist/fft.d.ts +2 -0
- package/dist/fft.js +36 -0
- package/dist/generated/runtime-inline.js +1 -1
- package/dist/hyperframe.runtime.iife.js +56 -56
- package/dist/runtime/media.d.ts +1 -0
- package/dist/runtime/media.js +35 -0
- package/package.json +8 -4
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider ladder for audio analysis. Each capability lists providers best first; a call takes the first one
|
|
3
|
+
* that is available within the requested quality and answers, falling through on failure. The result says which
|
|
4
|
+
* provider answered and why the ones above it did not.
|
|
5
|
+
*/
|
|
6
|
+
/** The default order per capability: every registered provider that offers it, in registration order. */
|
|
7
|
+
export function defaultLadder(capability, providers) {
|
|
8
|
+
return providers.filter((p) => p.capabilities.includes(capability)).map((p) => p.id);
|
|
9
|
+
}
|
|
10
|
+
/** Why a provider may not be used at this quality, or null when it may. */
|
|
11
|
+
export function exclusion(provider, quality) {
|
|
12
|
+
if (quality === "fast" && !provider.signal)
|
|
13
|
+
return "quality fast allows signal processing only";
|
|
14
|
+
if (quality === "local" && !provider.local)
|
|
15
|
+
return "quality local keeps audio on this machine";
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const message = (error) => (error instanceof Error ? error.message : String(error));
|
|
19
|
+
async function availabilityOf(provider) {
|
|
20
|
+
try {
|
|
21
|
+
return provider.available ? await provider.available() : { ok: true };
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return { ok: false, reason: message(error) };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Why a provider cannot take this step, or null when it can. */
|
|
28
|
+
async function blocked(id, candidate, capability, quality) {
|
|
29
|
+
if (!candidate?.capabilities.includes(capability))
|
|
30
|
+
return { provider: id, outcome: "unavailable", reason: `no provider ${id} for ${capability}` };
|
|
31
|
+
const excluded = exclusion(candidate, quality);
|
|
32
|
+
if (excluded)
|
|
33
|
+
return { provider: id, outcome: "excluded", reason: excluded };
|
|
34
|
+
const availability = await availabilityOf(candidate);
|
|
35
|
+
if (!availability.ok)
|
|
36
|
+
return { provider: id, outcome: "unavailable", reason: availability.reason };
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/** One line naming the providers above `answered` that could not answer, and why; null when none was passed over. */
|
|
40
|
+
function passedOver(capability, steps, answered) {
|
|
41
|
+
const skipped = steps.filter((s) => s.outcome === "unavailable" || s.outcome === "failed");
|
|
42
|
+
if (!skipped.length)
|
|
43
|
+
return null;
|
|
44
|
+
const why = skipped
|
|
45
|
+
.map((s) => `${s.provider} ${s.outcome}${s.reason ? ` (${s.reason})` : ""}`)
|
|
46
|
+
.join("; ");
|
|
47
|
+
return answered ? `${capability}: used ${answered}; ${why}` : `${capability}: ${why}`;
|
|
48
|
+
}
|
|
49
|
+
export async function runLadder(capability, input, providers, { quality = "best", ladder, provider, options = {} } = {}) {
|
|
50
|
+
const byId = new Map(providers.map((p) => [p.id, p]));
|
|
51
|
+
const order = provider ? [provider] : (ladder ?? defaultLadder(capability, providers));
|
|
52
|
+
const steps = [];
|
|
53
|
+
for (const id of order) {
|
|
54
|
+
const candidate = byId.get(id);
|
|
55
|
+
const skip = await blocked(id, candidate, capability, quality);
|
|
56
|
+
if (skip) {
|
|
57
|
+
steps.push(skip);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const value = (await candidate.analyze(capability, input, options));
|
|
62
|
+
const warning = passedOver(capability, steps, id);
|
|
63
|
+
steps.push({ provider: id, outcome: "answered" });
|
|
64
|
+
return { value, provenance: id, steps, warnings: warning ? [warning] : [] };
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
steps.push({ provider: id, outcome: "failed", reason: message(error) });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const none = order.length ? "" : " (none offers it)";
|
|
71
|
+
const why = passedOver(capability, steps, null)?.slice(capability.length + 2);
|
|
72
|
+
return {
|
|
73
|
+
value: null,
|
|
74
|
+
provenance: null,
|
|
75
|
+
steps,
|
|
76
|
+
warnings: [`${capability}: no provider answered${none}${why ? `; ${why}` : ""}`],
|
|
77
|
+
};
|
|
78
|
+
}
|
package/dist/audioCarve.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* chain the studio generated rather than a separate rendering path.
|
|
12
12
|
*/
|
|
13
13
|
import { defaultAudioFxParams, HF_AUDIO_FX_CHAIN_VERSION, } from "./audioFx.js";
|
|
14
|
+
import { fft } from "./fft.js";
|
|
14
15
|
export const HF_AUDIO_CARVE_ATTR = "data-fx-carve";
|
|
15
16
|
/** Third-octave centres spanning the range speech actually occupies. */
|
|
16
17
|
const CANDIDATE_CENTERS_HZ = [160, 250, 400, 630, 1000, 1600, 2500, 4000, 6000];
|
|
@@ -282,42 +283,6 @@ function powerSpectrum(mono, sampleRate) {
|
|
|
282
283
|
}
|
|
283
284
|
return { freqs, power };
|
|
284
285
|
}
|
|
285
|
-
/** In-place iterative radix-2 FFT. */
|
|
286
|
-
function fft(re, im) {
|
|
287
|
-
const n = re.length;
|
|
288
|
-
for (let i = 1, j = 0; i < n; i++) {
|
|
289
|
-
let bit = n >> 1;
|
|
290
|
-
for (; j & bit; bit >>= 1)
|
|
291
|
-
j ^= bit;
|
|
292
|
-
j ^= bit;
|
|
293
|
-
if (i < j) {
|
|
294
|
-
[re[i], re[j]] = [re[j], re[i]];
|
|
295
|
-
[im[i], im[j]] = [im[j], im[i]];
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
for (let len = 2; len <= n; len <<= 1) {
|
|
299
|
-
const ang = (-2 * Math.PI) / len;
|
|
300
|
-
const wr = Math.cos(ang);
|
|
301
|
-
const wi = Math.sin(ang);
|
|
302
|
-
for (let i = 0; i < n; i += len) {
|
|
303
|
-
let cr = 1;
|
|
304
|
-
let ci = 0;
|
|
305
|
-
for (let k = 0; k < len / 2; k++) {
|
|
306
|
-
const ur = re[i + k];
|
|
307
|
-
const ui = im[i + k];
|
|
308
|
-
const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
|
|
309
|
-
const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
|
|
310
|
-
re[i + k] = ur + vr;
|
|
311
|
-
im[i + k] = ui + vi;
|
|
312
|
-
re[i + k + len / 2] = ur - vr;
|
|
313
|
-
im[i + k + len / 2] = ui - vi;
|
|
314
|
-
const ncr = cr * wr - ci * wi;
|
|
315
|
-
ci = cr * wi + ci * wr;
|
|
316
|
-
cr = ncr;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
286
|
function bandPower(freqs, power, center) {
|
|
322
287
|
const lo = center / Math.pow(2, 1 / 6);
|
|
323
288
|
const hi = center * Math.pow(2, 1 / 6);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beats, downbeats and tempo from signal processing alone: a log-band spectral-flux onset curve, an autocorrelation
|
|
3
|
+
* tempo with a log-normal prior, and dynamic-programming beat placement (Ellis 2007). Times are seconds into the signal.
|
|
4
|
+
*/
|
|
5
|
+
import type { AudioSignal } from "./audioAnalysis";
|
|
6
|
+
export interface Beats {
|
|
7
|
+
/** Empty when there is no steady beat, with `reason` saying so. */
|
|
8
|
+
times: number[];
|
|
9
|
+
/** 0–1: how strongly the onsets repeat at the beat period, scaled by how much stronger they are on the beats. */
|
|
10
|
+
confidence?: number;
|
|
11
|
+
reason?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Downbeats {
|
|
14
|
+
times: number[];
|
|
15
|
+
/** Beats per bar, 3 or 4; null when there are too few beats to tell. */
|
|
16
|
+
meter: number | null;
|
|
17
|
+
/** "low" for a guess from where the low end hits hardest. */
|
|
18
|
+
confidence?: "low";
|
|
19
|
+
}
|
|
20
|
+
export interface TempoPoint {
|
|
21
|
+
t: number;
|
|
22
|
+
bpm: number;
|
|
23
|
+
}
|
|
24
|
+
export interface Tempo {
|
|
25
|
+
/** Beats per minute from the median beat interval; null without at least two beats. */
|
|
26
|
+
value: number | null;
|
|
27
|
+
/** The tempo over the beats around each point, every few seconds, for music that speeds up or slows down. */
|
|
28
|
+
curve: TempoPoint[];
|
|
29
|
+
}
|
|
30
|
+
export interface Rhythm {
|
|
31
|
+
beats: Beats;
|
|
32
|
+
downbeats: Downbeats;
|
|
33
|
+
}
|
|
34
|
+
/** Log energy per frame in log-spaced bands, the frames `fps` per second. */
|
|
35
|
+
export interface BandFrames {
|
|
36
|
+
fps: number;
|
|
37
|
+
bands: Float64Array[];
|
|
38
|
+
}
|
|
39
|
+
/** Per-band log power, summed over channels so a polarity-flipped channel cannot cancel. */
|
|
40
|
+
export declare function bandFrames(signal: AudioSignal): BandFrames;
|
|
41
|
+
export declare function medianInterval(times: readonly number[]): number | null;
|
|
42
|
+
/** Below this the beats are more likely the tracker's own grid than the music's. */
|
|
43
|
+
export declare const STEADY_BEAT_CONFIDENCE = 0.05;
|
|
44
|
+
/** Beats and downbeats of `frames`, the band energies of a signal at `sampleRate`. */
|
|
45
|
+
export declare function rhythmOf(frames: BandFrames, sampleRate: number): Rhythm;
|
|
46
|
+
/** Tempo from beat times, by whichever provider placed them: the median interval, and a curve over ±4 s windows. */
|
|
47
|
+
export declare function tempoOf(times: readonly number[]): Tempo;
|
|
48
|
+
/** Band energies of `signal`, computed once per signal for every capability that reads them. */
|
|
49
|
+
export declare function bandFramesOf(signal: AudioSignal): BandFrames;
|
|
50
|
+
export declare function rhythm(signal: AudioSignal): Rhythm;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beats, downbeats and tempo from signal processing alone: a log-band spectral-flux onset curve, an autocorrelation
|
|
3
|
+
* tempo with a log-normal prior, and dynamic-programming beat placement (Ellis 2007). Times are seconds into the signal.
|
|
4
|
+
*/
|
|
5
|
+
import { fft } from "./fft.js";
|
|
6
|
+
const FRAMES_PER_SECOND = 100;
|
|
7
|
+
const LOW_BAND_HZ = 150;
|
|
8
|
+
const SLOWEST_BPM = 40;
|
|
9
|
+
const FASTEST_BPM = 240;
|
|
10
|
+
const TIGHTNESS = 100;
|
|
11
|
+
const round = (t) => Math.round(t * 1000) / 1000;
|
|
12
|
+
function bandEdges(sampleRate, size) {
|
|
13
|
+
const binHz = sampleRate / size;
|
|
14
|
+
const edges = [];
|
|
15
|
+
for (let hz = 30; hz < Math.min(9000, sampleRate / 2); hz *= 2 ** (1 / 5))
|
|
16
|
+
edges.push(Math.max(1, Math.round(hz / binHz)));
|
|
17
|
+
return edges;
|
|
18
|
+
}
|
|
19
|
+
/** Add each band's power in the Hann-windowed frame centred on `center` into `into`. */
|
|
20
|
+
function addFramePower(samples, center, window, edges, into) {
|
|
21
|
+
const size = window.length;
|
|
22
|
+
const re = new Float64Array(size);
|
|
23
|
+
const im = new Float64Array(size);
|
|
24
|
+
const first = center - size / 2;
|
|
25
|
+
for (let i = 0; i < size; i++) {
|
|
26
|
+
const at = first + i;
|
|
27
|
+
re[i] = at >= 0 && at < samples.length ? samples[at] * window[i] : 0;
|
|
28
|
+
}
|
|
29
|
+
fft(re, im);
|
|
30
|
+
for (let b = 0; b + 1 < edges.length; b++) {
|
|
31
|
+
const end = Math.max(edges[b] + 1, edges[b + 1]);
|
|
32
|
+
let power = 0;
|
|
33
|
+
for (let k = edges[b]; k < end; k++)
|
|
34
|
+
power += re[k] * re[k] + im[k] * im[k];
|
|
35
|
+
into[b] += power / (size * size * (end - edges[b]));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Per-band log power, summed over channels so a polarity-flipped channel cannot cancel. */
|
|
39
|
+
export function bandFrames(signal) {
|
|
40
|
+
const { sampleRate, channels } = signal;
|
|
41
|
+
const hop = Math.max(1, Math.round(sampleRate / FRAMES_PER_SECOND));
|
|
42
|
+
const size = 2 ** Math.round(Math.log2(sampleRate * 0.046));
|
|
43
|
+
const window = Float64Array.from({ length: size }, (_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / size));
|
|
44
|
+
const edges = bandEdges(sampleRate, size);
|
|
45
|
+
const length = channels[0]?.length ?? 0;
|
|
46
|
+
const bands = [];
|
|
47
|
+
for (let center = 0; center < length; center += hop) {
|
|
48
|
+
const power = new Float64Array(Math.max(0, edges.length - 1));
|
|
49
|
+
for (const channel of channels)
|
|
50
|
+
addFramePower(channel, center, window, edges, power);
|
|
51
|
+
bands.push(power.map((p) => Math.log1p(1e9 * p)));
|
|
52
|
+
}
|
|
53
|
+
return { fps: sampleRate / hop, bands };
|
|
54
|
+
}
|
|
55
|
+
/** Subtract the mean of the surrounding ±`radius` frames and keep what rises above it. */
|
|
56
|
+
function aboveLocalMean(curve, radius) {
|
|
57
|
+
const sums = new Float64Array(curve.length + 1);
|
|
58
|
+
for (let i = 0; i < curve.length; i++)
|
|
59
|
+
sums[i + 1] = sums[i] + curve[i];
|
|
60
|
+
return curve.map((value, i) => {
|
|
61
|
+
const from = Math.max(0, i - radius);
|
|
62
|
+
const to = Math.min(curve.length, i + radius + 1);
|
|
63
|
+
return Math.max(0, value - (sums[to] - sums[from]) / (to - from));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function standardized(curve) {
|
|
67
|
+
const mean = curve.reduce((a, b) => a + b, 0) / (curve.length || 1);
|
|
68
|
+
const spread = Math.sqrt(curve.reduce((a, v) => a + (v - mean) ** 2, 0) / (curve.length || 1));
|
|
69
|
+
return spread > 0 ? curve.map((v) => v / spread) : curve.map(() => 0);
|
|
70
|
+
}
|
|
71
|
+
function onsetCurves({ fps, bands }, sampleRate) {
|
|
72
|
+
const size = 2 ** Math.round(Math.log2(sampleRate * 0.046));
|
|
73
|
+
const lowBands = bandEdges(sampleRate, size).filter((bin) => (bin * sampleRate) / size < LOW_BAND_HZ).length;
|
|
74
|
+
const broad = new Float64Array(bands.length);
|
|
75
|
+
const low = new Float64Array(bands.length);
|
|
76
|
+
for (let f = 1; f < bands.length; f++) {
|
|
77
|
+
const frame = bands[f];
|
|
78
|
+
const previous = bands[f - 1];
|
|
79
|
+
for (let b = 0; b < frame.length; b++) {
|
|
80
|
+
const rise = Math.max(0, frame[b] - previous[b]);
|
|
81
|
+
broad[f] += rise;
|
|
82
|
+
if (b < lowBands)
|
|
83
|
+
low[f] += rise;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const radius = Math.round(0.4 * fps);
|
|
87
|
+
return {
|
|
88
|
+
fps,
|
|
89
|
+
broad: standardized(aboveLocalMean(broad, radius)),
|
|
90
|
+
low: standardized(aboveLocalMean(low, radius)),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Autocorrelation of `curve` at lags 0…`maxLag`, normalized by lag 0. */
|
|
94
|
+
function autocorrelation(curve, maxLag) {
|
|
95
|
+
const mean = curve.reduce((a, b) => a + b, 0) / (curve.length || 1);
|
|
96
|
+
const centred = curve.map((v) => v - mean);
|
|
97
|
+
const ac = new Float64Array(maxLag + 1);
|
|
98
|
+
for (let lag = 0; lag <= maxLag && lag < centred.length; lag++) {
|
|
99
|
+
let sum = 0;
|
|
100
|
+
for (let i = lag; i < centred.length; i++)
|
|
101
|
+
sum += centred[i] * centred[i - lag];
|
|
102
|
+
ac[lag] = sum;
|
|
103
|
+
}
|
|
104
|
+
const zero = ac[0] || 1;
|
|
105
|
+
return ac.map((v) => v / zero);
|
|
106
|
+
}
|
|
107
|
+
const tempoPrior = (lag, fps) => Math.exp(-0.5 * Math.log2(60 / (lag / fps) / 120) ** 2);
|
|
108
|
+
const fullRange = (fps) => ({
|
|
109
|
+
shortest: Math.max(1, Math.floor((60 / FASTEST_BPM) * fps)),
|
|
110
|
+
longest: Math.ceil((60 / SLOWEST_BPM) * fps),
|
|
111
|
+
});
|
|
112
|
+
/** The beat period in frames: prior-weighted autocorrelation plus half its value at twice the lag. */
|
|
113
|
+
function beatPeriod(curve, fps, { shortest, longest } = fullRange(fps)) {
|
|
114
|
+
const ac = autocorrelation(curve, 2 * longest + 1);
|
|
115
|
+
const at = (lag) => ac[Math.min(ac.length - 1, Math.max(0, lag))];
|
|
116
|
+
const score = (lag) => tempoPrior(lag, fps) *
|
|
117
|
+
(at(lag) + 0.5 * at(2 * lag) + 0.25 * (at(2 * lag - 1) + at(2 * lag + 1)));
|
|
118
|
+
let best = shortest;
|
|
119
|
+
for (let lag = shortest; lag <= longest; lag++)
|
|
120
|
+
if (score(lag) > score(best))
|
|
121
|
+
best = lag;
|
|
122
|
+
const [a, b, c] = [score(best - 1), score(best), score(best + 1)];
|
|
123
|
+
const curvature = a - 2 * b + c;
|
|
124
|
+
const shift = curvature ? Math.max(-0.5, Math.min(0.5, (0.5 * (a - c)) / curvature)) : 0;
|
|
125
|
+
return { period: best + shift, clarity: Math.max(0, Math.min(1, at(best))) };
|
|
126
|
+
}
|
|
127
|
+
/** The onset curve smoothed by a Gaussian a thirty-second of a beat wide. */
|
|
128
|
+
function smoothed(curve, period) {
|
|
129
|
+
const half = Math.max(1, Math.round(period / 8));
|
|
130
|
+
const kernel = Array.from({ length: 2 * half + 1 }, (_, i) => Math.exp(-0.5 * (((i - half) * 32) / period) ** 2));
|
|
131
|
+
return curve.map((_, i) => {
|
|
132
|
+
let sum = 0;
|
|
133
|
+
for (let j = 0; j < kernel.length; j++)
|
|
134
|
+
sum += kernel[j] * (curve[i + j - half] ?? 0);
|
|
135
|
+
return sum;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** Ellis's recursion: each frame's best score from a predecessor about one period back, penalized by log deviation. */
|
|
139
|
+
function cumulativeScore(local, periodAt) {
|
|
140
|
+
const score = new Float64Array(local.length);
|
|
141
|
+
const back = new Int32Array(local.length).fill(-1);
|
|
142
|
+
for (let i = 0; i < local.length; i++) {
|
|
143
|
+
const period = periodAt(i);
|
|
144
|
+
let best = -Infinity;
|
|
145
|
+
let from = -1;
|
|
146
|
+
for (let j = Math.max(0, Math.round(i - 2 * period)); j <= i - Math.round(period / 2); j++) {
|
|
147
|
+
const candidate = score[j] - TIGHTNESS * Math.log((i - j) / period) ** 2;
|
|
148
|
+
if (candidate > best)
|
|
149
|
+
[best, from] = [candidate, j];
|
|
150
|
+
}
|
|
151
|
+
score[i] = local[i] + Math.max(0, best);
|
|
152
|
+
back[i] = best > 0 ? from : -1;
|
|
153
|
+
}
|
|
154
|
+
return { score, back };
|
|
155
|
+
}
|
|
156
|
+
/** The last local maximum of the cumulative score that reaches half the median of all of them. */
|
|
157
|
+
function lastBeat(score) {
|
|
158
|
+
const maxima = [];
|
|
159
|
+
for (let i = 1; i + 1 < score.length; i++)
|
|
160
|
+
if (score[i] > score[i - 1] && score[i] >= score[i + 1])
|
|
161
|
+
maxima.push(i);
|
|
162
|
+
if (!maxima.length)
|
|
163
|
+
return score.length - 1;
|
|
164
|
+
const median = maxima.map((i) => score[i]).sort((a, b) => a - b)[maxima.length >> 1];
|
|
165
|
+
for (let k = maxima.length - 1; k >= 0; k--)
|
|
166
|
+
if (score[maxima[k]] >= 0.5 * median)
|
|
167
|
+
return maxima[k];
|
|
168
|
+
return maxima[maxima.length - 1];
|
|
169
|
+
}
|
|
170
|
+
/** Drop beats at either end whose onset strength is under half the RMS of the beats' strengths. */
|
|
171
|
+
function trimmed(beats, local) {
|
|
172
|
+
const rms = Math.sqrt(beats.reduce((a, b) => a + local[b] ** 2, 0) / (beats.length || 1));
|
|
173
|
+
let first = 0;
|
|
174
|
+
let end = beats.length;
|
|
175
|
+
while (first < end && local[beats[first]] < 0.5 * rms)
|
|
176
|
+
first++;
|
|
177
|
+
while (end > first && local[beats[end - 1]] < 0.5 * rms)
|
|
178
|
+
end--;
|
|
179
|
+
return beats.slice(first, end);
|
|
180
|
+
}
|
|
181
|
+
const LOCAL_WINDOW_SECONDS = 8;
|
|
182
|
+
/** The period through the piece: eight-second windows a second apart, held near the global period, median-smoothed. */
|
|
183
|
+
function localPeriods(curve, fps, period) {
|
|
184
|
+
const window = Math.round(LOCAL_WINDOW_SECONDS * fps);
|
|
185
|
+
const hop = Math.round(fps);
|
|
186
|
+
const range = {
|
|
187
|
+
shortest: Math.max(fullRange(fps).shortest, Math.floor(period * 0.7)),
|
|
188
|
+
longest: Math.min(fullRange(fps).longest, Math.ceil(period * 1.4)),
|
|
189
|
+
};
|
|
190
|
+
const centres = [];
|
|
191
|
+
const periods = [];
|
|
192
|
+
for (let start = 0; start === 0 || start + window <= curve.length; start += hop) {
|
|
193
|
+
const end = Math.min(curve.length, start + window);
|
|
194
|
+
periods.push(beatPeriod(curve.subarray(start, end), fps, range).period);
|
|
195
|
+
centres.push((start + end) / 2);
|
|
196
|
+
}
|
|
197
|
+
const median = periods.map((_, i) => {
|
|
198
|
+
const near = periods.slice(Math.max(0, i - 2), i + 3).sort((a, b) => a - b);
|
|
199
|
+
return near[near.length >> 1];
|
|
200
|
+
});
|
|
201
|
+
return (frame) => interpolate(centres, median, frame);
|
|
202
|
+
}
|
|
203
|
+
function interpolate(xs, ys, x) {
|
|
204
|
+
if (x <= xs[0])
|
|
205
|
+
return ys[0];
|
|
206
|
+
for (let i = 1; i < xs.length; i++)
|
|
207
|
+
if (x <= xs[i]) {
|
|
208
|
+
const t = (x - xs[i - 1]) / (xs[i] - xs[i - 1]);
|
|
209
|
+
return ys[i - 1] * (1 - t) + ys[i] * t;
|
|
210
|
+
}
|
|
211
|
+
return ys[ys.length - 1];
|
|
212
|
+
}
|
|
213
|
+
function beatFrames(curve, fps, period) {
|
|
214
|
+
const local = smoothed(curve, period);
|
|
215
|
+
const { score, back } = cumulativeScore(local, localPeriods(curve, fps, period));
|
|
216
|
+
const beats = [];
|
|
217
|
+
for (let i = lastBeat(score); i >= 0; i = back[i])
|
|
218
|
+
beats.push(i);
|
|
219
|
+
return { frames: trimmed(beats.reverse(), local), local };
|
|
220
|
+
}
|
|
221
|
+
/** How much stronger the smoothed onsets are on the beats than on average, as a share: 0 is no stronger. */
|
|
222
|
+
function onsetLift(frames, local) {
|
|
223
|
+
const overall = local.reduce((a, b) => a + b, 0) / (local.length || 1);
|
|
224
|
+
const onBeats = frames.reduce((a, f) => a + local[f], 0) / (frames.length || 1);
|
|
225
|
+
return overall > 0 ? onBeats / overall - 1 : 0;
|
|
226
|
+
}
|
|
227
|
+
/** The meter and bar phase whose beats carry the most low-band onset energy against the others. */
|
|
228
|
+
function barPhase(beats, curves) {
|
|
229
|
+
const strength = (f) => Math.max(...[-1, 0, 1, 2].map((d) => (curves.low[f + d] ?? 0) + 0.3 * (curves.broad[f + d] ?? 0)));
|
|
230
|
+
const levels = beats.map(strength);
|
|
231
|
+
let best = { meter: 4, phase: 0, contrast: -Infinity };
|
|
232
|
+
for (const meter of [4, 3])
|
|
233
|
+
for (let phase = 0; phase < meter; phase++) {
|
|
234
|
+
const on = levels.filter((_, k) => (k - phase) % meter === 0);
|
|
235
|
+
const off = levels.filter((_, k) => (k - phase) % meter !== 0);
|
|
236
|
+
const mean = (v) => v.reduce((a, b) => a + b, 0) / (v.length || 1);
|
|
237
|
+
const contrast = (mean(on) - mean(off)) * (meter === 3 ? 0.85 : 1);
|
|
238
|
+
if (contrast > best.contrast)
|
|
239
|
+
best = { meter, phase, contrast };
|
|
240
|
+
}
|
|
241
|
+
return best;
|
|
242
|
+
}
|
|
243
|
+
export function medianInterval(times) {
|
|
244
|
+
const gaps = times
|
|
245
|
+
.slice(1)
|
|
246
|
+
.map((t, i) => t - times[i])
|
|
247
|
+
.sort((a, b) => a - b);
|
|
248
|
+
return gaps.length ? gaps[gaps.length >> 1] : null;
|
|
249
|
+
}
|
|
250
|
+
const noRhythm = (confidence, reason = "no steady beat") => ({
|
|
251
|
+
beats: { times: [], confidence, reason },
|
|
252
|
+
downbeats: { times: [], meter: null, confidence: "low" },
|
|
253
|
+
});
|
|
254
|
+
/** Two bars of 4/4 at 80 BPM: anything shorter has too few beats to call steady. */
|
|
255
|
+
const SHORTEST_SECONDS = 6;
|
|
256
|
+
/** Below this the beats are more likely the tracker's own grid than the music's. */
|
|
257
|
+
export const STEADY_BEAT_CONFIDENCE = 0.05;
|
|
258
|
+
/** Clarity of the period, scaled down when the beats are under three times as strong as the onsets around them. */
|
|
259
|
+
const beatConfidence = (clarity, lift) => round(Math.max(0, Math.min(1, clarity)) * Math.max(0, Math.min(1, lift / 3)));
|
|
260
|
+
/** Beats and downbeats of `frames`, the band energies of a signal at `sampleRate`. */
|
|
261
|
+
export function rhythmOf(frames, sampleRate) {
|
|
262
|
+
const curves = onsetCurves(frames, sampleRate);
|
|
263
|
+
const { period, clarity } = beatPeriod(curves.broad, curves.fps);
|
|
264
|
+
if (curves.broad.length < SHORTEST_SECONDS * curves.fps)
|
|
265
|
+
return noRhythm(0, "too short for a steady beat");
|
|
266
|
+
if (!curves.broad.some((v) => v > 0) || curves.broad.length < 2 * period)
|
|
267
|
+
return noRhythm(0);
|
|
268
|
+
const { frames: beatList, local } = beatFrames(curves.broad, curves.fps, period);
|
|
269
|
+
const confidence = beatConfidence(clarity, onsetLift(beatList, local));
|
|
270
|
+
if (beatList.length < 4 || confidence < STEADY_BEAT_CONFIDENCE)
|
|
271
|
+
return noRhythm(confidence);
|
|
272
|
+
const times = beatList.map((f) => round(f / curves.fps));
|
|
273
|
+
const bar = beatList.length >= 6 ? barPhase(beatList, curves) : null;
|
|
274
|
+
return {
|
|
275
|
+
beats: { times, confidence },
|
|
276
|
+
downbeats: {
|
|
277
|
+
times: bar ? times.filter((_, k) => (k - bar.phase) % bar.meter === 0) : [],
|
|
278
|
+
meter: bar?.meter ?? null,
|
|
279
|
+
confidence: "low",
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const TEMPO_STEP_SECONDS = 4;
|
|
284
|
+
/** Tempo from beat times, by whichever provider placed them: the median interval, and a curve over ±4 s windows. */
|
|
285
|
+
export function tempoOf(times) {
|
|
286
|
+
const interval = medianInterval(times);
|
|
287
|
+
if (!interval)
|
|
288
|
+
return { value: null, curve: [] };
|
|
289
|
+
const bpm = (gap) => Math.round(600 / gap) / 10;
|
|
290
|
+
const curve = [];
|
|
291
|
+
const last = times[times.length - 1];
|
|
292
|
+
for (let t = times[0]; t <= last; t += TEMPO_STEP_SECONDS) {
|
|
293
|
+
const gap = medianInterval(times.filter((b) => Math.abs(b - t) <= TEMPO_STEP_SECONDS));
|
|
294
|
+
if (gap)
|
|
295
|
+
curve.push({ t: round(t), bpm: bpm(gap) });
|
|
296
|
+
}
|
|
297
|
+
return { value: bpm(interval), curve };
|
|
298
|
+
}
|
|
299
|
+
const measured = new WeakMap();
|
|
300
|
+
/** Band energies of `signal`, computed once per signal for every capability that reads them. */
|
|
301
|
+
export function bandFramesOf(signal) {
|
|
302
|
+
let entry = measured.get(signal);
|
|
303
|
+
if (!entry)
|
|
304
|
+
measured.set(signal, (entry = { frames: bandFrames(signal) }));
|
|
305
|
+
return entry.frames;
|
|
306
|
+
}
|
|
307
|
+
export function rhythm(signal) {
|
|
308
|
+
const frames = bandFramesOf(signal);
|
|
309
|
+
const entry = measured.get(signal);
|
|
310
|
+
return (entry.rhythm ??= rhythmOf(frames, signal.sampleRate));
|
|
311
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sections from signal processing: band energies averaged into half-second blocks, a self-similarity matrix, and
|
|
3
|
+
* Foote's checkerboard novelty, whose peaks are where the music changes. Boundaries snap to the nearest beat.
|
|
4
|
+
*/
|
|
5
|
+
import type { AudioSignal } from "./audioAnalysis";
|
|
6
|
+
import { type SnapReport } from "./audioSnap";
|
|
7
|
+
export interface Section {
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
/** Sections that sound alike share a letter. */
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Sections {
|
|
14
|
+
spans: Section[];
|
|
15
|
+
snap?: SnapReport;
|
|
16
|
+
}
|
|
17
|
+
export interface SectionOptions {
|
|
18
|
+
/** Shortest section, in seconds. */
|
|
19
|
+
minSeconds?: number;
|
|
20
|
+
}
|
|
21
|
+
export declare function sections(signal: AudioSignal, options?: SectionOptions): Sections;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sections from signal processing: band energies averaged into half-second blocks, a self-similarity matrix, and
|
|
3
|
+
* Foote's checkerboard novelty, whose peaks are where the music changes. Boundaries snap to the nearest beat.
|
|
4
|
+
*/
|
|
5
|
+
import { bandFramesOf, medianInterval, rhythm } from "./audioRhythm.js";
|
|
6
|
+
import { snapper } from "./audioSnap.js";
|
|
7
|
+
const BLOCK_SECONDS = 0.5;
|
|
8
|
+
/** Checkerboard half-width: eight seconds of music either side of a candidate boundary. */
|
|
9
|
+
const KERNEL_BLOCKS = 16;
|
|
10
|
+
const SAME_SECTION = 0.9;
|
|
11
|
+
const round = (t) => Math.round(t * 1000) / 1000;
|
|
12
|
+
function meanOf(frames) {
|
|
13
|
+
const sum = new Array(frames[0]?.length ?? 0).fill(0);
|
|
14
|
+
for (const frame of frames)
|
|
15
|
+
for (let b = 0; b < sum.length; b++)
|
|
16
|
+
sum[b] += frame[b];
|
|
17
|
+
return sum.map((v) => v / (frames.length || 1));
|
|
18
|
+
}
|
|
19
|
+
/** Each band moved to zero mean and unit spread over time, in place. */
|
|
20
|
+
function standardizeBands(blocks) {
|
|
21
|
+
for (let b = 0; b < (blocks[0]?.length ?? 0); b++) {
|
|
22
|
+
const column = blocks.map((block) => block[b]);
|
|
23
|
+
const mean = column.reduce((a, v) => a + v, 0) / column.length;
|
|
24
|
+
const spread = Math.sqrt(column.reduce((a, v) => a + (v - mean) ** 2, 0) / column.length);
|
|
25
|
+
for (const block of blocks)
|
|
26
|
+
block[b] = (block[b] - mean) / (spread || 1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Band energies averaged over blocks, each band standardized over time and each block scaled to unit length. */
|
|
30
|
+
function blockFeatures({ fps, bands }) {
|
|
31
|
+
const per = Math.max(1, Math.round(BLOCK_SECONDS * fps));
|
|
32
|
+
const blocks = [];
|
|
33
|
+
for (let start = 0; start + per <= bands.length; start += per)
|
|
34
|
+
blocks.push(meanOf(bands.slice(start, start + per)));
|
|
35
|
+
standardizeBands(blocks);
|
|
36
|
+
return blocks.map(unit);
|
|
37
|
+
}
|
|
38
|
+
function unit(vector) {
|
|
39
|
+
const length = Math.hypot(...vector) || 1;
|
|
40
|
+
return vector.map((v) => v / length);
|
|
41
|
+
}
|
|
42
|
+
const dot = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
|
|
43
|
+
/** The checkerboard kernel, offsets -KERNEL_BLOCKS…KERNEL_BLOCKS-1 each way: + within a side, − across, tapered. */
|
|
44
|
+
const CHECKERBOARD = (() => {
|
|
45
|
+
const offsets = Array.from({ length: 2 * KERNEL_BLOCKS }, (_, k) => k - KERNEL_BLOCKS);
|
|
46
|
+
const taper = (k) => Math.exp(-0.5 * (((k < 0 ? -k - 1 : k) + 0.5) / (KERNEL_BLOCKS / 2)) ** 2);
|
|
47
|
+
return offsets.map((a) => offsets.map((b) => (a < 0 === b < 0 ? 1 : -1) * taper(a) * taper(b)));
|
|
48
|
+
})();
|
|
49
|
+
/** Foote novelty at block `i`: similarity within each side of it minus similarity across it. */
|
|
50
|
+
function noveltyAt(blocks, i) {
|
|
51
|
+
let sum = 0;
|
|
52
|
+
CHECKERBOARD.forEach((row, a) => {
|
|
53
|
+
const x = blocks[i + a - KERNEL_BLOCKS];
|
|
54
|
+
if (!x)
|
|
55
|
+
return;
|
|
56
|
+
row.forEach((weight, b) => {
|
|
57
|
+
const y = blocks[i + b - KERNEL_BLOCKS];
|
|
58
|
+
if (y)
|
|
59
|
+
sum += weight * dot(x, y);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
return sum;
|
|
63
|
+
}
|
|
64
|
+
const novelty = (blocks) => blocks.map((_, i) => noveltyAt(blocks, i));
|
|
65
|
+
/** Blocks where novelty peaks over its neighbourhood and clears the mean by a standard deviation. */
|
|
66
|
+
function boundaryBlocks(curve, minBlocks) {
|
|
67
|
+
const mean = curve.reduce((a, b) => a + b, 0) / (curve.length || 1);
|
|
68
|
+
const spread = Math.sqrt(curve.reduce((a, v) => a + (v - mean) ** 2, 0) / (curve.length || 1));
|
|
69
|
+
const peaks = [];
|
|
70
|
+
for (let i = minBlocks; i <= curve.length - minBlocks; i++) {
|
|
71
|
+
const neighbourhood = curve.slice(Math.max(0, i - minBlocks), i + minBlocks + 1);
|
|
72
|
+
if (curve[i] < Math.max(...neighbourhood) || curve[i] <= mean + spread)
|
|
73
|
+
continue;
|
|
74
|
+
if (!peaks.length || i - peaks[peaks.length - 1] >= minBlocks)
|
|
75
|
+
peaks.push(i);
|
|
76
|
+
}
|
|
77
|
+
return peaks;
|
|
78
|
+
}
|
|
79
|
+
/** A letter per span: the letter of the first earlier span whose mean features are this alike, else a new one. */
|
|
80
|
+
function labels(blocks, edges) {
|
|
81
|
+
const means = [];
|
|
82
|
+
const letters = [];
|
|
83
|
+
for (let s = 0; s + 1 < edges.length; s++) {
|
|
84
|
+
const part = blocks.slice(edges[s], edges[s + 1]);
|
|
85
|
+
const mean = unit(part[0].map((_, b) => part.reduce((a, block) => a + block[b], 0)));
|
|
86
|
+
const match = means.findIndex((other) => dot(other, mean) >= SAME_SECTION);
|
|
87
|
+
letters.push(match >= 0 ? letters[match] : String.fromCharCode(65 + (new Set(letters).size % 26)));
|
|
88
|
+
means.push(mean);
|
|
89
|
+
}
|
|
90
|
+
return letters;
|
|
91
|
+
}
|
|
92
|
+
/** Half the median beat interval: a boundary moves to a beat, never past one. */
|
|
93
|
+
const beatTolerance = (beats) => (medianInterval(beats) ?? 0) / 2;
|
|
94
|
+
export function sections(signal, options = {}) {
|
|
95
|
+
const duration = round((signal.channels[0]?.length ?? 0) / signal.sampleRate);
|
|
96
|
+
const blocks = blockFeatures(bandFramesOf(signal));
|
|
97
|
+
const minBlocks = Math.max(1, Math.round((options.minSeconds ?? 4) / BLOCK_SECONDS));
|
|
98
|
+
if (blocks.length < 2 * minBlocks)
|
|
99
|
+
return { spans: [{ start: 0, end: duration, label: "A" }] };
|
|
100
|
+
const edges = [0, ...boundaryBlocks(novelty(blocks), minBlocks), blocks.length];
|
|
101
|
+
const letters = labels(blocks, edges);
|
|
102
|
+
const beats = rhythm(signal).beats.times;
|
|
103
|
+
const onBeat = snapper(beats, beatTolerance(beats), "signal beats");
|
|
104
|
+
const times = edges.map((e, i) => i === 0 ? 0 : i === edges.length - 1 ? duration : round(onBeat.snap(e * BLOCK_SECONDS)));
|
|
105
|
+
return {
|
|
106
|
+
spans: letters.map((label, s) => ({ start: times[s], end: times[s + 1], label })),
|
|
107
|
+
snap: onBeat.report(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Moving reported times onto measured ones, one at a time, and saying how far they moved. */
|
|
2
|
+
export interface SnapReport {
|
|
3
|
+
/** What the times were moved onto. */
|
|
4
|
+
reference: string;
|
|
5
|
+
toleranceMs: number;
|
|
6
|
+
/** Times moved onto a measured one. */
|
|
7
|
+
snapped: number;
|
|
8
|
+
/** Times with nothing measured within the tolerance, kept as reported. */
|
|
9
|
+
unsnapped: number[];
|
|
10
|
+
/** Median distance between a reported time and the measured one it moved to, in ms: low means they agreed. */
|
|
11
|
+
medianShiftMs: number | null;
|
|
12
|
+
}
|
|
13
|
+
export interface Snapper {
|
|
14
|
+
snap: (t: number) => number;
|
|
15
|
+
report: () => SnapReport;
|
|
16
|
+
}
|
|
17
|
+
/** Snaps each time to the nearest of `measured` (ascending) when one is within `toleranceSeconds`, else keeps it. */
|
|
18
|
+
export declare function snapper(measured: readonly number[], toleranceSeconds: number, reference: string): Snapper;
|