nodejs-audio-visualizer 5.5.2 → 5.6.3

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 (48) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +84 -83
  3. package/dist/audio.d.ts +13 -13
  4. package/dist/audio.js +128 -128
  5. package/dist/beats.d.ts +39 -44
  6. package/dist/beats.js +220 -313
  7. package/dist/beats.js.map +1 -1
  8. package/dist/bpmEncoder.d.ts +14 -14
  9. package/dist/bpmEncoder.js +97 -97
  10. package/dist/config.d.ts +81 -80
  11. package/dist/config.js +392 -391
  12. package/dist/config.js.map +1 -1
  13. package/dist/dsp.d.ts +16 -16
  14. package/dist/dsp.js +76 -76
  15. package/dist/highlight.d.ts +91 -91
  16. package/dist/highlight.js +606 -606
  17. package/dist/image.d.ts +107 -97
  18. package/dist/image.js +614 -545
  19. package/dist/image.js.map +1 -1
  20. package/dist/index.d.ts +98 -96
  21. package/dist/index.js +673 -667
  22. package/dist/index.js.map +1 -1
  23. package/dist/subtitleConvert.d.ts +3 -3
  24. package/dist/subtitleConvert.js +61 -61
  25. package/dist/video.d.ts +171 -171
  26. package/dist/video.js +743 -743
  27. package/dist/waitForEventLoop.d.ts +1 -1
  28. package/dist/waitForEventLoop.js +5 -5
  29. package/package.json +62 -61
  30. package/src/audio.ts +129 -129
  31. package/src/beats.ts +279 -396
  32. package/src/bpmEncoder.ts +97 -97
  33. package/src/config.ts +504 -501
  34. package/src/dsp.ts +95 -95
  35. package/src/highlight.ts +617 -617
  36. package/src/image.ts +791 -707
  37. package/src/index.ts +896 -885
  38. package/src/subtitleConvert.ts +63 -63
  39. package/src/tests/audio.test.ts +102 -102
  40. package/src/tests/beats.test.ts +134 -134
  41. package/src/tests/config.test.ts +568 -553
  42. package/src/tests/dsp.test.ts +24 -24
  43. package/src/tests/highlight.test.ts +397 -397
  44. package/src/tests/image.test.ts +371 -319
  45. package/src/tests/index.test.ts +180 -180
  46. package/src/tests/subtitleConvert.test.ts +23 -23
  47. package/src/typings.d.ts +28 -6
  48. package/src/waitForEventLoop.ts +2 -2
