@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.
- package/dist/adapters/gsap.d.ts +1 -1
- package/dist/adapters/index.d.ts +3 -3
- package/dist/audioAnalysis.d.ts +7 -5
- package/dist/audioAnalysis.js +1 -1
- package/dist/audioResample.d.ts +2 -0
- package/dist/audioResample.js +37 -0
- package/dist/audioRhythm.d.ts +7 -9
- package/dist/audioRhythm.js +145 -49
- package/dist/audioSections.d.ts +3 -3
- package/dist/audioSections.js +14 -6
- package/dist/beats/beatDetection.d.ts +6 -8
- package/dist/beats/beatDetection.js +25 -217
- package/dist/colorGrading.d.ts +3 -3
- package/dist/compiler/htmlBundler.d.ts +2 -2
- package/dist/compiler/index.d.ts +19 -19
- package/dist/compiler/inlineSubCompositions.d.ts +4 -4
- package/dist/compiler/svgIdNamespacing.d.ts +1 -1
- package/dist/figma/assetSnippet.d.ts +1 -1
- package/dist/figma/client.d.ts +1 -1
- package/dist/figma/emitTimelineScript.d.ts +1 -1
- package/dist/figma/index.d.ts +22 -22
- package/dist/figma/manifest.d.ts +1 -1
- package/dist/figma/motionContextToDocs.d.ts +1 -1
- package/dist/figma/motionEase.d.ts +1 -1
- package/dist/figma/motionToGsap.d.ts +1 -1
- package/dist/figma/nodeDocument.d.ts +1 -1
- package/dist/figma/nodeToHtml.d.ts +3 -3
- package/dist/figma/parseFigmaRef.d.ts +1 -1
- package/dist/figma/resolveBindings.d.ts +2 -2
- package/dist/figma/tokensToVariables.d.ts +2 -2
- package/dist/generators/hyperframes.d.ts +1 -1
- package/dist/index.d.ts +37 -37
- package/dist/inline-scripts/hyperframe.d.ts +1 -1
- package/dist/projects.d.ts +1 -1
- package/dist/runtime/bridge.d.ts +2 -2
- package/dist/runtime/clipTree.d.ts +1 -1
- package/dist/runtime/compositionLength.d.ts +2 -2
- package/dist/runtime/mediaVolumeEnvelope.d.ts +1 -1
- package/dist/runtime/playbackRate.d.ts +1 -1
- package/dist/runtime/startResolver.d.ts +3 -3
- package/dist/runtime/state.d.ts +3 -3
- package/dist/runtime/timeline.d.ts +2 -2
- package/dist/runtime/timelineDuration.d.ts +1 -1
- package/dist/runtime/types.d.ts +2 -2
- package/dist/runtime/validateVariables.d.ts +1 -1
- package/dist/templates/base.d.ts +1 -1
- package/dist/vfx/refs/displacementMap.d.ts +1 -1
- package/dist/vfx/refs/fractalNoise.d.ts +1 -1
- package/dist/vfx/refs/lumaMatte.d.ts +1 -1
- package/dist/vfx/refs/noise.d.ts +1 -1
- package/dist/vfx/refs/waveWarp.d.ts +1 -1
- package/package.json +5 -6
package/dist/adapters/gsap.d.ts
CHANGED
package/dist/adapters/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/audioAnalysis.d.ts
CHANGED
|
@@ -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>;
|
package/dist/audioAnalysis.js
CHANGED
|
@@ -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,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
|
+
}
|
package/dist/audioRhythm.d.ts
CHANGED
|
@@ -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"
|
|
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,
|
|
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.
|
|
44
|
-
/**
|
|
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;
|
package/dist/audioRhythm.js
CHANGED
|
@@ -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(
|
|
13
|
-
const binHz =
|
|
20
|
+
function bandEdges() {
|
|
21
|
+
const binHz = ANALYSIS_RATE / FRAME_SIZE;
|
|
14
22
|
const edges = [];
|
|
15
|
-
for (let hz = 30; hz <
|
|
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,
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
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
|
|
48
|
-
const power = new Float64Array(
|
|
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,
|
|
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
|
|
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 }
|
|
72
|
-
const
|
|
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
|
|
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.
|
|
188
|
-
longest: Math.min(fullRange(fps).longest, Math.ceil(period * 1.
|
|
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
|
-
|
|
228
|
-
function
|
|
229
|
-
const
|
|
230
|
-
const
|
|
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 =
|
|
235
|
-
const off =
|
|
236
|
-
const
|
|
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.
|
|
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`,
|
|
261
|
-
|
|
262
|
-
const curves = onsetCurves(frames
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
406
|
+
return (entry.rhythm ??= rhythmOf(frames));
|
|
311
407
|
}
|
package/dist/audioSections.d.ts
CHANGED
|
@@ -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;
|
package/dist/audioSections.js
CHANGED
|
@@ -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
|
-
/**
|
|
30
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
25
|
-
export declare function
|
|
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 {};
|