@hyperframes/core 0.8.160 → 0.8.162

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 (52) 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 +7 -5
  4. package/dist/audioAnalysis.js +1 -1
  5. package/dist/audioResample.d.ts +2 -0
  6. package/dist/audioResample.js +37 -0
  7. package/dist/audioRhythm.d.ts +7 -9
  8. package/dist/audioRhythm.js +145 -49
  9. package/dist/audioSections.d.ts +3 -3
  10. package/dist/audioSections.js +14 -6
  11. package/dist/beats/beatDetection.d.ts +6 -8
  12. package/dist/beats/beatDetection.js +25 -217
  13. package/dist/colorGrading.d.ts +3 -3
  14. package/dist/compiler/htmlBundler.d.ts +2 -2
  15. package/dist/compiler/index.d.ts +19 -19
  16. package/dist/compiler/inlineSubCompositions.d.ts +4 -4
  17. package/dist/compiler/svgIdNamespacing.d.ts +1 -1
  18. package/dist/figma/assetSnippet.d.ts +1 -1
  19. package/dist/figma/client.d.ts +1 -1
  20. package/dist/figma/emitTimelineScript.d.ts +1 -1
  21. package/dist/figma/index.d.ts +22 -22
  22. package/dist/figma/manifest.d.ts +1 -1
  23. package/dist/figma/motionContextToDocs.d.ts +1 -1
  24. package/dist/figma/motionEase.d.ts +1 -1
  25. package/dist/figma/motionToGsap.d.ts +1 -1
  26. package/dist/figma/nodeDocument.d.ts +1 -1
  27. package/dist/figma/nodeToHtml.d.ts +3 -3
  28. package/dist/figma/parseFigmaRef.d.ts +1 -1
  29. package/dist/figma/resolveBindings.d.ts +2 -2
  30. package/dist/figma/tokensToVariables.d.ts +2 -2
  31. package/dist/generators/hyperframes.d.ts +1 -1
  32. package/dist/index.d.ts +37 -37
  33. package/dist/inline-scripts/hyperframe.d.ts +1 -1
  34. package/dist/projects.d.ts +1 -1
  35. package/dist/runtime/bridge.d.ts +2 -2
  36. package/dist/runtime/clipTree.d.ts +1 -1
  37. package/dist/runtime/compositionLength.d.ts +2 -2
  38. package/dist/runtime/mediaVolumeEnvelope.d.ts +1 -1
  39. package/dist/runtime/playbackRate.d.ts +1 -1
  40. package/dist/runtime/startResolver.d.ts +3 -3
  41. package/dist/runtime/state.d.ts +3 -3
  42. package/dist/runtime/timeline.d.ts +2 -2
  43. package/dist/runtime/timelineDuration.d.ts +1 -1
  44. package/dist/runtime/types.d.ts +2 -2
  45. package/dist/runtime/validateVariables.d.ts +1 -1
  46. package/dist/templates/base.d.ts +1 -1
  47. package/dist/vfx/refs/displacementMap.d.ts +1 -1
  48. package/dist/vfx/refs/fractalNoise.d.ts +1 -1
  49. package/dist/vfx/refs/lumaMatte.d.ts +1 -1
  50. package/dist/vfx/refs/noise.d.ts +1 -1
  51. package/dist/vfx/refs/waveWarp.d.ts +1 -1
  52. package/package.json +5 -6