package/src/beats.ts CHANGED
@@ -1,396 +1,279 @@
1
- export interface BeatInfo {
2
- isBeat: boolean;
3
- intensity: number;
4
- energy: number;
5
- threshold: number;
6
- }
7
-
8
- export interface BeatDetectorOptions {
9
- /** Number of past frames to average for adaptive threshold (~1.5s by default) */
10
- historySize?: number;
11
- /** Energy must exceed average * this multiplier to trigger a beat */
12
- thresholdMultiplier?: number;
13
- /** How fast the beat intensity fades between beats (0-1, lower = faster fade) */
14
- decayRate?: number;
15
- /** Minimum frames between consecutive beats (~150ms by default) */
16
- cooldownFrames?: number;
17
- /** How many low-frequency spectrum bands contribute to energy (sub-bass + bass) */
18
- bassEndIndex?: number;
19
- }
20
-
21
- export interface TempoEstimate {
22
- bpm: number;
23
- periodFrames: number;
24
- phaseFrame: number;
25
- /** Beat times in seconds from the start of the analyzed audio. */
26
- beatsSec?: number[];
27
- }
28
-
29
- /** Detected tempo is folded (doubled/halved) into this range. */
30
- export const MIN_TEMPO_BPM = 90;
31
- export const MAX_TEMPO_BPM = 180;
32
- export const DEFAULT_BASS_END_INDEX = 8;
33
- /** Longest PCM window used for tempo analysis (seconds), taken from the start of the audio. */
34
- export const MAX_TEMPO_ANALYSIS_SECONDS = 90;
35
- /** Band-pass (low pass then high pass) that isolates kick impulses before peak picking. */
36
- const TEMPO_LOW_PASS_HZ = 150;
37
- const TEMPO_HIGH_PASS_HZ = 100;
38
- /** Biquad Q in dB, as in the Web Audio BiquadFilterNode. */
39
- const TEMPO_FILTER_Q_DB = 1;
40
- /** Audio is split into parts of this length; each part contributes its loudest sample as a peak. */
41
- const TEMPO_PART_SECONDS = 0.5;
42
- /** Each peak is compared with this many following peaks to build intervals. */
43
- const TEMPO_INTERVAL_NEIGHBOURS = 9;
44
- /** Peaks are moved left by this share of a beat to land on the start of the hit, not its maximum. */
45
- const TEMPO_PEAK_LEFT_SHIFT_SHARE = 0.05;
46
- /** Peaks whose beat offset is within this many seconds of the loudest peak's offset set the phase. */
47
- const TEMPO_OFFSET_TOLERANCE_SEC = 0.05;
48
- const SILENCE_THRESHOLD = 1e-4;
49
-
50
- export const frameBassEnergy = (spectrum: number[], bassEndIndex = DEFAULT_BASS_END_INDEX): number => {
51
- const end = Math.min(bassEndIndex, spectrum.length);
52
- if (end <= 0) {
53
- return 0;
54
- }
55
- let sum = 0;
56
- for (let i = 0; i < end; i++) {
57
- const v = spectrum[i];
58
- sum += v * v;
59
- }
60
- return sum / end;
61
- };
62
-
63
- export const createBeatDetector = (fps: number, options?: BeatDetectorOptions) => {
64
- const historySize = options?.historySize ?? Math.round(fps * 1.5);
65
- const thresholdMultiplier = options?.thresholdMultiplier ?? 1.4;
66
- const decayRate = options?.decayRate ?? 0.85;
67
- const cooldownFrames = options?.cooldownFrames ?? Math.round(fps * 0.15);
68
- const bassEndIndex = options?.bassEndIndex ?? DEFAULT_BASS_END_INDEX;
69
-
70
- const energyHistory: number[] = [];
71
- let framesSinceLastBeat = cooldownFrames;
72
- let decayingIntensity = 0;
73
-
74
- return (spectrum: number[]): BeatInfo => {
75
- const energy = frameBassEnergy(spectrum, bassEndIndex);
76
-
77
- energyHistory.push(energy);
78
- if (energyHistory.length > historySize) {
79
- energyHistory.shift();
80
- }
81
-
82
- const avgEnergy = energyHistory.reduce((sum, e) => sum + e, 0) / energyHistory.length;
83
- const variance = energyHistory.reduce((sum, e) => sum + (e - avgEnergy) ** 2, 0) / energyHistory.length;
84
- const stdDev = Math.sqrt(variance);
85
-
86
- const threshold = avgEnergy + stdDev * thresholdMultiplier;
87
-
88
- framesSinceLastBeat++;
89
-
90
- const hasEnoughHistory = energyHistory.length >= Math.round(historySize / 3);
91
- const isAboveThreshold = energy > threshold && hasEnoughHistory;
92
- const isCooldownOver = framesSinceLastBeat >= cooldownFrames;
93
- const isBeat = isAboveThreshold && isCooldownOver;
94
-
95
- if (isBeat) {
96
- framesSinceLastBeat = 0;
97
- decayingIntensity = Math.min(1, (energy - threshold) / (stdDev + 1e-6));
98
- } else {
99
- decayingIntensity *= decayRate;
100
- }
101
-
102
- return {
103
- isBeat,
104
- intensity: decayingIntensity,
105
- energy,
106
- threshold,
107
- };
108
- };
109
- };
110
-
111
- const wrapIntoPeriod = (value: number, period: number): number => {
112
- if (!(period > 0) || !isFinite(value)) {
113
- return 0;
114
- }
115
- let wrapped = value - period * Math.floor(value / period);
116
- if (wrapped < 0) {
117
- wrapped += period;
118
- }
119
- if (wrapped >= period) {
120
- wrapped = 0;
121
- }
122
- return wrapped;
123
- };
124
-
125
- const circularMeanPhase = (frames: number[], period: number): number => {
126
- if (!(period > 0) || frames.length === 0) {
127
- return 0;
128
- }
129
- let sinSum = 0;
130
- let cosSum = 0;
131
- for (const frame of frames) {
132
- const angle = (2 * Math.PI * frame) / period;
133
- sinSum += Math.sin(angle);
134
- cosSum += Math.cos(angle);
135
- }
136
- const phase = Math.atan2(sinSum, cosSum) * period / (2 * Math.PI);
137
- return wrapIntoPeriod(phase, period);
138
- };
139
-
140
- /** Signed distance from `value` to `reference` on a circle of length `period`, in [-period/2, period/2). */
141
- const circularDiff = (value: number, reference: number, period: number): number =>
142
- wrapIntoPeriod(value - reference + period / 2, period) - period / 2;
143
-
144
- type BiquadType = 'lowpass' | 'highpass';
145
-
146
- /** Biquad filter with the Web Audio BiquadFilterNode (RBJ cookbook) coefficients. */
147
- const biquadFilter = (
148
- samples: ArrayLike<number>,
149
- length: number,
150
- sampleRate: number,
151
- type: BiquadType,
152
- frequency: number,
153
- qDb: number,
154
- ): Float32Array => {
155
- const w0 = 2 * Math.PI * Math.min(frequency, sampleRate / 2 - 1) / sampleRate;
156
- const cosW0 = Math.cos(w0);
157
- const alpha = Math.sin(w0) / (2 * Math.pow(10, qDb / 20));
158
- const a0 = 1 + alpha;
159
- const b1 = (type === 'lowpass' ? 1 - cosW0 : -(1 + cosW0)) / a0;
160
- const b0 = (type === 'lowpass' ? b1 / 2 : -b1 / 2);
161
- const b2 = b0;
162
- const a1 = (-2 * cosW0) / a0;
163
- const a2 = (1 - alpha) / a0;
164
-
165
- const out = new Float32Array(length);
166
- let x1 = 0;
167
- let x2 = 0;
168
- let y1 = 0;
169
- let y2 = 0;
170
- for (let i = 0; i < length; i++) {
171
- const x = samples[i];
172
- const y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
173
- out[i] = y;
174
- x2 = x1;
175
- x1 = x;
176
- y2 = y1;
177
- y1 = y;
178
- }
179
- return out;
180
- };
181
-
182
- interface Peak {
183
- position: number;
184
- volume: number;
185
- }
186
-
187
- /** Loudest sample of each part (`getVolume` picks how a sample is measured). */
188
- const partPeaks = (
189
- data: Float32Array,
190
- partSize: number,
191
- getVolume: (sample: number) => number,
192
- positionShift = 0,
193
- ): Peak[] => {
194
- const peaks: Peak[] = [];
195
- for (let start = 0; start < data.length; start += partSize) {
196
- const end = Math.min(start + partSize, data.length);
197
- let max: Peak | null = null;
198
- for (let j = start; j < end; j++) {
199
- const volume = getVolume(data[j]);
200
- if (!max || volume > max.volume) {
201
- max = { position: j - positionShift, volume };
202
- }
203
- }
204
- if (max) {
205
- peaks.push(max);
206
- }
207
- }
208
- return peaks;
209
- };
210
-
211
- /** Loudest half of the per-part peaks, in playback order. */
212
- const getLoudPeaks = (data: Float32Array, partSize: number): Peak[] => {
213
- const peaks = partPeaks(data, partSize, Math.abs)
214
- .sort((a, b) => b.volume - a.volume);
215
- return peaks
216
- .slice(0, Math.floor(peaks.length / 2))
217
- .filter(p => p.volume > SILENCE_THRESHOLD)
218
- .sort((a, b) => a.position - b.position);
219
- };
220
-
221
- const foldTempoBpm = (bpm: number): number => {
222
- let folded = bpm;
223
- while (folded <= MIN_TEMPO_BPM) {
224
- folded *= 2;
225
- }
226
- while (folded > MAX_TEMPO_BPM) {
227
- folded /= 2;
228
- }
229
- return folded;
230
- };
231
-
232
- /** Most frequent tempo among the intervals between each peak and its following neighbours. */
233
- const getMostFrequentTempo = (peaks: Peak[], sampleRate: number): number | null => {
234
- const counts = new Map<number, number>();
235
- peaks.forEach((peak, index) => {
236
- for (let i = 1; index + i < peaks.length && i <= TEMPO_INTERVAL_NEIGHBOURS; i++) {
237
- const distance = peaks[index + i].position - peak.position;
238
- if (distance <= 0) {
239
- continue;
240
- }
241
- const tempo = Math.round(foldTempoBpm((60 * sampleRate) / distance));
242
- counts.set(tempo, (counts.get(tempo) ?? 0) + 1);
243
- }
244
- });
245
- let best: number | null = null;
246
- let bestCount = 0;
247
- counts.forEach((count, tempo) => {
248
- if (count > bestCount) {
249
- best = tempo;
250
- bestCount = count;
251
- }
252
- });
253
- return best;
254
- };
255
-
256
- /**
257
- * Beat offset in [0, beat) seconds: the loudest peak is taken as a strong beat, and the offsets
258
- * of peaks that agree with it (within `TEMPO_OFFSET_TOLERANCE_SEC`) are averaged.
259
- */
260
- const getBeatOffsetSec = (data: Float32Array, partSize: number, sampleRate: number, bpm: number): number => {
261
- const beatSec = 60 / bpm;
262
- const shift = Math.round(beatSec * TEMPO_PEAK_LEFT_SHIFT_SHARE * sampleRate);
263
- const peaks = partPeaks(data, partSize, v => v, shift)
264
- .filter(p => p.volume > SILENCE_THRESHOLD)
265
- .sort((a, b) => b.volume - a.volume);
266
- if (peaks.length === 0) {
267
- return 0;
268
- }
269
- const refOffset = wrapIntoPeriod(peaks[0].position / sampleRate, beatSec);
270
- let diffSum = 0;
271
- let count = 0;
272
- for (const peak of peaks) {
273
- const diff = circularDiff(peak.position / sampleRate, refOffset, beatSec);
274
- if (Math.abs(diff) < TEMPO_OFFSET_TOLERANCE_SEC) {
275
- diffSum += diff;
276
- count++;
277
- }
278
- }
279
- return wrapIntoPeriod(refOffset + diffSum / count, beatSec);
280
- };
281
-
282
- /**
283
- * Estimates the track tempo and beat phase (after BeatDetect.js by Arthur Beaulieu):
284
- * band-pass the audio around the kick, take the loudest sample of every half second,
285
- * keep the loudest half of those peaks, and pick the most frequent tempo among the intervals
286
- * between neighbouring peaks. The beat phase comes from the loudest peak and the peaks aligned with it.
287
- */
288
- export const estimateTempo = (
289
- samples: ArrayLike<number>,
290
- sampleRate: number,
291
- fps: number,
292
- ): TempoEstimate | null => {
293
- if (!samples || samples.length === 0 || !(sampleRate > 0) || !(fps > 0)) {
294
- return null;
295
- }
296
-
297
- const length = Math.min(samples.length, Math.max(1, Math.floor(sampleRate * MAX_TEMPO_ANALYSIS_SECONDS)));
298
- const lowPassed = biquadFilter(samples, length, sampleRate, 'lowpass', TEMPO_LOW_PASS_HZ, TEMPO_FILTER_Q_DB);
299
- const data = biquadFilter(lowPassed, length, sampleRate, 'highpass', TEMPO_HIGH_PASS_HZ, TEMPO_FILTER_Q_DB);
300
- const partSize = Math.max(1, Math.round(sampleRate * TEMPO_PART_SECONDS));
301
-
302
- const bpm = getMostFrequentTempo(getLoudPeaks(data, partSize), sampleRate);
303
- if (bpm === null) {
304
- return null;
305
- }
306
-
307
- const beatSec = 60 / bpm;
308
- const offsetSec = getBeatOffsetSec(data, partSize, sampleRate, bpm);
309
- const durationSec = length / sampleRate;
310
- const beatsSec: number[] = [];
311
- for (let t = offsetSec; t < durationSec; t += beatSec) {
312
- beatsSec.push(t);
313
- }
314
-
315
- const periodFrames = fps * beatSec;
316
- return {
317
- bpm,
318
- periodFrames,
319
- phaseFrame: wrapIntoPeriod(offsetSec * fps, periodFrames),
320
- ...(beatsSec.length > 0 ? { beatsSec } : {}),
321
- };
322
- };
323
-
324
- export const shiftTempoPhase = (
325
- tempo: TempoEstimate,
326
- startFrame: number,
327
- ): TempoEstimate => {
328
- const period = tempo.periodFrames;
329
- if (!(period > 0) || startFrame === 0) {
330
- return tempo;
331
- }
332
- return {
333
- ...tempo,
334
- phaseFrame: wrapIntoPeriod(tempo.phaseFrame - startFrame, period),
335
- };
336
- };
337
-
338
- /** Beat-grid phase for a later audio window, using in-window beat times when available. */
339
- export const tempoForWindow = (
340
- tempo: TempoEstimate,
341
- startFrame: number,
342
- fps: number,
343
- windowFrames?: number,
344
- localOnsetFrames?: number[],
345
- ): TempoEstimate => {
346
- const period = tempo.periodFrames;
347
- if (!(period > 0) || !(fps > 0)) {
348
- return tempo;
349
- }
350
- const startSec = startFrame / fps;
351
- const endSec = windowFrames != null && windowFrames >= 0
352
- ? (startFrame + windowFrames) / fps
353
- : Number.POSITIVE_INFINITY;
354
- const inWindow = (tempo.beatsSec ?? []).filter(t => t >= startSec && t < endSec);
355
- if (inWindow.length > 0) {
356
- const localFrames = inWindow.map(t => (t - startSec) * fps);
357
- return {
358
- ...tempo,
359
- phaseFrame: wrapIntoPeriod(circularMeanPhase(localFrames, period), period),
360
- };
361
- }
362
- const onsets = (localOnsetFrames ?? []).filter(frame =>
363
- frame > 0 && (windowFrames == null || frame < windowFrames),
364
- );
365
- if (onsets.length > 0) {
366
- return {
367
- ...tempo,
368
- phaseFrame: wrapIntoPeriod(circularMeanPhase(onsets, period), period),
369
- };
370
- }
371
- return shiftTempoPhase(tempo, startFrame);
372
- };
373
-
374
- export const beatGridFrameIndices = (
375
- tempo: Pick<TempoEstimate, 'periodFrames' | 'phaseFrame'>,
376
- totalFrames: number,
377
- stride = 1,
378
- ): number[] => {
379
- const period = tempo.periodFrames;
380
- const step = Math.max(1, Math.round(stride));
381
- if (!(period > 0) || totalFrames <= 1) {
382
- return [];
383
- }
384
- const frames: number[] = [];
385
- const nStart = tempo.phaseFrame > 0 ? 0 : 1;
386
- for (let n = nStart; ; n += step) {
387
- const frame = Math.round(tempo.phaseFrame + n * period);
388
- if (frame >= totalFrames) {
389
- break;
390
- }
391
- if (frame > 0) {
392
- frames.push(frame);
393
- }
394
- }
395
- return frames;
396
- };
1
+ import MusicTempo from 'music-tempo';
2
+
3
+ export interface BeatInfo {
4
+ isBeat: boolean;
5
+ intensity: number;
6
+ energy: number;
7
+ threshold: number;
8
+ }
9
+
10
+ export interface BeatDetectorOptions {
11
+ /** Number of past frames to average for adaptive threshold (~1.5s by default) */
12
+ historySize?: number;
13
+ /** Energy must exceed average * this multiplier to trigger a beat */
14
+ thresholdMultiplier?: number;
15
+ /** How fast the beat intensity fades between beats (0-1, lower = faster fade) */
16
+ decayRate?: number;
17
+ /** Minimum frames between consecutive beats (~150ms by default) */
18
+ cooldownFrames?: number;
19
+ /** How many low-frequency spectrum bands contribute to energy (sub-bass + bass) */
20
+ bassEndIndex?: number;
21
+ }
22
+
23
+ export interface TempoEstimate {
24
+ bpm: number;
25
+ periodFrames: number;
26
+ phaseFrame: number;
27
+ /** Beat times in seconds from the start of the analyzed audio. */
28
+ beatsSec?: number[];
29
+ }
30
+
31
+ export const MIN_TEMPO_BPM = 70;
32
+ export const MAX_TEMPO_BPM = 180;
33
+ export const PREFERRED_TEMPO_BPM_MIN = 90;
34
+ export const PREFERRED_TEMPO_BPM_MAX = 160;
35
+ export const DEFAULT_BASS_END_INDEX = 8;
36
+ /** Longest PCM window passed to music-tempo (seconds), taken from the start of the audio. */
37
+ export const MAX_TEMPO_ANALYSIS_SECONDS = 90;
38
+ const TEMPO_TIME_STEP = 0.01;
39
+
40
+ export const frameBassEnergy = (spectrum: number[], bassEndIndex = DEFAULT_BASS_END_INDEX): number => {
41
+ const end = Math.min(bassEndIndex, spectrum.length);
42
+ if (end <= 0) {
43
+ return 0;
44
+ }
45
+ let sum = 0;
46
+ for (let i = 0; i < end; i++) {
47
+ const v = spectrum[i];
48
+ sum += v * v;
49
+ }
50
+ return sum / end;
51
+ };
52
+
53
+ export const createBeatDetector = (fps: number, options?: BeatDetectorOptions) => {
54
+ const historySize = options?.historySize ?? Math.round(fps * 1.5);
55
+ const thresholdMultiplier = options?.thresholdMultiplier ?? 1.4;
56
+ const decayRate = options?.decayRate ?? 0.85;
57
+ const cooldownFrames = options?.cooldownFrames ?? Math.round(fps * 0.15);
58
+ const bassEndIndex = options?.bassEndIndex ?? DEFAULT_BASS_END_INDEX;
59
+
60
+ const energyHistory: number[] = [];
61
+ let framesSinceLastBeat = cooldownFrames;
62
+ let decayingIntensity = 0;
63
+
64
+ return (spectrum: number[]): BeatInfo => {
65
+ const energy = frameBassEnergy(spectrum, bassEndIndex);
66
+
67
+ energyHistory.push(energy);
68
+ if (energyHistory.length > historySize) {
69
+ energyHistory.shift();
70
+ }
71
+
72
+ const avgEnergy = energyHistory.reduce((sum, e) => sum + e, 0) / energyHistory.length;
73
+ const variance = energyHistory.reduce((sum, e) => sum + (e - avgEnergy) ** 2, 0) / energyHistory.length;
74
+ const stdDev = Math.sqrt(variance);
75
+
76
+ const threshold = avgEnergy + stdDev * thresholdMultiplier;
77
+
78
+ framesSinceLastBeat++;
79
+
80
+ const hasEnoughHistory = energyHistory.length >= Math.round(historySize / 3);
81
+ const isAboveThreshold = energy > threshold && hasEnoughHistory;
82
+ const isCooldownOver = framesSinceLastBeat >= cooldownFrames;
83
+ const isBeat = isAboveThreshold && isCooldownOver;
84
+
85
+ if (isBeat) {
86
+ framesSinceLastBeat = 0;
87
+ decayingIntensity = Math.min(1, (energy - threshold) / (stdDev + 1e-6));
88
+ } else {
89
+ decayingIntensity *= decayRate;
90
+ }
91
+
92
+ return {
93
+ isBeat,
94
+ intensity: decayingIntensity,
95
+ energy,
96
+ threshold,
97
+ };
98
+ };
99
+ };
100
+
101
+ const foldTempoBpm = (bpm: number): number => {
102
+ let folded = bpm;
103
+ while (folded > MAX_TEMPO_BPM && folded / 2 >= MIN_TEMPO_BPM) {
104
+ folded /= 2;
105
+ }
106
+ while (folded < MIN_TEMPO_BPM && folded * 2 <= MAX_TEMPO_BPM) {
107
+ folded *= 2;
108
+ }
109
+ return folded;
110
+ };
111
+
112
+ const samplesToArray = (samples: ArrayLike<number>, maxLength: number): number[] => {
113
+ const n = Math.min(samples.length, maxLength);
114
+ if (Array.isArray(samples) && n === samples.length) {
115
+ return samples;
116
+ }
117
+ const audioData = new Array<number>(n);
118
+ for (let i = 0; i < n; i++) {
119
+ audioData[i] = samples[i];
120
+ }
121
+ return audioData;
122
+ };
123
+
124
+ const wrapIntoPeriod = (value: number, period: number): number => {
125
+ if (!(period > 0) || !isFinite(value)) {
126
+ return 0;
127
+ }
128
+ let wrapped = value - period * Math.floor(value / period);
129
+ if (wrapped < 0) {
130
+ wrapped += period;
131
+ }
132
+ if (wrapped >= period) {
133
+ wrapped = 0;
134
+ }
135
+ return wrapped;
136
+ };
137
+
138
+ const circularMeanPhase = (frames: number[], period: number): number => {
139
+ if (!(period > 0) || frames.length === 0) {
140
+ return 0;
141
+ }
142
+ let sinSum = 0;
143
+ let cosSum = 0;
144
+ for (const frame of frames) {
145
+ const angle = (2 * Math.PI * frame) / period;
146
+ sinSum += Math.sin(angle);
147
+ cosSum += Math.cos(angle);
148
+ }
149
+ const phase = Math.atan2(sinSum, cosSum) * period / (2 * Math.PI);
150
+ return wrapIntoPeriod(phase, period);
151
+ };
152
+
153
+ export const estimateTempo = (
154
+ samples: ArrayLike<number>,
155
+ sampleRate: number,
156
+ fps: number,
157
+ ): TempoEstimate | null => {
158
+ if (!samples || samples.length === 0 || !(sampleRate > 0) || !(fps > 0)) {
159
+ return null;
160
+ }
161
+
162
+ const maxSamples = Math.max(1, Math.floor(sampleRate * MAX_TEMPO_ANALYSIS_SECONDS));
163
+ const audioData = samplesToArray(samples, maxSamples);
164
+ const hopSize = Math.max(1, Math.round(sampleRate * TEMPO_TIME_STEP));
165
+
166
+ try {
167
+ const mt = new MusicTempo(audioData, {
168
+ hopSize,
169
+ timeStep: TEMPO_TIME_STEP,
170
+ minBeatInterval: 60 / MAX_TEMPO_BPM,
171
+ maxBeatInterval: 60 / MIN_TEMPO_BPM,
172
+ });
173
+ const beatInterval = Number(mt.beatInterval);
174
+ let bpm = Number(mt.tempo);
175
+ if (!(bpm > 0) && beatInterval > 0) {
176
+ bpm = 60 / beatInterval;
177
+ }
178
+ if (!(bpm > 0)) {
179
+ return null;
180
+ }
181
+
182
+ bpm = foldTempoBpm(bpm);
183
+ if (bpm < MIN_TEMPO_BPM * 0.9 || bpm > MAX_TEMPO_BPM * 1.1) {
184
+ return null;
185
+ }
186
+
187
+ const periodFrames = fps * 60 / bpm;
188
+ const beatsSec = (mt.beats ?? [])
189
+ .map(t => Number(t))
190
+ .filter(t => t >= 0 && isFinite(t));
191
+ const beatFrames = beatsSec.map(t => t * fps);
192
+ const phaseFrame = beatFrames.length > 0
193
+ ? wrapIntoPeriod(circularMeanPhase(beatFrames, periodFrames), periodFrames)
194
+ : 0;
195
+
196
+ return {
197
+ bpm: Math.round(bpm),
198
+ periodFrames,
199
+ phaseFrame,
200
+ ...(beatsSec.length > 0 ? { beatsSec } : {}),
201
+ };
202
+ } catch {
203
+ return null;
204
+ }
205
+ };
206
+
207
+ export const shiftTempoPhase = (
208
+ tempo: TempoEstimate,
209
+ startFrame: number,
210
+ ): TempoEstimate => {
211
+ const period = tempo.periodFrames;
212
+ if (!(period > 0) || startFrame === 0) {
213
+ return tempo;
214
+ }
215
+ return {
216
+ ...tempo,
217
+ phaseFrame: wrapIntoPeriod(tempo.phaseFrame - startFrame, period),
218
+ };
219
+ };
220
+
221
+ /** Beat-grid phase for a later audio window, using in-window beat times when available. */
222
+ export const tempoForWindow = (
223
+ tempo: TempoEstimate,
224
+ startFrame: number,
225
+ fps: number,
226
+ windowFrames?: number,
227
+ localOnsetFrames?: number[],
228
+ ): TempoEstimate => {
229
+ const period = tempo.periodFrames;
230
+ if (!(period > 0) || !(fps > 0)) {
231
+ return tempo;
232
+ }
233
+ const startSec = startFrame / fps;
234
+ const endSec = windowFrames != null && windowFrames >= 0
235
+ ? (startFrame + windowFrames) / fps
236
+ : Number.POSITIVE_INFINITY;
237
+ const inWindow = (tempo.beatsSec ?? []).filter(t => t >= startSec && t < endSec);
238
+ if (inWindow.length > 0) {
239
+ const localFrames = inWindow.map(t => (t - startSec) * fps);
240
+ return {
241
+ ...tempo,
242
+ phaseFrame: wrapIntoPeriod(circularMeanPhase(localFrames, period), period),
243
+ };
244
+ }
245
+ const onsets = (localOnsetFrames ?? []).filter(frame =>
246
+ frame > 0 && (windowFrames == null || frame < windowFrames),
247
+ );
248
+ if (onsets.length > 0) {
249
+ return {
250
+ ...tempo,
251
+ phaseFrame: wrapIntoPeriod(circularMeanPhase(onsets, period), period),
252
+ };
253
+ }
254
+ return shiftTempoPhase(tempo, startFrame);
255
+ };
256
+
257
+ export const beatGridFrameIndices = (
258
+ tempo: Pick<TempoEstimate, 'periodFrames' | 'phaseFrame'>,
259
+ totalFrames: number,
260
+ stride = 1,
261
+ ): number[] => {
262
+ const period = tempo.periodFrames;
263
+ const step = Math.max(1, Math.round(stride));
264
+ if (!(period > 0) || totalFrames <= 1) {
265
+ return [];
266
+ }
267
+ const frames: number[] = [];
268
+ const nStart = tempo.phaseFrame > 0 ? 0 : 1;
269
+ for (let n = nStart; ; n += step) {
270
+ const frame = Math.round(tempo.phaseFrame + n * period);
271
+ if (frame >= totalFrames) {
272
+ break;
273
+ }
274
+ if (frame > 0) {
275
+ frames.push(frame);
276
+ }
277
+ }
278
+ return frames;
279
+ };