@hyperframes/core 0.8.159 → 0.8.161

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 (63) hide show
  1. package/dist/adapters/gsap.d.ts +1 -1
  2. package/dist/adapters/index.d.ts +3 -3
  3. package/dist/audioAnalysis.d.ts +121 -0
  4. package/dist/audioAnalysis.js +466 -0
  5. package/dist/audioAnalysisLadder.d.ts +52 -0
  6. package/dist/audioAnalysisLadder.js +78 -0
  7. package/dist/audioCarve.js +1 -36
  8. package/dist/audioResample.d.ts +2 -0
  9. package/dist/audioResample.js +37 -0
  10. package/dist/audioRhythm.d.ts +48 -0
  11. package/dist/audioRhythm.js +407 -0
  12. package/dist/audioSections.d.ts +21 -0
  13. package/dist/audioSections.js +117 -0
  14. package/dist/audioSnap.d.ts +18 -0
  15. package/dist/audioSnap.js +41 -0
  16. package/dist/beats/beatDetection.d.ts +6 -8
  17. package/dist/beats/beatDetection.js +25 -217
  18. package/dist/colorGrading.d.ts +3 -3
  19. package/dist/compiler/htmlBundler.d.ts +2 -2
  20. package/dist/compiler/index.d.ts +19 -19
  21. package/dist/compiler/inlineSubCompositions.d.ts +4 -4
  22. package/dist/compiler/svgIdNamespacing.d.ts +1 -1
  23. package/dist/fft.d.ts +2 -0
  24. package/dist/fft.js +36 -0
  25. package/dist/figma/assetSnippet.d.ts +1 -1
  26. package/dist/figma/client.d.ts +1 -1
  27. package/dist/figma/emitTimelineScript.d.ts +1 -1
  28. package/dist/figma/index.d.ts +22 -22
  29. package/dist/figma/manifest.d.ts +1 -1
  30. package/dist/figma/motionContextToDocs.d.ts +1 -1
  31. package/dist/figma/motionEase.d.ts +1 -1
  32. package/dist/figma/motionToGsap.d.ts +1 -1
  33. package/dist/figma/nodeDocument.d.ts +1 -1
  34. package/dist/figma/nodeToHtml.d.ts +3 -3
  35. package/dist/figma/parseFigmaRef.d.ts +1 -1
  36. package/dist/figma/resolveBindings.d.ts +2 -2
  37. package/dist/figma/tokensToVariables.d.ts +2 -2
  38. package/dist/generated/runtime-inline.js +1 -1
  39. package/dist/generators/hyperframes.d.ts +1 -1
  40. package/dist/hyperframe.runtime.iife.js +55 -55
  41. package/dist/index.d.ts +37 -37
  42. package/dist/inline-scripts/hyperframe.d.ts +1 -1
  43. package/dist/projects.d.ts +1 -1
  44. package/dist/runtime/bridge.d.ts +2 -2
  45. package/dist/runtime/clipTree.d.ts +1 -1
  46. package/dist/runtime/compositionLength.d.ts +2 -2
  47. package/dist/runtime/media.d.ts +1 -0
  48. package/dist/runtime/media.js +35 -0
  49. package/dist/runtime/mediaVolumeEnvelope.d.ts +1 -1
  50. package/dist/runtime/playbackRate.d.ts +1 -1
  51. package/dist/runtime/startResolver.d.ts +3 -3
  52. package/dist/runtime/state.d.ts +3 -3
  53. package/dist/runtime/timeline.d.ts +2 -2
  54. package/dist/runtime/timelineDuration.d.ts +1 -1
  55. package/dist/runtime/types.d.ts +2 -2
  56. package/dist/runtime/validateVariables.d.ts +1 -1
  57. package/dist/templates/base.d.ts +1 -1
  58. package/dist/vfx/refs/displacementMap.d.ts +1 -1
  59. package/dist/vfx/refs/fractalNoise.d.ts +1 -1
  60. package/dist/vfx/refs/lumaMatte.d.ts +1 -1
  61. package/dist/vfx/refs/noise.d.ts +1 -1
  62. package/dist/vfx/refs/waveWarp.d.ts +1 -1
  63. package/package.json +9 -6