@@ -1,4 +1,4 @@
1
- import type { FrameAdapter } from "./types";
1
+ import type { FrameAdapter } from "./types.js";
2
2
  export interface GSAPTimelineLike {
3
3
  duration: () => number;
4
4
  totalDuration?: () => number;
@@ -1,3 +1,3 @@
1
- export type { FrameAdapter, FrameAdapterContext } from "./types";
2
- export type { GSAPTimelineLike, CreateGSAPFrameAdapterOptions } from "./gsap";
3
- export { createGSAPFrameAdapter } from "./gsap";
1
+ export type { FrameAdapter, FrameAdapterContext } from "./types.js";
2
+ export type { GSAPTimelineLike, CreateGSAPFrameAdapterOptions } from "./gsap.js";
3
+ export { createGSAPFrameAdapter } from "./gsap.js";
@@ -2,7 +2,7 @@
2
2
  * Measurements of decoded audio over time: an energy envelope, onsets, the moments a sound lands, loudness and silence.
3
3
  * Pure functions of PCM samples, so they run the same in Node, Bun and the browser. Times are seconds into the signal.
4
4
  */
5
- import type { AudioAnalysisProvider } from "./audioAnalysisLadder";
5
+ import type { AudioAnalysisProvider } from "./audioAnalysisLadder.js";
6
6
  /** Decoded audio: one Float32Array per channel, all the same length. */
7
7
  export interface AudioSignal {
8
8
  sampleRate: number;
@@ -100,6 +100,8 @@ export interface KindFeatures {
100
100
  sustain: number;
101
101
  }
102
102
  export declare const signalDuration: (signal: AudioSignal) => number;
103
+ /** Per-sample energy. Channels are squared first, so a reversed channel stays audible. */
104
+ export declare function energyMix(signal: AudioSignal): Float32Array;
103
105
  export declare function envelope(signal: AudioSignal, options?: EnvelopeOptions): Envelope;
104
106
  export declare function onsets(signal: AudioSignal, options?: OnsetOptions): Onsets;
105
107
  export declare function moments(signal: AudioSignal, options?: MomentOptions): Moments | null;
@@ -111,9 +113,9 @@ export declare function kindFeatures(signal: AudioSignal): KindFeatures;
111
113
  * when its envelope moves at syllable rate without a beat; short audio is voice when it also holds its level.
112
114
  */
113
115
  export declare function classify(signal: AudioSignal): KindGuess;
114
- export * from "./audioAnalysisLadder";
115
- export * from "./audioRhythm";
116
- export * from "./audioSections";
117
- export * from "./audioSnap";
116
+ export * from "./audioAnalysisLadder.js";
117
+ export * from "./audioRhythm.js";
118
+ export * from "./audioSections.js";
119
+ export * from "./audioSnap.js";
118
120
  /** Signal processing as a ladder provider: always available, the measured ground every other provider snaps to. */
119
121
  export declare const signalProvider: AudioAnalysisProvider<AudioSignal>;
@@ -8,7 +8,7 @@ import { sections } from "./audioSections.js";
8
8
  const DB_FLOOR = -120;
9
9
  export const signalDuration = (signal) => (signal.channels[0]?.length ?? 0) / signal.sampleRate;
10
10
  /** Per-sample energy. Channels are squared first, so a reversed channel stays audible. */
11
- function energyMix(signal) {
11
+ export function energyMix(signal) {
12
12
  const [first, ...rest] = signal.channels;
13
13
  if (!first)
14
14
  return new Float32Array(0);
@@ -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
+ }
@@ -2,7 +2,7 @@
2
2
  * Beats, downbeats and tempo from signal processing alone: a log-band spectral-flux onset curve, an autocorrelation
3
3
  * tempo with a log-normal prior, and dynamic-programming beat placement (Ellis 2007). Times are seconds into the signal.
4
4
  */
5
- import type { AudioSignal } from "./audioAnalysis";
5
+ import type { AudioSignal } from "./audioAnalysis.js";
6
6
  export interface Beats {
7
7
  /** Empty when there is no steady beat, with `reason` saying so. */
8
8
  times: number[];
@@ -14,7 +14,7 @@ export interface Downbeats {
14
14
  times: number[];
15
15
  /** Beats per bar, 3 or 4; null when there are too few beats to tell. */
16
16
  meter: number | null;
17
- /** "low" for a guess from where the low end hits hardest. */
17
+ /** "low": a guess from where the harmony changes and the low end hits. */
18
18
  confidence?: "low";
19
19
  }
20
20
  export interface TempoPoint {
@@ -31,19 +31,17 @@ export interface Rhythm {
31
31
  beats: Beats;
32
32
  downbeats: Downbeats;
33
33
  }
34
- /** Log energy per frame in log-spaced bands, the frames `fps` per second. */
34
+ /** Log energy per frame in log-spaced bands, `fps` frames per second, and a slower pitch-class profile. */
35
35
  export interface BandFrames {
36
36
  fps: number;
37
37
  bands: Float64Array[];
38
+ chroma: Float64Array[];
39
+ chromaFps: number;
38
40
  }
39
- /** Per-band log power, summed over channels so a polarity-flipped channel cannot cancel. */
40
- export declare function bandFrames(signal: AudioSignal): BandFrames;
41
41
  export declare function medianInterval(times: readonly number[]): number | null;
42
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. */
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. */
47
45
  export declare function tempoOf(times: readonly number[]): Tempo;
48
46
  /** Band energies of `signal`, computed once per signal for every capability that reads them. */
49
47
  export declare function bandFramesOf(signal: AudioSignal): BandFrames;
@@ -2,20 +2,29 @@
2
2
  * Beats, downbeats and tempo from signal processing alone: a log-band spectral-flux onset curve, an autocorrelation
3
3
  * tempo with a log-normal prior, and dynamic-programming beat placement (Ellis 2007). Times are seconds into the signal.
4
4
  */
5
+ import { resample } from "./audioResample.js";
5
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;
6
12
  const FRAMES_PER_SECOND = 100;
7
13
  const LOW_BAND_HZ = 150;
8
14
  const SLOWEST_BPM = 40;
9
15
  const FASTEST_BPM = 240;
10
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;
11
19
  const round = (t) => Math.round(t * 1000) / 1000;
12
- function bandEdges(sampleRate, size) {
13
- const binHz = sampleRate / size;
20
+ function bandEdges() {
21
+ const binHz = ANALYSIS_RATE / FRAME_SIZE;
14
22
  const edges = [];
15
- for (let hz = 30; hz < Math.min(9000, sampleRate / 2); hz *= 2 ** (1 / 5))
23
+ for (let hz = 30; hz < 9000; hz *= 2 ** (1 / 5))
16
24
  edges.push(Math.max(1, Math.round(hz / binHz)));
17
25
  return edges;
18
26
  }
27
+ const hann = (size) => Float64Array.from({ length: size }, (_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / size));
19
28
  /** Add each band's power in the Hann-windowed frame centred on `center` into `into`. */
20
29
  function addFramePower(samples, center, window, edges, into) {
21
30
  const size = window.length;
@@ -35,22 +44,60 @@ function addFramePower(samples, center, window, edges, into) {
35
44
  into[b] += power / (size * size * (end - edges[b]));
36
45
  }
37
46
  }
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;
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;
46
52
  const bands = [];
47
- for (let center = 0; center < length; center += hop) {
48
- const power = new Float64Array(Math.max(0, edges.length - 1));
53
+ for (let f = 0; Math.round(f * hop) < (channels[0]?.length ?? 0); f++) {
54
+ const power = new Float64Array(edges.length - 1);
49
55
  for (const channel of channels)
50
- addFramePower(channel, center, window, edges, power);
51
- bands.push(power.map((p) => Math.log1p(1e9 * p)));
56
+ addFramePower(channel, Math.round(f * hop), window, edges, power);
57
+ bands.push(power.map((p) => Math.log1p((1e9 * p) / channels.length)));
52
58
  }
53
- return { fps: sampleRate / hop, bands };
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
+ };
54
101
  }
55
102
  /** Subtract the mean of the surrounding ±`radius` frames and keep what rises above it. */
56
103
  function aboveLocalMean(curve, radius) {
@@ -68,9 +115,8 @@ function standardized(curve) {
68
115
  const spread = Math.sqrt(curve.reduce((a, v) => a + (v - mean) ** 2, 0) / (curve.length || 1));
69
116
  return spread > 0 ? curve.map((v) => v / spread) : curve.map(() => 0);
70
117
  }
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;
118
+ function onsetCurves({ fps, bands }) {
119
+ const lowBands = bandEdges().filter((bin) => (bin * ANALYSIS_RATE) / FRAME_SIZE < LOW_BAND_HZ).length;
74
120
  const broad = new Float64Array(bands.length);
75
121
  const low = new Float64Array(bands.length);
76
122
  for (let f = 1; f < bands.length; f++) {
@@ -104,7 +150,7 @@ function autocorrelation(curve, maxLag) {
104
150
  const zero = ac[0] || 1;
105
151
  return ac.map((v) => v / zero);
106
152
  }
107
- const tempoPrior = (lag, fps) => Math.exp(-0.5 * Math.log2(60 / (lag / fps) / 120) ** 2);
153
+ const tempoPrior = (lag, fps) => Math.exp(-0.5 * (Math.log2(60 / (lag / fps) / 120) / 0.8) ** 2);
108
154
  const fullRange = (fps) => ({
109
155
  shortest: Math.max(1, Math.floor((60 / FASTEST_BPM) * fps)),
110
156
  longest: Math.ceil((60 / SLOWEST_BPM) * fps),
@@ -179,13 +225,13 @@ function trimmed(beats, local) {
179
225
  return beats.slice(first, end);
180
226
  }
181
227
  const LOCAL_WINDOW_SECONDS = 8;
182
- /** The period through the piece: eight-second windows a second apart, held near the global period, median-smoothed. */
228
+ /** The period through the piece: eight-second windows a second apart, held within 10% of the global period, median-smoothed. */
183
229
  function localPeriods(curve, fps, period) {
184
230
  const window = Math.round(LOCAL_WINDOW_SECONDS * fps);
185
231
  const hop = Math.round(fps);
186
232
  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)),
233
+ shortest: Math.max(fullRange(fps).shortest, Math.floor(period * 0.9)),
234
+ longest: Math.min(fullRange(fps).longest, Math.ceil(period * 1.1)),
189
235
  };
190
236
  const centres = [];
191
237
  const periods = [];
@@ -224,22 +270,70 @@ function onsetLift(frames, local) {
224
270
  const onBeats = frames.reduce((a, f) => a + local[f], 0) / (frames.length || 1);
225
271
  return overall > 0 ? onBeats / overall - 1 : 0;
226
272
  }
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);
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);
231
318
  let best = { meter: 4, phase: 0, contrast: -Infinity };
232
319
  for (const meter of [4, 3])
233
320
  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);
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);
238
324
  if (contrast > best.contrast)
239
325
  best = { meter, phase, contrast };
240
326
  }
241
327
  return best;
242
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
+ }
243
337
  export function medianInterval(times) {
244
338
  const gaps = times
245
339
  .slice(1)
@@ -254,12 +348,12 @@ const noRhythm = (confidence, reason = "no steady beat") => ({
254
348
  /** Two bars of 4/4 at 80 BPM: anything shorter has too few beats to call steady. */
255
349
  const SHORTEST_SECONDS = 6;
256
350
  /** Below this the beats are more likely the tracker's own grid than the music's. */
257
- export const STEADY_BEAT_CONFIDENCE = 0.05;
351
+ export const STEADY_BEAT_CONFIDENCE = 0.15;
258
352
  /** Clarity of the period, scaled down when the beats are under three times as strong as the onsets around them. */
259
353
  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);
354
+ /** Beats and downbeats of `frames`, a signal's band energies and chroma. */
355
+ function rhythmOf(frames) {
356
+ const curves = onsetCurves(frames);
263
357
  const { period, clarity } = beatPeriod(curves.broad, curves.fps);
264
358
  if (curves.broad.length < SHORTEST_SECONDS * curves.fps)
265
359
  return noRhythm(0, "too short for a steady beat");
@@ -269,28 +363,30 @@ export function rhythmOf(frames, sampleRate) {
269
363
  const confidence = beatConfidence(clarity, onsetLift(beatList, local));
270
364
  if (beatList.length < 4 || confidence < STEADY_BEAT_CONFIDENCE)
271
365
  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
- };
366
+ const times = beatList.map((f) => round(f / curves.fps + ONSET_LAG_SECONDS));
367
+ return { beats: { times, confidence }, downbeats: downbeatsOf(beatList, times, curves, frames) };
282
368
  }
283
369
  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. */
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. */
285
381
  export function tempoOf(times) {
286
- const interval = medianInterval(times);
382
+ const interval = steadyInterval(times);
287
383
  if (!interval)
288
384
  return { value: null, curve: [] };
289
385
  const bpm = (gap) => Math.round(600 / gap) / 10;
290
386
  const curve = [];
291
387
  const last = times[times.length - 1];
292
388
  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));
389
+ const gap = steadyInterval(times.filter((b) => Math.abs(b - t) <= TEMPO_STEP_SECONDS));
294
390
  if (gap)
295
391
  curve.push({ t: round(t), bpm: bpm(gap) });
296
392
  }
@@ -307,5 +403,5 @@ export function bandFramesOf(signal) {
307
403
  export function rhythm(signal) {
308
404
  const frames = bandFramesOf(signal);
309
405
  const entry = measured.get(signal);
310
- return (entry.rhythm ??= rhythmOf(frames, signal.sampleRate));
406
+ return (entry.rhythm ??= rhythmOf(frames));
311
407
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Sections from signal processing: band energies averaged into half-second blocks, a self-similarity matrix, and
2
+ * Sections from signal processing: band energies and chroma averaged into half-second blocks, a self-similarity matrix, and
3
3
  * Foote's checkerboard novelty, whose peaks are where the music changes. Boundaries snap to the nearest beat.
4
4
  */
5
- import type { AudioSignal } from "./audioAnalysis";
6
- import { type SnapReport } from "./audioSnap";
5
+ import type { AudioSignal } from "./audioAnalysis.js";
6
+ import { type SnapReport } from "./audioSnap.js";
7
7
  export interface Section {
8
8
  start: number;
9
9
  end: number;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Sections from signal processing: band energies averaged into half-second blocks, a self-similarity matrix, and
2
+ * Sections from signal processing: band energies and chroma averaged into half-second blocks, a self-similarity matrix, and
3
3
  * Foote's checkerboard novelty, whose peaks are where the music changes. Boundaries snap to the nearest beat.
4
4
  */
5
5
  import { bandFramesOf, medianInterval, rhythm } from "./audioRhythm.js";
@@ -26,14 +26,22 @@ function standardizeBands(blocks) {
26
26
  block[b] = (block[b] - mean) / (spread || 1);
27
27
  }
28
28
  }
29
- /** Band energies averaged over blocks, each band standardized over time and each block scaled to unit length. */
30
- function blockFeatures({ fps, bands }) {
29
+ /** Block means of the band energies and of the log chroma, each part unit length within a block. */
30
+ function blockParts({ fps, bands, chroma, chromaFps }) {
31
31
  const per = Math.max(1, Math.round(BLOCK_SECONDS * fps));
32
+ const chromaPer = Math.max(1, Math.round(BLOCK_SECONDS * chromaFps));
32
33
  const blocks = [];
33
- for (let start = 0; start + per <= bands.length; start += per)
34
- blocks.push(meanOf(bands.slice(start, start + per)));
34
+ for (let start = 0, b = 0; start + per <= bands.length; start += per, b++) {
35
+ const pitch = meanOf(chroma.slice(b * chromaPer, (b + 1) * chromaPer)).map(Math.log1p);
36
+ blocks.push([...meanOf(bands.slice(start, start + per)), ...unit(pitch)]);
37
+ }
38
+ return blocks;
39
+ }
40
+ function blockFeatures(frames) {
41
+ const blocks = blockParts(frames);
35
42
  standardizeBands(blocks);
36
- return blocks.map(unit);
43
+ const split = frames.bands[0]?.length ?? 0;
44
+ return blocks.map((block) => [...unit(block.slice(0, split)), ...unit(block.slice(split))].map((v) => v * Math.SQRT1_2));
37
45
  }
38
46
  function unit(vector) {
39
47
  const length = Math.hypot(...vector) || 1;
@@ -1,13 +1,12 @@
1
- type BpmDetect = (buffer: AudioBuffer) => number;
2
- export declare function loadBpmDetective(importFn?: () => Promise<unknown>): Promise<BpmDetect | null>;
1
+ import { type AudioSignal } from "../audioAnalysis.js";
3
2
  export interface MusicBeatAnalysis {
4
3
  beatTimes: number[];
5
4
  /** Per-beat loudness 0–1 (local RMS / peak), aligned by index with beatTimes. */
6
5
  beatStrengths: number[];
7
6
  bpm: number | null;
8
7
  bpmConfidence: "high" | "low" | "uncertain";
9
- /** Decoded mono samples — retained so strength can be measured at user-added
10
- * beats. Audio-file coordinates. May be null if decode data was dropped. */
8
+ /** Per-sample level across channels — retained so strength can be measured at
9
+ * user-added beats. Audio-file coordinates. May be null if decode data was dropped. */
11
10
  channelData: Float32Array | null;
12
11
  sampleRate: number;
13
12
  /** Reference peak RMS used to normalize beat strengths. */
@@ -17,11 +16,10 @@ export interface MusicBeatAnalysis {
17
16
  export declare function computeRmsAt(channelData: Float32Array, sampleRate: number, time: number): number;
18
17
  /** Normalized beat strength (0–1) at an audio-file time, using a track peak. */
19
18
  export declare function strengthAtTime(analysis: Pick<MusicBeatAnalysis, "channelData" | "sampleRate" | "peak">, time: number): number;
20
- export declare function detectBeats(audioBuffer: AudioBuffer): Promise<number[]>;
21
19
  export interface MusicAnalysisOptions {
22
20
  pause?: () => Promise<void>;
23
21
  }
24
- export declare function analyzeMusicFromBuffer(audioBuffer: AudioBuffer, { pause }?: MusicAnalysisOptions): Promise<MusicBeatAnalysis>;
25
- export declare function detectBeatsFromUrl(url: string): Promise<number[]>;
22
+ /** Beats, tempo and per-beat strength of decoded audio; no beats and a null bpm when it has no steady beat. */
23
+ export declare function analyzeMusicFromSignal(signal: AudioSignal, { pause }?: MusicAnalysisOptions): Promise<MusicBeatAnalysis>;
24
+ export declare function analyzeMusicFromBuffer(audioBuffer: AudioBuffer, options?: MusicAnalysisOptions): Promise<MusicBeatAnalysis>;
26
25
  export declare function analyzeMusicFromUrl(url: string, options?: MusicAnalysisOptions): Promise<MusicBeatAnalysis>;
27
- export {};