@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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Measurements of decoded audio over time: an energy envelope, onsets, the moments a sound lands, loudness and silence.
3
+ * Pure functions of PCM samples, so they run the same in Node, Bun and the browser. Times are seconds into the signal.
4
+ */
5
+ import type { AudioAnalysisProvider } from "./audioAnalysisLadder";
6
+ /** Decoded audio: one Float32Array per channel, all the same length. */
7
+ export interface AudioSignal {
8
+ sampleRate: number;
9
+ channels: Float32Array[];
10
+ }
11
+ export type EnvelopeScale = "db" | "linear";
12
+ export interface EnvelopeOptions {
13
+ hopMs?: number;
14
+ /** RMS window; twice the hop when left out. */
15
+ windowMs?: number;
16
+ scale?: EnvelopeScale;
17
+ /** Measure only this frequency band, in Hz. */
18
+ band?: [number, number];
19
+ }
20
+ export interface Envelope {
21
+ hopMs: number;
22
+ scale: EnvelopeScale;
23
+ /** One value per hop, the first centred on time 0. */
24
+ values: number[];
25
+ }
26
+ export type OnsetMethod = "spectral-flux" | "energy";
27
+ export interface OnsetOptions {
28
+ method?: OnsetMethod;
29
+ band?: [number, number];
30
+ /** Smallest normalized strength, 0–1, that counts as an onset. */
31
+ threshold?: number;
32
+ /** Onsets closer than this keep only the stronger. */
33
+ minGapMs?: number;
34
+ hopMs?: number;
35
+ }
36
+ export interface Onset {
37
+ t: number;
38
+ /** Relative to the strongest onset in the signal, 0–1. */
39
+ strength: number;
40
+ }
41
+ export interface Onsets {
42
+ method: OnsetMethod;
43
+ events: Onset[];
44
+ }
45
+ export interface MomentOptions {
46
+ /** The onset is where RMS first reaches this fraction of its peak. */
47
+ onsetRatio?: number;
48
+ /** The tail ends where RMS last stays above the peak plus this many dB. */
49
+ tailDb?: number;
50
+ hopMs?: number;
51
+ }
52
+ /** Where a sound lands. Null for a silent signal. */
53
+ export interface Moments {
54
+ /** RMS first reaches `onsetRatio` of its peak. */
55
+ onset: number;
56
+ /** The largest rise in RMS between neighbouring hops. */
57
+ steepest: number;
58
+ /** The loudest hop. */
59
+ peak: number;
60
+ /** The last hop within `tailDb` of the peak. */
61
+ tailEnd: number;
62
+ }
63
+ export interface Loudness {
64
+ /** ITU-R BS.1770 gated integrated loudness; null when every block is below the absolute gate. */
65
+ lufsIntegrated: number | null;
66
+ /** The loudest 3 s window. */
67
+ lufsShortTermMax: number | null;
68
+ samplePeakDb: number;
69
+ /** A 4× oversampled estimate of the true peak, in dBTP. */
70
+ truePeakDb: number;
71
+ clipping: {
72
+ count: number;
73
+ firstAt: number | null;
74
+ };
75
+ }
76
+ export interface SilenceOptions {
77
+ thresholdDb?: number;
78
+ minMs?: number;
79
+ hopMs?: number;
80
+ }
81
+ export interface Span {
82
+ start: number;
83
+ end: number;
84
+ }
85
+ export type AudioKind = "sfx" | "music" | "voice" | "mixed";
86
+ export interface KindGuess {
87
+ value: AudioKind;
88
+ /** 0–1. Signal heuristics only: treat below 0.6 as a guess. */
89
+ confidence: number;
90
+ /** What the guess was made from. */
91
+ features: KindFeatures;
92
+ }
93
+ export interface KindFeatures {
94
+ duration: number;
95
+ /** Strongest autocorrelation of the onset curve at beat-like lags (0.3–1.5 s), 0–1. */
96
+ regularity: number;
97
+ /** Share of envelope modulation at syllable rate (3–8 Hz), 0–1. */
98
+ syllables: number;
99
+ /** Share of 50 ms frames within 20 dB of the loud end (95th percentile), 0–1: high for beds and speech, low for hits. */
100
+ sustain: number;
101
+ }
102
+ export declare const signalDuration: (signal: AudioSignal) => number;
103
+ export declare function envelope(signal: AudioSignal, options?: EnvelopeOptions): Envelope;
104
+ export declare function onsets(signal: AudioSignal, options?: OnsetOptions): Onsets;
105
+ export declare function moments(signal: AudioSignal, options?: MomentOptions): Moments | null;
106
+ export declare function loudness(signal: AudioSignal): Loudness;
107
+ export declare function silence(signal: AudioSignal, options?: SilenceOptions): Span[];
108
+ export declare function kindFeatures(signal: AudioSignal): KindFeatures;
109
+ /**
110
+ * A first guess at what the audio is. Length decides most of it: beds run long, effects short. Long audio is voice
111
+ * when its envelope moves at syllable rate without a beat; short audio is voice when it also holds its level.
112
+ */
113
+ export declare function classify(signal: AudioSignal): KindGuess;
114
+ export * from "./audioAnalysisLadder";
115
+ export * from "./audioRhythm";
116
+ export * from "./audioSections";
117
+ export * from "./audioSnap";
118
+ /** Signal processing as a ladder provider: always available, the measured ground every other provider snaps to. */
119
+ export declare const signalProvider: AudioAnalysisProvider<AudioSignal>;
@@ -0,0 +1,466 @@
1
+ /**
2
+ * Measurements of decoded audio over time: an energy envelope, onsets, the moments a sound lands, loudness and silence.
3
+ * Pure functions of PCM samples, so they run the same in Node, Bun and the browser. Times are seconds into the signal.
4
+ */
5
+ import { fft } from "./fft.js";
6
+ import { rhythm } from "./audioRhythm.js";
7
+ import { sections } from "./audioSections.js";
8
+ const DB_FLOOR = -120;
9
+ export const signalDuration = (signal) => (signal.channels[0]?.length ?? 0) / signal.sampleRate;
10
+ /** Per-sample energy. Channels are squared first, so a reversed channel stays audible. */
11
+ function energyMix(signal) {
12
+ const [first, ...rest] = signal.channels;
13
+ if (!first)
14
+ return new Float32Array(0);
15
+ if (!rest.length)
16
+ return first;
17
+ const mixed = new Float32Array(first.length);
18
+ for (const channel of signal.channels)
19
+ for (let i = 0; i < mixed.length; i++)
20
+ mixed[i] += channel[i] * channel[i];
21
+ const scale = 1 / signal.channels.length;
22
+ for (let i = 0; i < mixed.length; i++)
23
+ mixed[i] = Math.sqrt(mixed[i] * scale);
24
+ return mixed;
25
+ }
26
+ const toDb = (amplitude) => amplitude > 0 ? Math.max(DB_FLOOR, 20 * Math.log10(amplitude)) : DB_FLOOR;
27
+ const hopSamples = (sampleRate, hopMs) => Math.max(1, Math.round((sampleRate * hopMs) / 1000));
28
+ const round = (t) => Math.round(t * 1e4) / 1e4;
29
+ function rmsFrames(samples, sampleRate, hopMs, windowMs) {
30
+ const hop = hopSamples(sampleRate, hopMs);
31
+ const half = Math.max(1, Math.round((sampleRate * windowMs) / 2000));
32
+ const frames = [];
33
+ for (let center = 0; center < samples.length; center += hop) {
34
+ const from = Math.max(0, center - half);
35
+ const to = Math.min(samples.length, center + half);
36
+ let sum = 0;
37
+ for (let i = from; i < to; i++)
38
+ sum += samples[i] * samples[i];
39
+ frames.push(to > from ? Math.sqrt(sum / (to - from)) : 0);
40
+ }
41
+ return frames;
42
+ }
43
+ const fftSizeFor = (sampleRate) => 2 ** Math.round(Math.log2(sampleRate * 0.023));
44
+ /** Magnitude spectra of Hann-windowed frames centred on each hop, limited to `band`. */
45
+ /** The FFT bins inside `band`, as [first, end). */
46
+ function bandBins(sampleRate, size, band) {
47
+ const binHz = sampleRate / size;
48
+ const low = Math.max(1, Math.floor((band?.[0] ?? 0) / binHz));
49
+ return [low, Math.max(low, Math.min(size / 2, Math.ceil((band?.[1] ?? sampleRate / 2) / binHz)))];
50
+ }
51
+ /** Load the Hann-windowed frame centred on `center` into `re`, zero outside the signal. */
52
+ function loadFrame(samples, center, window, re, im) {
53
+ const first = center - window.length / 2;
54
+ for (let i = 0; i < window.length; i++) {
55
+ const at = first + i;
56
+ re[i] = at >= 0 && at < samples.length ? samples[at] * window[i] : 0;
57
+ im[i] = 0;
58
+ }
59
+ }
60
+ function spectra(samples, sampleRate, hopMs, band) {
61
+ const size = fftSizeFor(sampleRate);
62
+ const hop = hopSamples(sampleRate, hopMs);
63
+ const window = Float64Array.from({ length: size }, (_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / size));
64
+ const [low, high] = bandBins(sampleRate, size, band);
65
+ const frames = [];
66
+ const re = new Float64Array(size);
67
+ const im = new Float64Array(size);
68
+ for (let center = 0; center < samples.length; center += hop) {
69
+ loadFrame(samples, center, window, re, im);
70
+ fft(re, im);
71
+ const magnitudes = new Float64Array(high - low);
72
+ for (let k = low; k < high; k++)
73
+ magnitudes[k - low] = Math.hypot(re[k], im[k]);
74
+ frames.push(magnitudes);
75
+ }
76
+ return frames;
77
+ }
78
+ function averageMagnitudes(frames) {
79
+ const mixed = new Float64Array(frames[0]?.length ?? 0);
80
+ for (const frame of frames)
81
+ for (let bin = 0; bin < mixed.length; bin++)
82
+ mixed[bin] += frame[bin];
83
+ const scale = frames.length || 1;
84
+ for (let bin = 0; bin < mixed.length; bin++)
85
+ mixed[bin] /= scale;
86
+ return mixed;
87
+ }
88
+ /** Mean magnitude spectrum across channels, so opposite polarity cannot cancel a bin. */
89
+ function meanSpectra(signal, hopMs, band) {
90
+ const stacked = signal.channels.map((channel) => spectra(channel, signal.sampleRate, hopMs, band));
91
+ const count = stacked[0]?.length ?? 0;
92
+ const mixed = [];
93
+ for (let frame = 0; frame < count; frame++)
94
+ mixed.push(averageMagnitudes(stacked.map((channel) => channel[frame])));
95
+ return mixed;
96
+ }
97
+ export function envelope(signal, options = {}) {
98
+ const { hopMs = 5, scale = "db", band } = options;
99
+ const windowMs = options.windowMs ?? hopMs * 2;
100
+ const size = fftSizeFor(signal.sampleRate);
101
+ // Parseval over the band's bins; a Hann window keeps 0.375 of a frame's power.
102
+ const amplitudes = band
103
+ ? meanSpectra(signal, hopMs, band).map((frame) => {
104
+ let sum = 0;
105
+ for (const magnitude of frame)
106
+ sum += magnitude * magnitude;
107
+ return Math.sqrt((2 * sum) / (size * size * 0.375));
108
+ })
109
+ : rmsFrames(energyMix(signal), signal.sampleRate, hopMs, windowMs);
110
+ return { hopMs, scale, values: amplitudes.map((a) => (scale === "db" ? toDb(a) : a)) };
111
+ }
112
+ /** Positive change between neighbouring frames: spectral flux of log magnitudes, or the rise in RMS. */
113
+ function noveltyCurve(signal, method, hopMs, band) {
114
+ const samples = energyMix(signal);
115
+ if (method === "energy") {
116
+ const rms = rmsFrames(samples, signal.sampleRate, hopMs, hopMs * 2);
117
+ return rms.map((value, i) => Math.max(0, value - (rms[i - 1] ?? 0)));
118
+ }
119
+ const frames = meanSpectra(signal, hopMs, band).map((frame) => frame.map((m) => Math.log1p(100 * m)));
120
+ // A frame cut off by the end of the signal splatters across the spectrum; that is the edge, not a hit.
121
+ const lastWhole = Math.floor((samples.length - fftSizeFor(signal.sampleRate) / 2) / hopSamples(signal.sampleRate, hopMs));
122
+ return frames.map((frame, i) => {
123
+ const previous = frames[i - 1];
124
+ if (!previous || i > lastWhole)
125
+ return 0;
126
+ let flux = 0;
127
+ for (let k = 0; k < frame.length; k++)
128
+ flux += Math.max(0, frame[k] - previous[k]);
129
+ return flux;
130
+ });
131
+ }
132
+ /** Peaks of the novelty curve that stand out from their neighbourhood. */
133
+ /** A local maximum within `gap` frames that clears the mean of its ±`context` neighbourhood by 5% of the top. */
134
+ function isOnsetPeak(curve, i, gap, context, top) {
135
+ const value = curve[i];
136
+ let sum = 0;
137
+ let count = 0;
138
+ for (let j = Math.max(0, i - context); j <= Math.min(curve.length - 1, i + context); j++) {
139
+ if (Math.abs(j - i) <= gap && curve[j] > value)
140
+ return false;
141
+ sum += curve[j];
142
+ count++;
143
+ }
144
+ return value >= sum / count + 0.05 * top;
145
+ }
146
+ export function onsets(signal, options = {}) {
147
+ const { method = "spectral-flux", band, threshold = 0.1, minGapMs = 30, hopMs = 5 } = options;
148
+ const curve = noveltyCurve(signal, method, hopMs, band);
149
+ const top = Math.max(0, ...curve);
150
+ if (top <= 0)
151
+ return { method, events: [] };
152
+ const gap = Math.max(1, Math.round(minGapMs / hopMs));
153
+ const context = Math.max(gap, Math.round(100 / hopMs));
154
+ const hopSeconds = hopSamples(signal.sampleRate, hopMs) / signal.sampleRate;
155
+ const events = [];
156
+ for (let i = 0; i < curve.length; i++) {
157
+ const strength = curve[i] / top;
158
+ if (strength < threshold || !isOnsetPeak(curve, i, gap, context, top))
159
+ continue;
160
+ const onset = { t: round(i * hopSeconds), strength: round(strength) };
161
+ const last = events[events.length - 1];
162
+ if (!last || onset.t - last.t >= minGapMs / 1000)
163
+ events.push(onset);
164
+ else if (onset.strength > last.strength)
165
+ events[events.length - 1] = onset;
166
+ }
167
+ return { method, events };
168
+ }
169
+ export function moments(signal, options = {}) {
170
+ const { onsetRatio = 0.2, tailDb = -40, hopMs = 1 } = options;
171
+ const rms = rmsFrames(energyMix(signal), signal.sampleRate, hopMs, Math.max(4, hopMs * 4));
172
+ const hopSeconds = hopSamples(signal.sampleRate, hopMs) / signal.sampleRate;
173
+ let peakIndex = 0;
174
+ for (let i = 1; i < rms.length; i++)
175
+ if (rms[i] > rms[peakIndex])
176
+ peakIndex = i;
177
+ const peak = rms[peakIndex] ?? 0;
178
+ if (peak <= 0)
179
+ return null;
180
+ let onsetIndex = rms.findIndex((value) => value >= peak * onsetRatio);
181
+ if (onsetIndex < 0)
182
+ onsetIndex = peakIndex;
183
+ let steepestIndex = onsetIndex;
184
+ let steepestRise = -Infinity;
185
+ for (let i = 1; i <= peakIndex; i++) {
186
+ const rise = rms[i] - rms[i - 1];
187
+ if (rise > steepestRise)
188
+ [steepestRise, steepestIndex] = [rise, i];
189
+ }
190
+ const floor = peak * 10 ** (tailDb / 20);
191
+ let tailIndex = peakIndex;
192
+ for (let i = rms.length - 1; i > peakIndex; i--)
193
+ if (rms[i] >= floor) {
194
+ tailIndex = i;
195
+ break;
196
+ }
197
+ return {
198
+ onset: round(onsetIndex * hopSeconds),
199
+ steepest: round(steepestIndex * hopSeconds),
200
+ peak: round(peakIndex * hopSeconds),
201
+ tailEnd: round(tailIndex * hopSeconds),
202
+ };
203
+ }
204
+ /** BS.1770 K-weighting at any sample rate: a high-shelf pre-filter, then the RLB high-pass. */
205
+ function kWeighting(sampleRate) {
206
+ let k = Math.tan((Math.PI * 1681.974450955533) / sampleRate);
207
+ const q = 0.7071752369554196;
208
+ const vh = 10 ** (3.999843853973347 / 20);
209
+ const vb = vh ** 0.4996667741545416;
210
+ let a0 = 1 + k / q + k * k;
211
+ const shelf = [
212
+ (vh + (vb * k) / q + k * k) / a0,
213
+ (2 * (k * k - vh)) / a0,
214
+ (vh - (vb * k) / q + k * k) / a0,
215
+ (2 * (k * k - 1)) / a0,
216
+ (1 - k / q + k * k) / a0,
217
+ ];
218
+ k = Math.tan((Math.PI * 38.13547087602444) / sampleRate);
219
+ const q2 = 0.5003270373238773;
220
+ a0 = 1 + k / q2 + k * k;
221
+ const highPass = [1, -2, 1, (2 * (k * k - 1)) / a0, (1 - k / q2 + k * k) / a0];
222
+ return [shelf, highPass];
223
+ }
224
+ function filtered(samples, [b0, b1, b2, a1, a2]) {
225
+ const out = new Float64Array(samples.length);
226
+ let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
227
+ for (let i = 0; i < samples.length; i++) {
228
+ const x = samples[i];
229
+ const y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
230
+ x2 = x1;
231
+ x1 = x;
232
+ y2 = y1;
233
+ y1 = y;
234
+ out[i] = y;
235
+ }
236
+ return out;
237
+ }
238
+ const blockLoudness = (meanSquare) => meanSquare > 0 ? -0.691 + 10 * Math.log10(meanSquare) : -Infinity;
239
+ /** Running sum of squares, so any block's power is one subtraction. */
240
+ function squareSums(channel) {
241
+ const sums = new Float64Array(channel.length + 1);
242
+ for (let i = 0; i < channel.length; i++)
243
+ sums[i + 1] = sums[i] + channel[i] * channel[i];
244
+ return sums;
245
+ }
246
+ /** Summed channel power of each block of `blockSeconds`, stepped by `stepSeconds`. */
247
+ function blockPowers(sums, sampleRate, blockSeconds, stepSeconds) {
248
+ const length = (sums[0]?.length ?? 1) - 1;
249
+ if (!length)
250
+ return [];
251
+ const block = Math.min(length, Math.round(blockSeconds * sampleRate));
252
+ const step = Math.round(stepSeconds * sampleRate);
253
+ const powers = [];
254
+ for (let start = 0; start + block <= length; start += step)
255
+ powers.push(sums.reduce((power, sum) => power + (sum[start + block] - sum[start]) / block, 0));
256
+ return powers;
257
+ }
258
+ const TRUE_PEAK_OVERSAMPLE = 4;
259
+ const TRUE_PEAK_TAPS = 12;
260
+ const TRUE_PEAK_KERNELS = Array.from({ length: TRUE_PEAK_OVERSAMPLE - 1 }, (_, phase) => {
261
+ const offset = (phase + 1) / TRUE_PEAK_OVERSAMPLE;
262
+ return Float64Array.from({ length: 2 * TRUE_PEAK_TAPS }, (_, tap) => {
263
+ const x = tap - TRUE_PEAK_TAPS + 1 - offset;
264
+ const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
265
+ return sinc * (0.5 + 0.5 * Math.cos((Math.PI * x) / TRUE_PEAK_TAPS));
266
+ });
267
+ });
268
+ /**
269
+ * The 4× oversampled peak, interpolated only between samples at least half the sample peak: overshooting both
270
+ * neighbours by 6 dB takes energy near Nyquist that real program material does not carry.
271
+ */
272
+ function truePeak(channel) {
273
+ let samplePeak = 0;
274
+ for (let i = 0; i < channel.length; i++)
275
+ samplePeak = Math.max(samplePeak, Math.abs(channel[i]));
276
+ const floor = samplePeak / 2;
277
+ const taps = 2 * TRUE_PEAK_TAPS;
278
+ let peak = samplePeak;
279
+ for (let n = 0; n + taps <= channel.length; n++) {
280
+ if (Math.abs(channel[n + TRUE_PEAK_TAPS - 1]) < floor &&
281
+ Math.abs(channel[n + TRUE_PEAK_TAPS]) < floor)
282
+ continue;
283
+ for (const kernel of TRUE_PEAK_KERNELS) {
284
+ let y = 0;
285
+ for (let tap = 0; tap < taps; tap++)
286
+ y += channel[n + tap] * kernel[tap];
287
+ if (y > peak)
288
+ peak = y;
289
+ else if (-y > peak)
290
+ peak = -y;
291
+ }
292
+ }
293
+ return peak;
294
+ }
295
+ export function loudness(signal) {
296
+ const [shelf, highPass] = kWeighting(signal.sampleRate);
297
+ const sums = signal.channels.map((channel) => squareSums(filtered(filtered(channel, shelf), highPass)));
298
+ const momentary = blockPowers(sums, signal.sampleRate, 0.4, 0.1);
299
+ const audible = momentary.filter((power) => blockLoudness(power) > -70);
300
+ let lufsIntegrated = null;
301
+ if (audible.length) {
302
+ const relative = blockLoudness(audible.reduce((a, b) => a + b, 0) / audible.length) - 10;
303
+ const gated = audible.filter((power) => blockLoudness(power) > relative);
304
+ if (gated.length)
305
+ lufsIntegrated =
306
+ Math.round(blockLoudness(gated.reduce((a, b) => a + b, 0) / gated.length) * 10) / 10;
307
+ }
308
+ const shortTerm = blockPowers(sums, signal.sampleRate, 3, 0.1)
309
+ .map(blockLoudness)
310
+ .filter(Number.isFinite);
311
+ let samplePeak = 0;
312
+ let count = 0;
313
+ let firstAt = null;
314
+ for (const channel of signal.channels)
315
+ for (let i = 0; i < channel.length; i++) {
316
+ const magnitude = Math.abs(channel[i]);
317
+ samplePeak = Math.max(samplePeak, magnitude);
318
+ if (magnitude >= 0.999) {
319
+ count++;
320
+ const t = i / signal.sampleRate;
321
+ if (firstAt === null || t < firstAt)
322
+ firstAt = round(t);
323
+ }
324
+ }
325
+ return {
326
+ lufsIntegrated,
327
+ lufsShortTermMax: shortTerm.length ? Math.round(Math.max(...shortTerm) * 10) / 10 : null,
328
+ samplePeakDb: Math.round(toDb(samplePeak) * 10) / 10,
329
+ truePeakDb: Math.round(toDb(Math.max(0, ...signal.channels.map(truePeak))) * 10) / 10,
330
+ clipping: { count, firstAt },
331
+ };
332
+ }
333
+ export function silence(signal, options = {}) {
334
+ const { thresholdDb = -50, minMs = 200, hopMs = 5 } = options;
335
+ const db = rmsFrames(energyMix(signal), signal.sampleRate, hopMs, hopMs * 2).map(toDb);
336
+ const hopSeconds = hopSamples(signal.sampleRate, hopMs) / signal.sampleRate;
337
+ const spans = [];
338
+ let from = null;
339
+ for (let i = 0; i <= db.length; i++) {
340
+ const quiet = i < db.length && db[i] < thresholdDb;
341
+ if (quiet && from === null)
342
+ from = i;
343
+ if (!quiet && from !== null) {
344
+ if ((i - from) * hopSeconds * 1000 >= minMs)
345
+ spans.push({
346
+ start: round(from * hopSeconds),
347
+ end: round(Math.min(i * hopSeconds, signalDuration(signal))),
348
+ });
349
+ from = null;
350
+ }
351
+ }
352
+ return spans;
353
+ }
354
+ /** How regular the onsets are: the strongest normalized autocorrelation of the novelty curve at beat-like lags. */
355
+ function periodicity(curve, hopMs) {
356
+ const mean = curve.reduce((a, b) => a + b, 0) / (curve.length || 1);
357
+ const centred = curve.map((v) => v - mean);
358
+ const energy = centred.reduce((a, v) => a + v * v, 0);
359
+ if (!energy)
360
+ return 0;
361
+ let best = 0;
362
+ for (let lag = Math.round(300 / hopMs); lag <= Math.round(1500 / hopMs) && lag < centred.length; lag++) {
363
+ let sum = 0;
364
+ for (let i = lag; i < centred.length; i++)
365
+ sum += centred[i] * centred[i - lag];
366
+ best = Math.max(best, sum / energy);
367
+ }
368
+ return best;
369
+ }
370
+ /** How much of the envelope's modulation sits at syllable rate (3–8 Hz). */
371
+ function syllableModulation(signal) {
372
+ const hopMs = 10;
373
+ const rms = rmsFrames(energyMix(signal), signal.sampleRate, hopMs, 25);
374
+ const size = 2 ** Math.floor(Math.log2(Math.min(rms.length, 1024)));
375
+ if (size < 64)
376
+ return 0;
377
+ const re = Float64Array.from(rms.slice(0, size));
378
+ const mean = re.reduce((a, b) => a + b, 0) / size;
379
+ for (let i = 0; i < size; i++)
380
+ re[i] = re[i] - mean;
381
+ const im = new Float64Array(size);
382
+ fft(re, im);
383
+ const rate = 1000 / hopMs;
384
+ let inBand = 0;
385
+ let total = 0;
386
+ for (let k = 1; k < size / 2; k++) {
387
+ const power = re[k] * re[k] + im[k] * im[k];
388
+ const hz = (k * rate) / size;
389
+ total += power;
390
+ if (hz >= 3 && hz <= 8)
391
+ inBand += power;
392
+ }
393
+ return total ? inBand / total : 0;
394
+ }
395
+ function sustainShare(signal) {
396
+ const levels = rmsFrames(energyMix(signal), signal.sampleRate, 50, 100).map(toDb);
397
+ if (!levels.length)
398
+ return 0;
399
+ const loud = [...levels].sort((a, b) => a - b)[Math.floor((levels.length - 1) * 0.95)];
400
+ return levels.filter((level) => level > loud - 20).length / levels.length;
401
+ }
402
+ export function kindFeatures(signal) {
403
+ return {
404
+ duration: round(signalDuration(signal)),
405
+ regularity: round(periodicity(noveltyCurve(signal, "spectral-flux", 10), 10)),
406
+ syllables: round(syllableModulation(signal)),
407
+ sustain: round(sustainShare(signal)),
408
+ };
409
+ }
410
+ /**
411
+ * A first guess at what the audio is. Length decides most of it: beds run long, effects short. Long audio is voice
412
+ * when its envelope moves at syllable rate without a beat; short audio is voice when it also holds its level.
413
+ */
414
+ export function classify(signal) {
415
+ const features = kindFeatures(signal);
416
+ const { duration, regularity, syllables, sustain } = features;
417
+ const speechLike = syllables >= 0.35 && regularity < 0.35;
418
+ if (duration >= 8) {
419
+ if (sustain < 0.4)
420
+ return { value: "sfx", confidence: 0.6, features };
421
+ if (speechLike)
422
+ return { value: "voice", confidence: round(Math.min(0.9, 0.4 + syllables)), features };
423
+ return { value: "music", confidence: round(Math.min(0.9, 0.5 + regularity)), features };
424
+ }
425
+ if (duration >= 1.5 && syllables >= 0.17 && regularity < 0.3 && sustain >= 0.7)
426
+ return { value: "voice", confidence: speechLike ? 0.8 : 0.6, features };
427
+ return { value: "sfx", confidence: duration <= 2 ? 0.85 : 0.65, features };
428
+ }
429
+ export * from "./audioAnalysisLadder.js";
430
+ export * from "./audioRhythm.js";
431
+ export * from "./audioSections.js";
432
+ export * from "./audioSnap.js";
433
+ const SIGNAL_MEASURES = {
434
+ beats: (input) => rhythm(input).beats,
435
+ downbeats: (input) => rhythm(input).downbeats,
436
+ sections: (input, options) => sections(input, options),
437
+ envelope: (input, options) => envelope(input, options),
438
+ onsets: (input, options) => onsets(input, options),
439
+ moments: (input, options) => moments(input, options),
440
+ loudness: (input) => loudness(input),
441
+ silence: (input, options) => silence(input, options),
442
+ kind: (input) => classify(input),
443
+ };
444
+ /** Signal processing as a ladder provider: always available, the measured ground every other provider snaps to. */
445
+ export const signalProvider = {
446
+ id: "signal",
447
+ local: true,
448
+ signal: true,
449
+ capabilities: [
450
+ "envelope",
451
+ "onsets",
452
+ "moments",
453
+ "loudness",
454
+ "silence",
455
+ "kind",
456
+ "beats",
457
+ "downbeats",
458
+ "sections",
459
+ ],
460
+ async analyze(capability, input, options) {
461
+ const measure = SIGNAL_MEASURES[capability];
462
+ if (!measure)
463
+ throw new Error(`signal processing does not measure ${capability}`);
464
+ return measure(input, options);
465
+ },
466
+ };
@@ -0,0 +1,52 @@
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
+ export type AudioCapability = "envelope" | "onsets" | "moments" | "loudness" | "silence" | "kind" | "beats" | "downbeats" | "bpm" | "sections" | "describe";
7
+ /** `best` may use any provider; `local` none that sends audio off the machine; `fast` only signal processing. */
8
+ export type AnalysisQuality = "best" | "local" | "fast";
9
+ /** Whether a provider can run here now, and if not, what is missing. */
10
+ export type Availability = {
11
+ ok: true;
12
+ } | {
13
+ ok: false;
14
+ reason: string;
15
+ };
16
+ export interface AudioAnalysisProvider<Input = unknown> {
17
+ id: string;
18
+ /** Runs on this machine; a provider that is not local sends the audio to a service. */
19
+ local: boolean;
20
+ /** Pure signal processing: no model, no download. */
21
+ signal?: boolean;
22
+ capabilities: readonly AudioCapability[];
23
+ /** Whether it can run here now: a key, a platform, a model on disk. Missing means always. */
24
+ available?: () => Availability | Promise<Availability>;
25
+ analyze: (capability: AudioCapability, input: Input, options: Record<string, unknown>) => Promise<unknown>;
26
+ }
27
+ export type LadderOutcome = "answered" | "unavailable" | "excluded" | "failed";
28
+ export interface LadderStep {
29
+ provider: string;
30
+ outcome: LadderOutcome;
31
+ reason?: string;
32
+ }
33
+ export interface LadderResult<Value = unknown> {
34
+ value: Value | null;
35
+ /** The provider that answered; null when none could. */
36
+ provenance: string | null;
37
+ steps: LadderStep[];
38
+ warnings: string[];
39
+ }
40
+ export interface LadderOptions {
41
+ quality?: AnalysisQuality;
42
+ /** This capability's ladder, best first, in place of the default. */
43
+ ladder?: readonly string[];
44
+ /** Use only this provider. */
45
+ provider?: string;
46
+ options?: Record<string, unknown>;
47
+ }
48
+ /** The default order per capability: every registered provider that offers it, in registration order. */
49
+ export declare function defaultLadder<Input>(capability: AudioCapability, providers: readonly AudioAnalysisProvider<Input>[]): string[];
50
+ /** Why a provider may not be used at this quality, or null when it may. */
51
+ export declare function exclusion<Input>(provider: AudioAnalysisProvider<Input>, quality: AnalysisQuality): string | null;
52
+ export declare function runLadder<Input, Value = unknown>(capability: AudioCapability, input: Input, providers: readonly AudioAnalysisProvider<Input>[], { quality, ladder, provider, options }?: LadderOptions): Promise<LadderResult<Value>>;