@bitmagic/asset-core 0.2.25-dev.0 → 0.2.25-dev.1

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 (89) hide show
  1. package/dist/trailer/analyze.d.ts +24 -0
  2. package/dist/trailer/analyze.d.ts.map +1 -0
  3. package/dist/trailer/analyze.js +186 -0
  4. package/dist/trailer/analyze.js.map +1 -0
  5. package/dist/trailer/auto-edl.d.ts +51 -0
  6. package/dist/trailer/auto-edl.d.ts.map +1 -0
  7. package/dist/trailer/auto-edl.js +266 -0
  8. package/dist/trailer/auto-edl.js.map +1 -0
  9. package/dist/trailer/beat-cut.d.ts +58 -0
  10. package/dist/trailer/beat-cut.d.ts.map +1 -0
  11. package/dist/trailer/beat-cut.js +232 -0
  12. package/dist/trailer/beat-cut.js.map +1 -0
  13. package/dist/trailer/capture.d.ts +102 -0
  14. package/dist/trailer/capture.d.ts.map +1 -0
  15. package/dist/trailer/capture.js +268 -0
  16. package/dist/trailer/capture.js.map +1 -0
  17. package/dist/trailer/edl-time.d.ts +62 -0
  18. package/dist/trailer/edl-time.d.ts.map +1 -0
  19. package/dist/trailer/edl-time.js +105 -0
  20. package/dist/trailer/edl-time.js.map +1 -0
  21. package/dist/trailer/errors.d.ts +15 -0
  22. package/dist/trailer/errors.d.ts.map +1 -0
  23. package/dist/trailer/errors.js +22 -0
  24. package/dist/trailer/errors.js.map +1 -0
  25. package/dist/trailer/ffmpeg.d.ts +19 -0
  26. package/dist/trailer/ffmpeg.d.ts.map +1 -0
  27. package/dist/trailer/ffmpeg.js +100 -0
  28. package/dist/trailer/ffmpeg.js.map +1 -0
  29. package/dist/trailer/hud-state.d.ts +182 -0
  30. package/dist/trailer/hud-state.d.ts.map +1 -0
  31. package/dist/trailer/hud-state.js +523 -0
  32. package/dist/trailer/hud-state.js.map +1 -0
  33. package/dist/trailer/index.d.ts +30 -0
  34. package/dist/trailer/index.d.ts.map +1 -0
  35. package/dist/trailer/index.js +30 -0
  36. package/dist/trailer/index.js.map +1 -0
  37. package/dist/trailer/io.d.ts +23 -0
  38. package/dist/trailer/io.d.ts.map +1 -0
  39. package/dist/trailer/io.js +20 -0
  40. package/dist/trailer/io.js.map +1 -0
  41. package/dist/trailer/link.d.ts +2 -0
  42. package/dist/trailer/link.d.ts.map +1 -0
  43. package/dist/trailer/link.js +15 -0
  44. package/dist/trailer/link.js.map +1 -0
  45. package/dist/trailer/make.d.ts +60 -0
  46. package/dist/trailer/make.d.ts.map +1 -0
  47. package/dist/trailer/make.js +196 -0
  48. package/dist/trailer/make.js.map +1 -0
  49. package/dist/trailer/merge.d.ts +8 -0
  50. package/dist/trailer/merge.d.ts.map +1 -0
  51. package/dist/trailer/merge.js +165 -0
  52. package/dist/trailer/merge.js.map +1 -0
  53. package/dist/trailer/music-beats.d.ts +124 -0
  54. package/dist/trailer/music-beats.d.ts.map +1 -0
  55. package/dist/trailer/music-beats.js +672 -0
  56. package/dist/trailer/music-beats.js.map +1 -0
  57. package/dist/trailer/paths.d.ts +29 -0
  58. package/dist/trailer/paths.d.ts.map +1 -0
  59. package/dist/trailer/paths.js +101 -0
  60. package/dist/trailer/paths.js.map +1 -0
  61. package/dist/trailer/png.d.ts +10 -0
  62. package/dist/trailer/png.d.ts.map +1 -0
  63. package/dist/trailer/png.js +37 -0
  64. package/dist/trailer/png.js.map +1 -0
  65. package/dist/trailer/render.d.ts +93 -0
  66. package/dist/trailer/render.d.ts.map +1 -0
  67. package/dist/trailer/render.js +527 -0
  68. package/dist/trailer/render.js.map +1 -0
  69. package/dist/trailer/revision.d.ts +9 -0
  70. package/dist/trailer/revision.d.ts.map +1 -0
  71. package/dist/trailer/revision.js +31 -0
  72. package/dist/trailer/revision.js.map +1 -0
  73. package/dist/trailer/synth.d.ts +10 -0
  74. package/dist/trailer/synth.d.ts.map +1 -0
  75. package/dist/trailer/synth.js +120 -0
  76. package/dist/trailer/synth.js.map +1 -0
  77. package/dist/trailer/timeline-types.d.ts +196 -0
  78. package/dist/trailer/timeline-types.d.ts.map +1 -0
  79. package/dist/trailer/timeline-types.js +419 -0
  80. package/dist/trailer/timeline-types.js.map +1 -0
  81. package/dist/trailer/titles.d.ts +38 -0
  82. package/dist/trailer/titles.d.ts.map +1 -0
  83. package/dist/trailer/titles.js +110 -0
  84. package/dist/trailer/titles.js.map +1 -0
  85. package/dist/trailer/video-chain.d.ts +130 -0
  86. package/dist/trailer/video-chain.d.ts.map +1 -0
  87. package/dist/trailer/video-chain.js +261 -0
  88. package/dist/trailer/video-chain.js.map +1 -0
  89. package/package.json +5 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"music-beats.d.ts","sourceRoot":"","sources":["../../src/trailer/music-beats.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,672 @@
1
+ /**
2
+ * Beat detection for trailer music — hand-rolled DSP, no audio libraries.
3
+ *
4
+ * ffmpeg decodes the track to mono 16-bit PCM on stdout; everything after that
5
+ * (Hann window, radix-2 FFT, spectral flux, autocorrelation, phase search) is
6
+ * plain TypeScript, the same way synth.ts hand-writes WAV encoding.
7
+ *
8
+ * The pure half — analyzeBeatsFromSamples and everything below it — takes raw
9
+ * samples, so the whole pipeline unit-tests on synthetic PCM without ffmpeg.
10
+ */
11
+ import { TrailerError } from './errors.js';
12
+ import { captureFfmpegTool } from './ffmpeg.js';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import { SYNTH_SAMPLE_RATE, samplesToWav, synthesizeSamples } from './synth.js';
16
+ import { out } from './io.js';
17
+ /** Analysis decode rate: covers kick through hat, and keeps the FFTs cheap. */
18
+ export const BEAT_SAMPLE_RATE = 22050;
19
+ export const FRAME_SIZE = 1024;
20
+ export const HOP_SIZE = 512;
21
+ export const BEATS_PER_BAR = 4;
22
+ const MIN_BPM = 60;
23
+ const MAX_BPM = 200;
24
+ /** Under this, the autocorrelation peak is more likely half-time than tempo. */
25
+ const OCTAVE_UP_BELOW_BPM = 90;
26
+ /** Over this, the peak is more likely double-time than tempo. */
27
+ const OCTAVE_DOWN_ABOVE_BPM = 180;
28
+ /** Take the doubled tempo when it still scores this fraction of the peak. */
29
+ const OCTAVE_UP_TOLERANCE = 0.85;
30
+ /** Onset-spike blur (in frames) applied before autocorrelation. */
31
+ const TEMPO_SMOOTH_FRAMES = 2;
32
+ /** Width of the moving average subtracted from the raw flux. */
33
+ const BASELINE_SEC = 0.5;
34
+ /** Kick band for downbeat picking. */
35
+ const LOW_BAND_HZ = 150;
36
+ /** Below this relative spread the four low-band phase scores are a coin flip. */
37
+ const LOW_BAND_FLAT_SPREAD = 0.05;
38
+ const SILENCE_DBFS = -120;
39
+ /**
40
+ * confidence maps the ratio (mean onset strength on the grid) / (mean onset
41
+ * strength everywhere) onto 0..1: ratio 1 is chance — a grid that hits nothing
42
+ * special — and maps to 0; a grid landing on onsets this many times stronger
43
+ * than the track average maps to 1. Measured on real tracks, a wrong tempo
44
+ * sits at 0.8-1.5 and a locked-on grid at 3-4, so the scale is set at 4.
45
+ */
46
+ const CONFIDENCE_FULL_RATIO = 4;
47
+ /** Half-width, in beats, of the window a grid refinement pulls onsets from. */
48
+ const REFINE_WINDOW_BEATS = 0.25;
49
+ /** Beats in the first grid-refinement pass, before the horizon doubles. */
50
+ const REFINE_FIRST_HORIZON_BEATS = 8;
51
+ /** A refit that moves the period further than this is rejected as a bad fit. */
52
+ const REFINE_MAX_PERIOD_DRIFT = 0.05;
53
+ const twiddleCache = new Map();
54
+ /** cos/sin of -2*pi*k/n for k < n/2, cached per transform size. */
55
+ function twiddles(n) {
56
+ const cached = twiddleCache.get(n);
57
+ if (cached !== undefined)
58
+ return cached;
59
+ const cos = new Float64Array(n / 2);
60
+ const sin = new Float64Array(n / 2);
61
+ for (let k = 0; k < n / 2; k++) {
62
+ const angle = (-2 * Math.PI * k) / n;
63
+ cos[k] = Math.cos(angle);
64
+ sin[k] = Math.sin(angle);
65
+ }
66
+ const table = { cos, sin };
67
+ twiddleCache.set(n, table);
68
+ return table;
69
+ }
70
+ /**
71
+ * Iterative in-place radix-2 Cooley-Tukey FFT. Power-of-two lengths only —
72
+ * the frame size is ours to choose, so there is no mixed-radix fallback.
73
+ */
74
+ export function fftInPlace(re, im) {
75
+ const n = re.length;
76
+ if (im.length !== n)
77
+ throw new Error(`FFT real/imag length mismatch: ${n} vs ${im.length}`);
78
+ if (n < 2 || (n & (n - 1)) !== 0)
79
+ throw new Error(`FFT size must be a power of two >= 2, got ${n}`);
80
+ // Bit-reversal permutation.
81
+ for (let i = 1, j = 0; i < n; i++) {
82
+ let bit = n >> 1;
83
+ for (; (j & bit) !== 0; bit >>= 1)
84
+ j ^= bit;
85
+ j ^= bit;
86
+ if (i < j) {
87
+ const tr = re[i];
88
+ re[i] = re[j];
89
+ re[j] = tr;
90
+ const ti = im[i];
91
+ im[i] = im[j];
92
+ im[j] = ti;
93
+ }
94
+ }
95
+ const { cos, sin } = twiddles(n);
96
+ for (let len = 2; len <= n; len <<= 1) {
97
+ const half = len >> 1;
98
+ const stride = n / len;
99
+ for (let start = 0; start < n; start += len) {
100
+ for (let k = 0; k < half; k++) {
101
+ const wr = cos[k * stride];
102
+ const wi = sin[k * stride];
103
+ const a = start + k;
104
+ const b = a + half;
105
+ const vr = re[b] * wr - im[b] * wi;
106
+ const vi = re[b] * wi + im[b] * wr;
107
+ re[b] = re[a] - vr;
108
+ im[b] = im[a] - vi;
109
+ re[a] += vr;
110
+ im[a] += vi;
111
+ }
112
+ }
113
+ }
114
+ }
115
+ /** Magnitude spectrum of a real frame: bins 0..n/2 inclusive. */
116
+ export function fftMagnitudes(frame) {
117
+ const n = frame.length;
118
+ const re = new Float64Array(n);
119
+ const im = new Float64Array(n);
120
+ for (let i = 0; i < n; i++)
121
+ re[i] = frame[i];
122
+ fftInPlace(re, im);
123
+ const bins = n / 2 + 1;
124
+ const mags = new Float64Array(bins);
125
+ for (let b = 0; b < bins; b++)
126
+ mags[b] = Math.hypot(re[b], im[b]);
127
+ return mags;
128
+ }
129
+ function hannWindow(size) {
130
+ const w = new Float64Array(size);
131
+ for (let i = 0; i < size; i++)
132
+ w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / size));
133
+ return w;
134
+ }
135
+ /**
136
+ * Subtract a centred moving average and half-wave rectify, so a loud steady
137
+ * section reads as no onset at all while a transient still spikes.
138
+ */
139
+ function subtractBaseline(flux, hopSec) {
140
+ const n = flux.length;
141
+ const half = Math.max(1, Math.round(BASELINE_SEC / 2 / hopSec));
142
+ const prefix = new Float64Array(n + 1);
143
+ for (let i = 0; i < n; i++)
144
+ prefix[i + 1] = prefix[i] + flux[i];
145
+ const result = new Float64Array(n);
146
+ for (let i = 0; i < n; i++) {
147
+ const from = Math.max(0, i - half);
148
+ const to = Math.min(n, i + half + 1);
149
+ const mean = (prefix[to] - prefix[from]) / (to - from);
150
+ result[i] = Math.max(0, flux[i] - mean);
151
+ }
152
+ return result;
153
+ }
154
+ /**
155
+ * Spectral-flux onset envelope. Frames are centred (the signal is treated as
156
+ * zero-padded by frameSize/2 on the left) so frame t sits exactly on
157
+ * t * hopSec — the beat grid is built in that time base.
158
+ */
159
+ export function computeOnsetEnvelope(samples, sampleRate, frameSize = FRAME_SIZE, hopSize = HOP_SIZE) {
160
+ const hopSec = hopSize / sampleRate;
161
+ const frameCount = Math.max(1, Math.floor(samples.length / hopSize) + 1);
162
+ const window = hannWindow(frameSize);
163
+ const bins = frameSize / 2 + 1;
164
+ const lowBins = Math.max(1, Math.min(bins, Math.round((LOW_BAND_HZ * frameSize) / sampleRate) + 1));
165
+ const flux = new Float64Array(frameCount);
166
+ const lowFlux = new Float64Array(frameCount);
167
+ const frame = new Float64Array(frameSize);
168
+ let prev = null;
169
+ for (let t = 0; t < frameCount; t++) {
170
+ const start = t * hopSize - frameSize / 2;
171
+ for (let i = 0; i < frameSize; i++) {
172
+ const index = start + i;
173
+ frame[i] = index >= 0 && index < samples.length ? samples[index] * window[i] : 0;
174
+ }
175
+ const mags = fftMagnitudes(frame);
176
+ if (prev !== null) {
177
+ let total = 0;
178
+ let low = 0;
179
+ for (let b = 0; b < bins; b++) {
180
+ const rise = mags[b] - prev[b];
181
+ if (rise > 0) {
182
+ total += rise;
183
+ if (b < lowBins)
184
+ low += rise;
185
+ }
186
+ }
187
+ flux[t] = total;
188
+ lowFlux[t] = low;
189
+ }
190
+ prev = mags;
191
+ }
192
+ return {
193
+ strength: subtractBaseline(flux, hopSec),
194
+ low: subtractBaseline(lowFlux, hopSec),
195
+ hopSec
196
+ };
197
+ }
198
+ /** Linear-interpolated envelope value at an arbitrary time. */
199
+ function sampleEnvelope(values, hopSec, timeSec) {
200
+ const position = timeSec / hopSec;
201
+ if (position <= 0)
202
+ return values.length > 0 ? values[0] : 0;
203
+ const i = Math.floor(position);
204
+ if (i >= values.length - 1)
205
+ return values.length > 0 ? values[values.length - 1] : 0;
206
+ const frac = position - i;
207
+ return values[i] * (1 - frac) + values[i + 1] * frac;
208
+ }
209
+ /**
210
+ * Small Gaussian blur of the envelope, for tempo estimation only.
211
+ *
212
+ * A hard one-frame onset spike makes the autocorrelation prefer whole-frame
213
+ * lags: at a true period of 21.53 frames the beat lag lands between samples
214
+ * and scores low, while the two-beat lag (43.06) lands almost on a frame and
215
+ * scores high — the estimator picks half tempo for a purely numeric reason.
216
+ * Widening each spike to ~3 frames makes the correlation continuous in lag.
217
+ */
218
+ function smoothForTempo(values, sigmaFrames) {
219
+ const radius = Math.max(1, Math.ceil(sigmaFrames * 3));
220
+ const kernel = [];
221
+ let sum = 0;
222
+ for (let d = -radius; d <= radius; d++) {
223
+ const w = Math.exp(-(d * d) / (2 * sigmaFrames * sigmaFrames));
224
+ kernel.push(w);
225
+ sum += w;
226
+ }
227
+ const smoothed = new Float64Array(values.length);
228
+ for (let i = 0; i < values.length; i++) {
229
+ let acc = 0;
230
+ for (let d = -radius; d <= radius; d++) {
231
+ const j = i + d;
232
+ if (j >= 0 && j < values.length)
233
+ acc += values[j] * kernel[d + radius];
234
+ }
235
+ smoothed[i] = acc / sum;
236
+ }
237
+ return smoothed;
238
+ }
239
+ /**
240
+ * Autocorrelation of the mean-removed envelope at a (possibly fractional) lag
241
+ * in frames, normalized by the overlap length so long lags are not penalized.
242
+ */
243
+ function autocorrelation(centred, hopSec, lagFrames) {
244
+ const n = centred.length;
245
+ const span = n - Math.ceil(lagFrames);
246
+ if (span <= 0)
247
+ return 0;
248
+ let sum = 0;
249
+ for (let t = 0; t < span; t++) {
250
+ sum += centred[t] * sampleEnvelope(centred, hopSec, (t + lagFrames) * hopSec);
251
+ }
252
+ return sum / span;
253
+ }
254
+ /**
255
+ * Tempo from the onset envelope: autocorrelation peak over 60-200 BPM, refined
256
+ * to a fractional lag, then octave-corrected. The refinement matters — at a
257
+ * 23ms hop the integer lags around 120 BPM are 117.4 and 123.0 BPM, so an
258
+ * integer-lag answer alone cannot be within 1 BPM of the truth.
259
+ */
260
+ export function detectTempo(env) {
261
+ const { hopSec } = env;
262
+ // sqrt-compress first: tempo is about WHERE the onsets are, not how loud.
263
+ // Uncompressed, a run of alternating loud/quiet onsets correlates better at
264
+ // two beats (loud-to-loud) than at one (loud-to-quiet), and the estimator
265
+ // reports half tempo for what is really a dynamics artefact.
266
+ const compressed = new Float64Array(env.strength.length);
267
+ for (let i = 0; i < compressed.length; i++)
268
+ compressed[i] = Math.sqrt(env.strength[i]);
269
+ const strength = smoothForTempo(compressed, TEMPO_SMOOTH_FRAMES);
270
+ const n = strength.length;
271
+ let mean = 0;
272
+ for (let i = 0; i < n; i++)
273
+ mean += strength[i];
274
+ mean /= Math.max(1, n);
275
+ const centred = new Float64Array(n);
276
+ for (let i = 0; i < n; i++)
277
+ centred[i] = strength[i] - mean;
278
+ const minLag = Math.max(2, Math.floor(60 / MAX_BPM / hopSec));
279
+ const maxLag = Math.min(n - 2, Math.ceil(60 / MIN_BPM / hopSec));
280
+ if (maxLag <= minLag)
281
+ return { bpm: MIN_BPM, score: 0, rawBpm: MIN_BPM };
282
+ let bestLag = minLag;
283
+ let bestScore = -Infinity;
284
+ for (let lag = minLag; lag <= maxLag; lag++) {
285
+ const score = autocorrelation(centred, hopSec, lag);
286
+ if (score > bestScore) {
287
+ bestScore = score;
288
+ bestLag = lag;
289
+ }
290
+ }
291
+ const refined = refineLag(centred, hopSec, bestLag, minLag, maxLag);
292
+ const rawBpm = 60 / (refined.lag * hopSec);
293
+ // Octave correction. Halving is unconditional (60-200 BPM halves into a
294
+ // plausible 90-100); doubling has to earn it against the peak's own score.
295
+ let bpm = rawBpm;
296
+ let score = refined.score;
297
+ if (rawBpm > OCTAVE_DOWN_ABOVE_BPM) {
298
+ const halved = refineLag(centred, hopSec, refined.lag * 2, minLag, maxLag);
299
+ bpm = 60 / (halved.lag * hopSec);
300
+ score = halved.score;
301
+ }
302
+ else if (rawBpm < OCTAVE_UP_BELOW_BPM) {
303
+ const doubled = refineLag(centred, hopSec, refined.lag / 2, minLag, maxLag);
304
+ if (doubled.score >= refined.score * OCTAVE_UP_TOLERANCE) {
305
+ bpm = 60 / (doubled.lag * hopSec);
306
+ score = doubled.score;
307
+ }
308
+ }
309
+ return { bpm, score, rawBpm };
310
+ }
311
+ /** Fine scan of the autocorrelation around a lag, at 1/20th of a frame. */
312
+ function refineLag(centred, hopSec, lagFrames, minLag, maxLag) {
313
+ const step = 0.05;
314
+ let bestLag = Math.min(maxLag, Math.max(minLag, lagFrames));
315
+ let bestScore = autocorrelation(centred, hopSec, bestLag);
316
+ for (let delta = -1; delta <= 1 + 1e-9; delta += step) {
317
+ const lag = lagFrames + delta;
318
+ if (lag < minLag || lag > maxLag)
319
+ continue;
320
+ const score = autocorrelation(centred, hopSec, lag);
321
+ if (score > bestScore) {
322
+ bestScore = score;
323
+ bestLag = lag;
324
+ }
325
+ }
326
+ return { lag: bestLag, score: bestScore };
327
+ }
328
+ // ---------------------------------------------------------------------------
329
+ // Phase and grid
330
+ // ---------------------------------------------------------------------------
331
+ /**
332
+ * With the period fixed, the offset within one period whose grid collects the
333
+ * most onset strength. Offsets are hop-sized; refineGrid takes it from there.
334
+ */
335
+ export function detectPhase(env, periodSec, durationSec) {
336
+ const steps = Math.max(1, Math.round(periodSec / env.hopSec));
337
+ const beatCount = Math.max(1, Math.floor(durationSec / periodSec) + 1);
338
+ let bestOffset = 0;
339
+ let bestScore = -Infinity;
340
+ for (let k = 0; k < steps; k++) {
341
+ const offset = (k * periodSec) / steps;
342
+ let score = 0;
343
+ for (let j = 0; j < beatCount; j++) {
344
+ const t = offset + j * periodSec;
345
+ if (t > durationSec)
346
+ break;
347
+ score += sampleEnvelope(env.strength, env.hopSec, t);
348
+ }
349
+ if (score > bestScore) {
350
+ bestScore = score;
351
+ bestOffset = offset;
352
+ }
353
+ }
354
+ return bestOffset;
355
+ }
356
+ /**
357
+ * Pull the grid onto the onsets it found. Each beat contributes the
358
+ * strength-weighted centroid of the envelope within a quarter beat of it, and
359
+ * a weighted least-squares line through those centroids gives period+phase at
360
+ * sub-hop resolution.
361
+ *
362
+ * This is not a nicety. The autocorrelation peak around the beat lag is a
363
+ * near-flat trapezoid — one beat of a 120 BPM track is 21.53 frames, and both
364
+ * lag 21 (123.0 BPM) and lag 22 (117.4 BPM) score about the same — so the
365
+ * tempo alone is only good to a couple of percent, and no hop-sized phase can
366
+ * be better than +-12ms.
367
+ *
368
+ * `lockPeriod` keeps a forced --bpm exactly as given and refines only the phase.
369
+ */
370
+ export function refineGrid(env, periodSec, phaseSec, durationSec, lockPeriod) {
371
+ // A period that is a couple of percent off walks the grid clean off the
372
+ // onsets by the end of a long track, and the fit then averages whatever
373
+ // happens to be nearby. So the horizon starts short — where the drift is
374
+ // still well under a quarter beat — and doubles once the period is sharper.
375
+ let period = periodSec;
376
+ let phase = phaseSec;
377
+ for (let horizon = periodSec * REFINE_FIRST_HORIZON_BEATS;; horizon *= 2) {
378
+ const span = Math.min(horizon, durationSec);
379
+ const fit = fitGrid(env, period, phase, span, lockPeriod);
380
+ period = fit.periodSec;
381
+ phase = fit.phaseSec;
382
+ if (span >= durationSec)
383
+ return { periodSec: period, phaseSec: phase };
384
+ }
385
+ }
386
+ /** One weighted least-squares pass over the beats up to `durationSec`. */
387
+ function fitGrid(env, periodSec, phaseSec, durationSec, lockPeriod) {
388
+ const window = periodSec * REFINE_WINDOW_BEATS;
389
+ const halfFrames = Math.max(1, Math.round(window / env.hopSec));
390
+ const indices = [];
391
+ const times = [];
392
+ const weights = [];
393
+ for (let j = 0;; j++) {
394
+ const t = phaseSec + j * periodSec;
395
+ if (t > durationSec)
396
+ break;
397
+ const centre = Math.round(t / env.hopSec);
398
+ let weight = 0;
399
+ let moment = 0;
400
+ for (let f = centre - halfFrames; f <= centre + halfFrames; f++) {
401
+ if (f < 0 || f >= env.strength.length)
402
+ continue;
403
+ const offset = f * env.hopSec - t;
404
+ // Taper to zero at the window edge. Untapered, onsets sitting exactly at
405
+ // +-a quarter beat (eighth notes, when the grid is on quarters) pull the
406
+ // centroid as hard as the beat itself and the phase can walk off.
407
+ const w = env.strength[f] * (1 - Math.abs(offset) / window);
408
+ if (w <= 0)
409
+ continue;
410
+ weight += w;
411
+ moment += w * offset;
412
+ }
413
+ if (weight <= 0)
414
+ continue;
415
+ indices.push(j);
416
+ times.push(t + moment / weight);
417
+ weights.push(weight);
418
+ }
419
+ if (indices.length < 4)
420
+ return { periodSec, phaseSec };
421
+ let sumW = 0;
422
+ let sumJ = 0;
423
+ let sumT = 0;
424
+ let sumJJ = 0;
425
+ let sumJT = 0;
426
+ for (let i = 0; i < indices.length; i++) {
427
+ const w = weights[i];
428
+ sumW += w;
429
+ sumJ += w * indices[i];
430
+ sumT += w * times[i];
431
+ sumJJ += w * indices[i] * indices[i];
432
+ sumJT += w * indices[i] * times[i];
433
+ }
434
+ if (lockPeriod)
435
+ return { periodSec, phaseSec: (sumT - periodSec * sumJ) / sumW };
436
+ const denominator = sumW * sumJJ - sumJ * sumJ;
437
+ if (Math.abs(denominator) < 1e-12)
438
+ return { periodSec, phaseSec };
439
+ const slope = (sumW * sumJT - sumJ * sumT) / denominator;
440
+ const intercept = (sumT - slope * sumJ) / sumW;
441
+ // A refit that wanders this far found a different pulse, not a better fit.
442
+ if (!Number.isFinite(slope) || Math.abs(slope - periodSec) > periodSec * REFINE_MAX_PERIOD_DRIFT) {
443
+ return { periodSec, phaseSec };
444
+ }
445
+ return { periodSec: slope, phaseSec: intercept };
446
+ }
447
+ /**
448
+ * Of the four candidate downbeat phases, the one whose beats carry the most
449
+ * kick-band onset energy. When the low band is flat (no drums, or a mix with
450
+ * nothing under 150Hz) the four scores are meaningless, so it falls back to
451
+ * total onset strength.
452
+ */
453
+ export function pickDownbeat(env, beatTimes) {
454
+ const scoreFor = (values) => Array.from({ length: BEATS_PER_BAR }, (_, phase) => {
455
+ let sum = 0;
456
+ for (let i = phase; i < beatTimes.length; i += BEATS_PER_BAR) {
457
+ sum += sampleEnvelope(values, env.hopSec, beatTimes[i]);
458
+ }
459
+ return sum;
460
+ });
461
+ const lowScores = scoreFor(env.low);
462
+ const maxLow = Math.max(...lowScores);
463
+ const minLow = Math.min(...lowScores);
464
+ const scores = maxLow > 0 && (maxLow - minLow) / maxLow >= LOW_BAND_FLAT_SPREAD ? lowScores : scoreFor(env.strength);
465
+ let best = 0;
466
+ for (let phase = 1; phase < BEATS_PER_BAR; phase++) {
467
+ if (scores[phase] > scores[best])
468
+ best = phase;
469
+ }
470
+ return best;
471
+ }
472
+ // ---------------------------------------------------------------------------
473
+ // Analysis
474
+ // ---------------------------------------------------------------------------
475
+ function round(value, digits) {
476
+ const factor = 10 ** digits;
477
+ return Math.round(value * factor) / factor;
478
+ }
479
+ function rmsDbfs(samples, fromSec, toSec, sampleRate) {
480
+ const start = Math.max(0, Math.floor(fromSec * sampleRate));
481
+ const end = Math.min(samples.length, Math.ceil(toSec * sampleRate));
482
+ if (end <= start)
483
+ return SILENCE_DBFS;
484
+ let sum = 0;
485
+ for (let i = start; i < end; i++)
486
+ sum += samples[i] * samples[i];
487
+ const rms = Math.sqrt(sum / (end - start));
488
+ return rms > 0 ? Math.max(SILENCE_DBFS, 20 * Math.log10(rms)) : SILENCE_DBFS;
489
+ }
490
+ /** The whole pipeline over decoded mono samples — no ffmpeg, no filesystem. */
491
+ export function analyzeBeatsFromSamples(samples, sampleRate, opts = {}) {
492
+ if (samples.length === 0)
493
+ throw new Error('cannot analyze an empty sample buffer');
494
+ if (opts.bpm !== undefined && (!Number.isFinite(opts.bpm) || opts.bpm <= 0)) {
495
+ throw new Error(`invalid forced bpm: ${opts.bpm}`);
496
+ }
497
+ const durationSec = samples.length / sampleRate;
498
+ const env = computeOnsetEnvelope(samples, sampleRate);
499
+ const forced = opts.bpm !== undefined;
500
+ const initialPeriod = forced ? 60 / opts.bpm : 60 / detectTempo(env).bpm;
501
+ // Two passes. A phase found against a tempo that is a couple of percent off
502
+ // drifts a whole beat over a long track and can settle on the offbeat, so
503
+ // the first pass exists to sharpen the period; the phase is then searched
504
+ // again against that period, and refined once more.
505
+ const coarse = refineGrid(env, initialPeriod, detectPhase(env, initialPeriod, durationSec), durationSec, forced);
506
+ const phase = detectPhase(env, coarse.periodSec, durationSec);
507
+ const grid = refineGrid(env, coarse.periodSec, phase, durationSec, forced);
508
+ const periodSec = grid.periodSec;
509
+ // Keep the first beat inside the first period; a refit can drag it negative.
510
+ let phaseSec = grid.phaseSec;
511
+ while (phaseSec < 0)
512
+ phaseSec += periodSec;
513
+ while (phaseSec >= periodSec)
514
+ phaseSec -= periodSec;
515
+ const beatTimes = [];
516
+ for (let j = 0;; j++) {
517
+ const t = phaseSec + j * periodSec;
518
+ if (t > durationSec)
519
+ break;
520
+ beatTimes.push(round(t, 6));
521
+ }
522
+ const downbeatIndex = beatTimes.length > 0 ? pickDownbeat(env, beatTimes) : 0;
523
+ const barTimes = [];
524
+ for (let i = downbeatIndex; i < beatTimes.length; i += BEATS_PER_BAR)
525
+ barTimes.push(beatTimes[i]);
526
+ const barSec = periodSec * BEATS_PER_BAR;
527
+ const energyPerBar = barTimes.map((start) => round(rmsDbfs(samples, start, Math.min(durationSec, start + barSec), sampleRate), 2));
528
+ let beatSum = 0;
529
+ for (const t of beatTimes)
530
+ beatSum += sampleEnvelope(env.strength, env.hopSec, t);
531
+ let overallSum = 0;
532
+ for (const value of env.strength)
533
+ overallSum += value;
534
+ const meanAtBeats = beatTimes.length > 0 ? beatSum / beatTimes.length : 0;
535
+ const meanOverall = env.strength.length > 0 ? overallSum / env.strength.length : 0;
536
+ const ratio = meanOverall > 0 ? meanAtBeats / meanOverall : 0;
537
+ const confidence = Math.max(0, Math.min(1, (ratio - 1) / (CONFIDENCE_FULL_RATIO - 1)));
538
+ return {
539
+ bpm: forced ? opts.bpm : round(60 / periodSec, 2),
540
+ confidence: round(confidence, 3),
541
+ beatTimes,
542
+ barTimes,
543
+ downbeatIndex,
544
+ durationSec: round(durationSec, 3),
545
+ energyPerBar
546
+ };
547
+ }
548
+ // ---------------------------------------------------------------------------
549
+ // ffmpeg decode
550
+ // ---------------------------------------------------------------------------
551
+ /**
552
+ * Playable seconds in an audio file. Asks for the audio stream's own duration
553
+ * first and falls back to the container's; throws if neither is a number.
554
+ */
555
+ export async function probeAudioDurationSec(filePath) {
556
+ const stdout = await captureFfmpegTool('ffprobe', [
557
+ '-v', 'error',
558
+ '-select_streams', 'a:0',
559
+ '-show_entries', 'stream=duration:format=duration',
560
+ '-of', 'default=noprint_wrappers=1:nokey=1',
561
+ filePath
562
+ ]);
563
+ for (const line of stdout.toString('utf-8').split('\n')) {
564
+ const value = Number(line.trim());
565
+ if (Number.isFinite(value) && value > 0)
566
+ return value;
567
+ }
568
+ throw new Error(`ffprobe reported no usable duration for ${filePath}`);
569
+ }
570
+ /**
571
+ * Decode any audio file to mono float samples at `sampleRate`.
572
+ *
573
+ * `-vn` plus an explicit `-map 0:a:0` matter: music files routinely carry
574
+ * embedded cover art as a video stream, and without both, ffmpeg tries to push
575
+ * that image into a PCM stream and fails.
576
+ */
577
+ export async function decodeMonoPcm(audioPath, sampleRate) {
578
+ const raw = await captureFfmpegTool('ffmpeg', [
579
+ '-v', 'error',
580
+ '-nostdin',
581
+ '-i', audioPath,
582
+ '-vn',
583
+ '-map', '0:a:0',
584
+ '-ac', '1',
585
+ '-ar', String(sampleRate),
586
+ '-acodec', 'pcm_s16le',
587
+ '-f', 's16le',
588
+ 'pipe:1'
589
+ ]);
590
+ const count = Math.floor(raw.length / 2);
591
+ const samples = new Float32Array(count);
592
+ for (let i = 0; i < count; i++)
593
+ samples[i] = raw.readInt16LE(i * 2) / 32768;
594
+ return samples;
595
+ }
596
+ export async function analyzeBeats(audioPath, opts = {}) {
597
+ const sampleRate = opts.sampleRate ?? BEAT_SAMPLE_RATE;
598
+ const samples = await decodeMonoPcm(audioPath, sampleRate);
599
+ if (samples.length === 0)
600
+ throw new Error(`decoded no audio from ${audioPath}`);
601
+ return analyzeBeatsFromSamples(samples, sampleRate, opts);
602
+ }
603
+ // ---------------------------------------------------------------------------
604
+ // Click track — the human verification path
605
+ // ---------------------------------------------------------------------------
606
+ const CLICK_SEC = 0.025;
607
+ const DOWNBEAT_HZ = 1500;
608
+ const BEAT_HZ = 900;
609
+ /** Music is ducked under the clicks so the grid is audible against the track. */
610
+ const CLICK_MUSIC_GAIN = 0.6;
611
+ function clickRecipe(freq) {
612
+ return {
613
+ dur: CLICK_SEC,
614
+ layers: [
615
+ { wave: 'sine', freq, gain: [0.9, 0] },
616
+ { wave: 'noise', gain: [0.25, 0] }
617
+ ]
618
+ };
619
+ }
620
+ /**
621
+ * The music with a click burst on every beat (downbeats an octave-ish up), as
622
+ * a 48kHz mono WAV. The grid is only verifiable by ear, so this file is the
623
+ * point of the whole `beats` command.
624
+ */
625
+ export async function renderClickTrack(audioPath, analysis) {
626
+ const music = await decodeMonoPcm(audioPath, SYNTH_SAMPLE_RATE);
627
+ const mix = new Float32Array(music.length);
628
+ for (let i = 0; i < music.length; i++)
629
+ mix[i] = music[i] * CLICK_MUSIC_GAIN;
630
+ const downbeatClick = synthesizeSamples(clickRecipe(DOWNBEAT_HZ));
631
+ const beatClick = synthesizeSamples(clickRecipe(BEAT_HZ));
632
+ analysis.beatTimes.forEach((time, index) => {
633
+ const isDownbeat = index >= analysis.downbeatIndex && (index - analysis.downbeatIndex) % BEATS_PER_BAR === 0;
634
+ const click = isDownbeat ? downbeatClick : beatClick;
635
+ const start = Math.round(time * SYNTH_SAMPLE_RATE);
636
+ for (let i = 0; i < click.length; i++) {
637
+ const target = start + i;
638
+ if (target >= mix.length)
639
+ break;
640
+ mix[target] = Math.max(-1, Math.min(1, mix[target] + click[i]));
641
+ }
642
+ });
643
+ return samplesToWav(mix);
644
+ }
645
+ const PRINTED_BARS = 8;
646
+ export async function runBeats(audioPath, options = {}) {
647
+ const resolved = path.resolve(audioPath);
648
+ if (!fs.existsSync(resolved))
649
+ throw new TrailerError(`Audio file not found: ${resolved}`);
650
+ const analysis = await analyzeBeats(resolved, options.bpm !== undefined ? { bpm: options.bpm } : {});
651
+ const barSpacing = (BEATS_PER_BAR * 60) / analysis.bpm;
652
+ out(`bpm: ${analysis.bpm}${options.bpm !== undefined ? ' (forced)' : ''}`);
653
+ out(`confidence: ${analysis.confidence.toFixed(3)}`);
654
+ out(`duration: ${analysis.durationSec.toFixed(2)}s | beats: ${analysis.beatTimes.length} | bars: ${analysis.barTimes.length}`);
655
+ out(`downbeatIndex: ${analysis.downbeatIndex} | bar spacing: ${barSpacing.toFixed(3)}s (4 x 60/bpm)`);
656
+ const bars = analysis.barTimes.slice(0, PRINTED_BARS);
657
+ out(`first ${bars.length} bar times: ${bars.map((t) => t.toFixed(3)).join(', ')}`);
658
+ out(`first ${bars.length} bar energies (dBFS): ${analysis.energyPerBar.slice(0, PRINTED_BARS).map((e) => e.toFixed(1)).join(', ')}`);
659
+ if (options.jsonPath !== undefined) {
660
+ const jsonPath = path.resolve(options.jsonPath);
661
+ fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
662
+ fs.writeFileSync(jsonPath, `${JSON.stringify(analysis, null, 2)}\n`);
663
+ out(`wrote ${jsonPath}`);
664
+ }
665
+ if (options.clickPath !== undefined) {
666
+ const clickPath = path.resolve(options.clickPath);
667
+ fs.mkdirSync(path.dirname(clickPath), { recursive: true });
668
+ fs.writeFileSync(clickPath, await renderClickTrack(resolved, analysis));
669
+ out(`wrote ${clickPath}`);
670
+ }
671
+ }
672
+ //# sourceMappingURL=music-beats.js.map