@@ -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
+ }
@@ -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,2 @@
1
+ /** Band-limited resampling by windowed-sinc interpolation, so analysis reads the same at any decode rate. */
2
+ export declare function resample(samples: Float32Array, from: number, to: number): Float32Array;
@@ -0,0 +1,37 @@
1
+ /** Band-limited resampling by windowed-sinc interpolation, so analysis reads the same at any decode rate. */
2
+ const ZERO_CROSSINGS = 8;
3
+ const STEPS_PER_CROSSING = 256;
4
+ /** Cutoff as a share of the lower Nyquist, leaving the window's transition band below it. */
5
+ const CUTOFF = 0.95;
6
+ const KERNEL = Float64Array.from({ length: ZERO_CROSSINGS * STEPS_PER_CROSSING + 2 }, (_, i) => {
7
+ const x = i / STEPS_PER_CROSSING;
8
+ if (x >= ZERO_CROSSINGS)
9
+ return 0;
10
+ const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
11
+ return sinc * (0.5 + 0.5 * Math.cos((Math.PI * x) / ZERO_CROSSINGS));
12
+ });
13
+ function kernelAt(x) {
14
+ const at = Math.abs(x) * STEPS_PER_CROSSING;
15
+ const i = Math.floor(at);
16
+ if (i + 1 >= KERNEL.length)
17
+ return 0;
18
+ const frac = at - i;
19
+ return KERNEL[i] * (1 - frac) + KERNEL[i + 1] * frac;
20
+ }
21
+ export function resample(samples, from, to) {
22
+ if (from === to)
23
+ return samples;
24
+ const ratio = to / from;
25
+ const scale = Math.min(1, ratio) * CUTOFF;
26
+ const reach = ZERO_CROSSINGS / scale;
27
+ const out = new Float32Array(Math.floor(samples.length * ratio));
28
+ for (let n = 0; n < out.length; n++) {
29
+ const t = n / ratio;
30
+ const last = Math.min(samples.length - 1, Math.floor(t + reach));
31
+ let sum = 0;
32
+ for (let k = Math.max(0, Math.ceil(t - reach)); k <= last; k++)
33
+ sum += samples[k] * kernelAt((t - k) * scale);
34
+ out[n] = sum * scale;
35
+ }
36
+ return out;
37
+ }
@@ -0,0 +1,48 @@
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.js";
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": a guess from where the harmony changes and the low end hits. */
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, `fps` frames per second, and a slower pitch-class profile. */
35
+ export interface BandFrames {
36
+ fps: number;
37
+ bands: Float64Array[];
38
+ chroma: Float64Array[];
39
+ chromaFps: number;
40
+ }
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.15;
44
+ /** Tempo from beat times, by whichever provider placed them: the steady interval, and a curve over ±4 s windows. */
45
+ export declare function tempoOf(times: readonly number[]): Tempo;
46
+ /** Band energies of `signal`, computed once per signal for every capability that reads them. */
47
+ export declare function bandFramesOf(signal: AudioSignal): BandFrames;
48
+ export declare function rhythm(signal: AudioSignal): Rhythm;
@@ -0,0 +1,407 @@
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 { resample } from "./audioResample.js";
6
+ import { fft } from "./fft.js";
7
+ /** Every signal is analyzed at this rate and as the mean of its channels' power, so decode rate and layout cannot change a result. */
8
+ const ANALYSIS_RATE = 22050;
9
+ const FRAME_SIZE = 1024;
10
+ const CHROMA_SIZE = 4096;
11
+ const CHROMA_FPS = 10;
12
+ const FRAMES_PER_SECOND = 100;
13
+ const LOW_BAND_HZ = 150;
14
+ const SLOWEST_BPM = 40;
15
+ const FASTEST_BPM = 240;
16
+ const TIGHTNESS = 100;
17
+ /** The onset curve peaks this much ahead of where listeners place the beat, so beats move this much later. */
18
+ const ONSET_LAG_SECONDS = 0.015;
19
+ const round = (t) => Math.round(t * 1000) / 1000;
20
+ function bandEdges() {
21
+ const binHz = ANALYSIS_RATE / FRAME_SIZE;
22
+ const edges = [];
23
+ for (let hz = 30; hz < 9000; hz *= 2 ** (1 / 5))
24
+ edges.push(Math.max(1, Math.round(hz / binHz)));
25
+ return edges;
26
+ }
27
+ const hann = (size) => Float64Array.from({ length: size }, (_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / size));
28
+ /** Add each band's power in the Hann-windowed frame centred on `center` into `into`. */
29
+ function addFramePower(samples, center, window, edges, into) {
30
+ const size = window.length;
31
+ const re = new Float64Array(size);
32
+ const im = new Float64Array(size);
33
+ const first = center - size / 2;
34
+ for (let i = 0; i < size; i++) {
35
+ const at = first + i;
36
+ re[i] = at >= 0 && at < samples.length ? samples[at] * window[i] : 0;
37
+ }
38
+ fft(re, im);
39
+ for (let b = 0; b + 1 < edges.length; b++) {
40
+ const end = Math.max(edges[b] + 1, edges[b + 1]);
41
+ let power = 0;
42
+ for (let k = edges[b]; k < end; k++)
43
+ power += re[k] * re[k] + im[k] * im[k];
44
+ into[b] += power / (size * size * (end - edges[b]));
45
+ }
46
+ }
47
+ /** Per-band log power of every frame, averaged over channels so a polarity-flipped channel cannot cancel. */
48
+ function bandPower(channels) {
49
+ const window = hann(FRAME_SIZE);
50
+ const edges = bandEdges();
51
+ const hop = ANALYSIS_RATE / FRAMES_PER_SECOND;
52
+ const bands = [];
53
+ for (let f = 0; Math.round(f * hop) < (channels[0]?.length ?? 0); f++) {
54
+ const power = new Float64Array(edges.length - 1);
55
+ for (const channel of channels)
56
+ addFramePower(channel, Math.round(f * hop), window, edges, power);
57
+ bands.push(power.map((p) => Math.log1p((1e9 * p) / channels.length)));
58
+ }
59
+ return bands;
60
+ }
61
+ /** Index of the pitch class, A = 9, of each FFT bin from 55 Hz to 2 kHz; -1 outside that range. */
62
+ const PITCH_CLASS = Array.from({ length: CHROMA_SIZE / 2 }, (_, bin) => {
63
+ const hz = (bin * ANALYSIS_RATE) / CHROMA_SIZE;
64
+ if (hz < 55 || hz > 2000)
65
+ return -1;
66
+ return (((Math.round(12 * Math.log2(hz / 440)) + 9) % 12) + 12) % 12;
67
+ });
68
+ function chromaFrames(channels) {
69
+ const window = hann(CHROMA_SIZE);
70
+ const hop = ANALYSIS_RATE / CHROMA_FPS;
71
+ const re = new Float64Array(CHROMA_SIZE);
72
+ const im = new Float64Array(CHROMA_SIZE);
73
+ const frames = [];
74
+ for (let f = 0; Math.round(f * hop) < (channels[0]?.length ?? 0); f++) {
75
+ const chroma = new Float64Array(12);
76
+ const first = Math.round(f * hop) - CHROMA_SIZE / 2;
77
+ for (const channel of channels) {
78
+ for (let i = 0; i < CHROMA_SIZE; i++) {
79
+ const at = first + i;
80
+ re[i] = at >= 0 && at < channel.length ? channel[at] * window[i] : 0;
81
+ im[i] = 0;
82
+ }
83
+ fft(re, im);
84
+ PITCH_CLASS.forEach((pc, bin) => {
85
+ if (pc >= 0)
86
+ chroma[pc] += Math.hypot(re[bin], im[bin]);
87
+ });
88
+ }
89
+ frames.push(chroma);
90
+ }
91
+ return frames;
92
+ }
93
+ function bandFrames(signal) {
94
+ const channels = signal.channels.map((c) => resample(c, signal.sampleRate, ANALYSIS_RATE));
95
+ return {
96
+ fps: FRAMES_PER_SECOND,
97
+ bands: bandPower(channels),
98
+ chroma: chromaFrames(channels),
99
+ chromaFps: CHROMA_FPS,
100
+ };
101
+ }
102
+ /** Subtract the mean of the surrounding ±`radius` frames and keep what rises above it. */
103
+ function aboveLocalMean(curve, radius) {
104
+ const sums = new Float64Array(curve.length + 1);
105
+ for (let i = 0; i < curve.length; i++)
106
+ sums[i + 1] = sums[i] + curve[i];
107
+ return curve.map((value, i) => {
108
+ const from = Math.max(0, i - radius);
109
+ const to = Math.min(curve.length, i + radius + 1);
110
+ return Math.max(0, value - (sums[to] - sums[from]) / (to - from));
111
+ });
112
+ }
113
+ function standardized(curve) {
114
+ const mean = curve.reduce((a, b) => a + b, 0) / (curve.length || 1);
115
+ const spread = Math.sqrt(curve.reduce((a, v) => a + (v - mean) ** 2, 0) / (curve.length || 1));
116
+ return spread > 0 ? curve.map((v) => v / spread) : curve.map(() => 0);
117
+ }
118
+ function onsetCurves({ fps, bands }) {
119
+ const lowBands = bandEdges().filter((bin) => (bin * ANALYSIS_RATE) / FRAME_SIZE < LOW_BAND_HZ).length;
120
+ const broad = new Float64Array(bands.length);
121
+ const low = new Float64Array(bands.length);
122
+ for (let f = 1; f < bands.length; f++) {
123
+ const frame = bands[f];
124
+ const previous = bands[f - 1];
125
+ for (let b = 0; b < frame.length; b++) {
126
+ const rise = Math.max(0, frame[b] - previous[b]);
127
+ broad[f] += rise;
128
+ if (b < lowBands)
129
+ low[f] += rise;
130
+ }
131
+ }
132
+ const radius = Math.round(0.4 * fps);
133
+ return {
134
+ fps,
135
+ broad: standardized(aboveLocalMean(broad, radius)),
136
+ low: standardized(aboveLocalMean(low, radius)),
137
+ };
138
+ }
139
+ /** Autocorrelation of `curve` at lags 0…`maxLag`, normalized by lag 0. */
140
+ function autocorrelation(curve, maxLag) {
141
+ const mean = curve.reduce((a, b) => a + b, 0) / (curve.length || 1);
142
+ const centred = curve.map((v) => v - mean);
143
+ const ac = new Float64Array(maxLag + 1);
144
+ for (let lag = 0; lag <= maxLag && lag < centred.length; lag++) {
145
+ let sum = 0;
146
+ for (let i = lag; i < centred.length; i++)
147
+ sum += centred[i] * centred[i - lag];
148
+ ac[lag] = sum;
149
+ }
150
+ const zero = ac[0] || 1;
151
+ return ac.map((v) => v / zero);
152
+ }
153
+ const tempoPrior = (lag, fps) => Math.exp(-0.5 * (Math.log2(60 / (lag / fps) / 120) / 0.8) ** 2);
154
+ const fullRange = (fps) => ({
155
+ shortest: Math.max(1, Math.floor((60 / FASTEST_BPM) * fps)),
156
+ longest: Math.ceil((60 / SLOWEST_BPM) * fps),
157
+ });
158
+ /** The beat period in frames: prior-weighted autocorrelation plus half its value at twice the lag. */
159
+ function beatPeriod(curve, fps, { shortest, longest } = fullRange(fps)) {
160
+ const ac = autocorrelation(curve, 2 * longest + 1);
161
+ const at = (lag) => ac[Math.min(ac.length - 1, Math.max(0, lag))];
162
+ const score = (lag) => tempoPrior(lag, fps) *
163
+ (at(lag) + 0.5 * at(2 * lag) + 0.25 * (at(2 * lag - 1) + at(2 * lag + 1)));
164
+ let best = shortest;
165
+ for (let lag = shortest; lag <= longest; lag++)
166
+ if (score(lag) > score(best))
167
+ best = lag;
168
+ const [a, b, c] = [score(best - 1), score(best), score(best + 1)];
169
+ const curvature = a - 2 * b + c;
170
+ const shift = curvature ? Math.max(-0.5, Math.min(0.5, (0.5 * (a - c)) / curvature)) : 0;
171
+ return { period: best + shift, clarity: Math.max(0, Math.min(1, at(best))) };
172
+ }
173
+ /** The onset curve smoothed by a Gaussian a thirty-second of a beat wide. */
174
+ function smoothed(curve, period) {
175
+ const half = Math.max(1, Math.round(period / 8));
176
+ const kernel = Array.from({ length: 2 * half + 1 }, (_, i) => Math.exp(-0.5 * (((i - half) * 32) / period) ** 2));
177
+ return curve.map((_, i) => {
178
+ let sum = 0;
179
+ for (let j = 0; j < kernel.length; j++)
180
+ sum += kernel[j] * (curve[i + j - half] ?? 0);
181
+ return sum;
182
+ });
183
+ }
184
+ /** Ellis's recursion: each frame's best score from a predecessor about one period back, penalized by log deviation. */
185
+ function cumulativeScore(local, periodAt) {
186
+ const score = new Float64Array(local.length);
187
+ const back = new Int32Array(local.length).fill(-1);
188
+ for (let i = 0; i < local.length; i++) {
189
+ const period = periodAt(i);
190
+ let best = -Infinity;
191
+ let from = -1;
192
+ for (let j = Math.max(0, Math.round(i - 2 * period)); j <= i - Math.round(period / 2); j++) {
193
+ const candidate = score[j] - TIGHTNESS * Math.log((i - j) / period) ** 2;
194
+ if (candidate > best)
195
+ [best, from] = [candidate, j];
196
+ }
197
+ score[i] = local[i] + Math.max(0, best);
198
+ back[i] = best > 0 ? from : -1;
199
+ }
200
+ return { score, back };
201
+ }
202
+ /** The last local maximum of the cumulative score that reaches half the median of all of them. */
203
+ function lastBeat(score) {
204
+ const maxima = [];
205
+ for (let i = 1; i + 1 < score.length; i++)
206
+ if (score[i] > score[i - 1] && score[i] >= score[i + 1])
207
+ maxima.push(i);
208
+ if (!maxima.length)
209
+ return score.length - 1;
210
+ const median = maxima.map((i) => score[i]).sort((a, b) => a - b)[maxima.length >> 1];
211
+ for (let k = maxima.length - 1; k >= 0; k--)
212
+ if (score[maxima[k]] >= 0.5 * median)
213
+ return maxima[k];
214
+ return maxima[maxima.length - 1];
215
+ }
216
+ /** Drop beats at either end whose onset strength is under half the RMS of the beats' strengths. */
217
+ function trimmed(beats, local) {
218
+ const rms = Math.sqrt(beats.reduce((a, b) => a + local[b] ** 2, 0) / (beats.length || 1));
219
+ let first = 0;
220
+ let end = beats.length;
221
+ while (first < end && local[beats[first]] < 0.5 * rms)
222
+ first++;
223
+ while (end > first && local[beats[end - 1]] < 0.5 * rms)
224
+ end--;
225
+ return beats.slice(first, end);
226
+ }
227
+ const LOCAL_WINDOW_SECONDS = 8;
228
+ /** The period through the piece: eight-second windows a second apart, held within 10% of the global period, median-smoothed. */
229
+ function localPeriods(curve, fps, period) {
230
+ const window = Math.round(LOCAL_WINDOW_SECONDS * fps);
231
+ const hop = Math.round(fps);
232
+ const range = {
233
+ shortest: Math.max(fullRange(fps).shortest, Math.floor(period * 0.9)),
234
+ longest: Math.min(fullRange(fps).longest, Math.ceil(period * 1.1)),
235
+ };
236
+ const centres = [];
237
+ const periods = [];
238
+ for (let start = 0; start === 0 || start + window <= curve.length; start += hop) {
239
+ const end = Math.min(curve.length, start + window);
240
+ periods.push(beatPeriod(curve.subarray(start, end), fps, range).period);
241
+ centres.push((start + end) / 2);
242
+ }
243
+ const median = periods.map((_, i) => {
244
+ const near = periods.slice(Math.max(0, i - 2), i + 3).sort((a, b) => a - b);
245
+ return near[near.length >> 1];
246
+ });
247
+ return (frame) => interpolate(centres, median, frame);
248
+ }
249
+ function interpolate(xs, ys, x) {
250
+ if (x <= xs[0])
251
+ return ys[0];
252
+ for (let i = 1; i < xs.length; i++)
253
+ if (x <= xs[i]) {
254
+ const t = (x - xs[i - 1]) / (xs[i] - xs[i - 1]);
255
+ return ys[i - 1] * (1 - t) + ys[i] * t;
256
+ }
257
+ return ys[ys.length - 1];
258
+ }
259
+ function beatFrames(curve, fps, period) {
260
+ const local = smoothed(curve, period);
261
+ const { score, back } = cumulativeScore(local, localPeriods(curve, fps, period));
262
+ const beats = [];
263
+ for (let i = lastBeat(score); i >= 0; i = back[i])
264
+ beats.push(i);
265
+ return { frames: trimmed(beats.reverse(), local), local };
266
+ }
267
+ /** How much stronger the smoothed onsets are on the beats than on average, as a share: 0 is no stronger. */
268
+ function onsetLift(frames, local) {
269
+ const overall = local.reduce((a, b) => a + b, 0) / (local.length || 1);
270
+ const onBeats = frames.reduce((a, f) => a + local[f], 0) / (frames.length || 1);
271
+ return overall > 0 ? onBeats / overall - 1 : 0;
272
+ }
273
+ const mean = (values) => values.reduce((a, b) => a + b, 0) / (values.length || 1);
274
+ function zScored(values) {
275
+ const centre = mean(values);
276
+ const spread = Math.sqrt(mean(values.map((v) => (v - centre) ** 2))) || 1;
277
+ return values.map((v) => (v - centre) / spread);
278
+ }
279
+ const CHROMA_REACH_SECONDS = CHROMA_SIZE / 2 / ANALYSIS_RATE;
280
+ /** The unit-length mean chroma from one time to another, in seconds, leaving out frames that already hear the next onset. */
281
+ function chromaBetween({ chroma, chromaFps }, from, to) {
282
+ const middle = Math.round(((from + to) / 2) * chromaFps);
283
+ let first = Math.ceil(from * chromaFps);
284
+ let end = Math.floor((to - CHROMA_REACH_SECONDS) * chromaFps) + 1;
285
+ if (end <= first)
286
+ [first, end] = [middle, middle + 1];
287
+ const sum = new Array(12).fill(0);
288
+ for (let f = first; f < end; f++)
289
+ for (let c = 0; c < 12; c++)
290
+ sum[c] += chroma[f]?.[c] ?? 0;
291
+ const length = Math.hypot(...sum) || 1;
292
+ return sum.map((v) => v / length);
293
+ }
294
+ const cosine = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
295
+ /** How much the harmony changes at each beat: the chroma `span` beats before it against the `span` after. */
296
+ function harmonicChange(beats, fps, frames, span) {
297
+ return beats.map((f, k) => {
298
+ if (k < span || k + span >= beats.length)
299
+ return 0;
300
+ const before = chromaBetween(frames, beats[k - span] / fps, f / fps);
301
+ return 1 - cosine(before, chromaBetween(frames, f / fps, beats[k + span] / fps));
302
+ });
303
+ }
304
+ /** Each beat's evidence of starting a bar: a low-end hit, a chord change, and a chord change over half a bar. */
305
+ function barStartEvidence(beats, curves, frames) {
306
+ const lowHit = (f) => Math.max(...[-1, 0, 1, 2].map((d) => curves.low[f + d] ?? 0));
307
+ const cues = [
308
+ zScored(beats.map(lowHit)),
309
+ zScored(harmonicChange(beats, curves.fps, frames, 1)),
310
+ zScored(harmonicChange(beats, curves.fps, frames, 2)),
311
+ ];
312
+ return beats.map((_, k) => cues.reduce((sum, cue) => sum + cue[k], 0));
313
+ }
314
+ /** Most music is in four, so three must win by a clear margin. */
315
+ const TRIPLE_METER_HANDICAP = 0.7;
316
+ function barPhase(beats, curves, frames) {
317
+ const evidence = barStartEvidence(beats, curves, frames);
318
+ let best = { meter: 4, phase: 0, contrast: -Infinity };
319
+ for (const meter of [4, 3])
320
+ for (let phase = 0; phase < meter; phase++) {
321
+ const on = evidence.filter((_, k) => (k - phase) % meter === 0);
322
+ const off = evidence.filter((_, k) => (k - phase) % meter !== 0);
323
+ const contrast = (mean(on) - mean(off)) * (meter === 3 ? TRIPLE_METER_HANDICAP : 1);
324
+ if (contrast > best.contrast)
325
+ best = { meter, phase, contrast };
326
+ }
327
+ return best;
328
+ }
329
+ function downbeatsOf(beatList, times, curves, frames) {
330
+ const bar = beatList.length >= 6 ? barPhase(beatList, curves, frames) : null;
331
+ return {
332
+ times: bar ? times.filter((_, k) => (k - bar.phase) % bar.meter === 0) : [],
333
+ meter: bar?.meter ?? null,
334
+ confidence: "low",
335
+ };
336
+ }
337
+ export function medianInterval(times) {
338
+ const gaps = times
339
+ .slice(1)
340
+ .map((t, i) => t - times[i])
341
+ .sort((a, b) => a - b);
342
+ return gaps.length ? gaps[gaps.length >> 1] : null;
343
+ }
344
+ const noRhythm = (confidence, reason = "no steady beat") => ({
345
+ beats: { times: [], confidence, reason },
346
+ downbeats: { times: [], meter: null, confidence: "low" },
347
+ });
348
+ /** Two bars of 4/4 at 80 BPM: anything shorter has too few beats to call steady. */
349
+ const SHORTEST_SECONDS = 6;
350
+ /** Below this the beats are more likely the tracker's own grid than the music's. */
351
+ export const STEADY_BEAT_CONFIDENCE = 0.15;
352
+ /** Clarity of the period, scaled down when the beats are under three times as strong as the onsets around them. */
353
+ const beatConfidence = (clarity, lift) => round(Math.max(0, Math.min(1, clarity)) * Math.max(0, Math.min(1, lift / 3)));
354
+ /** Beats and downbeats of `frames`, a signal's band energies and chroma. */
355
+ function rhythmOf(frames) {
356
+ const curves = onsetCurves(frames);
357
+ const { period, clarity } = beatPeriod(curves.broad, curves.fps);
358
+ if (curves.broad.length < SHORTEST_SECONDS * curves.fps)
359
+ return noRhythm(0, "too short for a steady beat");
360
+ if (!curves.broad.some((v) => v > 0) || curves.broad.length < 2 * period)
361
+ return noRhythm(0);
362
+ const { frames: beatList, local } = beatFrames(curves.broad, curves.fps, period);
363
+ const confidence = beatConfidence(clarity, onsetLift(beatList, local));
364
+ if (beatList.length < 4 || confidence < STEADY_BEAT_CONFIDENCE)
365
+ return noRhythm(confidence);
366
+ const times = beatList.map((f) => round(f / curves.fps + ONSET_LAG_SECONDS));
367
+ return { beats: { times, confidence }, downbeats: downbeatsOf(beatList, times, curves, frames) };
368
+ }
369
+ const TEMPO_STEP_SECONDS = 4;
370
+ function steadyInterval(times) {
371
+ const median = medianInterval(times);
372
+ if (!median)
373
+ return null;
374
+ const near = times
375
+ .slice(1)
376
+ .map((t, i) => t - times[i])
377
+ .filter((gap) => Math.abs(gap - median) <= 0.1 * median);
378
+ return near.reduce((a, b) => a + b, 0) / near.length;
379
+ }
380
+ /** Tempo from beat times, by whichever provider placed them: the steady interval, and a curve over ±4 s windows. */
381
+ export function tempoOf(times) {
382
+ const interval = steadyInterval(times);
383
+ if (!interval)
384
+ return { value: null, curve: [] };
385
+ const bpm = (gap) => Math.round(600 / gap) / 10;
386
+ const curve = [];
387
+ const last = times[times.length - 1];
388
+ for (let t = times[0]; t <= last; t += TEMPO_STEP_SECONDS) {
389
+ const gap = steadyInterval(times.filter((b) => Math.abs(b - t) <= TEMPO_STEP_SECONDS));
390
+ if (gap)
391
+ curve.push({ t: round(t), bpm: bpm(gap) });
392
+ }
393
+ return { value: bpm(interval), curve };
394
+ }
395
+ const measured = new WeakMap();
396
+ /** Band energies of `signal`, computed once per signal for every capability that reads them. */
397
+ export function bandFramesOf(signal) {
398
+ let entry = measured.get(signal);
399
+ if (!entry)
400
+ measured.set(signal, (entry = { frames: bandFrames(signal) }));
401
+ return entry.frames;
402
+ }
403
+ export function rhythm(signal) {
404
+ const frames = bandFramesOf(signal);
405
+ const entry = measured.get(signal);
406
+ return (entry.rhythm ??= rhythmOf(frames));
407
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Sections from signal processing: band energies and chroma 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.js";
6
+ import { type SnapReport } from "./audioSnap.js";
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;