nodejs-audio-visualizer 5.2.0 → 5.4.0

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/src/highlight.ts CHANGED
@@ -9,9 +9,37 @@ export interface BeatFrameEvent {
9
9
  intensity: number;
10
10
  }
11
11
 
12
+ /**
13
+ * How far (seconds) a highlight boundary may move to land on a beat. The final length can
14
+ * therefore differ from `HIGHLIGHT_DURATION_SEC` by up to twice this value.
15
+ */
16
+ export const MAX_BEAT_SNAP_SEC = 1.5;
17
+ /** Beats per bar used when preferring highlight lengths made of whole bars. */
18
+ const BEATS_PER_BAR = 4;
19
+ /** Cost (in beats of boundary shift) for each beat the length is away from a whole bar. */
20
+ const OFF_BAR_PENALTY = 3;
21
+ /** Grid beats past the analyzed tempo region snap to a detected onset within this share of a beat. */
22
+ const GRID_ONSET_SNAP_SHARE = 0.2;
23
+ /** Seconds of audio compared before and after a frame to score it as a drop. */
24
+ export const DROP_WINDOW_SEC = 4;
25
+ /** Share of the track's biggest energy rise a peak needs to count as a drop. */
26
+ const MIN_DROP_SCORE_SHARE = 0.5;
27
+ /** With a tempo grid, a drop moves onto a beat within this share of a beat, so the hook ends on the beat. */
28
+ const DROP_BEAT_SNAP_SHARE = 0.2;
29
+
30
+ /** Tempo grid used to snap highlight cuts to beats (subset of `TempoEstimate`). */
31
+ export interface HighlightBeatGrid {
32
+ periodFrames: number;
33
+ phaseFrame: number;
34
+ /** Beat times in seconds from the start of the audio. */
35
+ beatsSec?: number[];
36
+ }
37
+
12
38
  export interface HighlightAudioSegment {
13
39
  seekSeconds: number;
14
40
  durationSeconds: number;
41
+ /** Silence played before the audio, for a lead-in reaching back past the track start. */
42
+ delaySeconds?: number;
15
43
  }
16
44
 
17
45
  /** One auto-highlight segment before concatenation (for separate output files). */
@@ -22,16 +50,27 @@ export interface HighlightRun {
22
50
  beatFrameIndices: number[];
23
51
  beatIntensities: number[];
24
52
  audioSegment: HighlightAudioSegment;
53
+ /** Frames from the run start to the detected drop (or window start); set when a lead-in was requested. */
54
+ leadInFrames?: number;
25
55
  }
26
56
 
57
+ /**
58
+ * `seg.startFrame` may be negative (a lead-in reaching back past the track start): those
59
+ * frames get silent spectrums and are played as silence before the audio.
60
+ */
27
61
  function buildHighlightRun(
28
62
  fps: number,
29
- seg: { startFrame: number; highlightFrames: number },
63
+ seg: { startFrame: number; highlightFrames: number; leadInFrames?: number },
30
64
  spectrums: number[][],
31
65
  beatEvents: BeatFrameEvent[],
32
66
  ): HighlightRun {
33
- const { startFrame, highlightFrames } = seg;
34
- const spectrumsSlice = spectrums.slice(startFrame, startFrame + highlightFrames);
67
+ const { startFrame, highlightFrames, leadInFrames } = seg;
68
+ const silentFrames = Math.max(0, -startFrame);
69
+ const silentSpectrum = new Array<number>(spectrums[0]?.length ?? 0).fill(0);
70
+ const spectrumsSlice = [
71
+ ...Array.from({ length: silentFrames }, () => silentSpectrum.slice()),
72
+ ...spectrums.slice(startFrame + silentFrames, startFrame + highlightFrames),
73
+ ];
35
74
  const beatsInWindow = beatEvents
36
75
  .filter(b => b.frameIndex >= startFrame && b.frameIndex < startFrame + highlightFrames)
37
76
  .sort((a, b) => a.frameIndex - b.frameIndex);
@@ -44,9 +83,11 @@ function buildHighlightRun(
44
83
  beatFrameIndices,
45
84
  beatIntensities,
46
85
  audioSegment: {
47
- seekSeconds: startFrame / fps,
48
- durationSeconds: highlightFrames / fps,
86
+ seekSeconds: (startFrame + silentFrames) / fps,
87
+ durationSeconds: (highlightFrames - silentFrames) / fps,
88
+ ...(silentFrames > 0 && { delaySeconds: silentFrames / fps }),
49
89
  },
90
+ ...(leadInFrames !== undefined && { leadInFrames }),
50
91
  };
51
92
  }
52
93
 
@@ -164,10 +205,247 @@ async function findBestHighlightStart(
164
205
  return bestStart;
165
206
  }
166
207
 
208
+ /**
209
+ * Scores each frame by the rise in mean energy from the `dropWindowFrames` before it to the
210
+ * `dropWindowFrames` after it, and picks the best drop, i.e. a frame whose rise is the biggest
211
+ * within `dropWindowFrames` on either side, such that the lead-in before it plus `mainFrames`
212
+ * after it fit in the track and miss the excluded ranges. Frames rather than beat candidates are
213
+ * scored so the drop lands on the actual energy onset (a beat grid can be off by a fraction of a
214
+ * beat), and only peaks count so a frame just past a drop is never taken for one. Peaks rising
215
+ * less than `MIN_DROP_SCORE_SHARE` of the biggest one are not drops. The window
216
+ * starts exactly `leadInFrames` before the drop, so the whole lead-in plays. Drops too close to
217
+ * the track start for the full lead-in are only used (with the window starting before the track,
218
+ * i.e. at a negative frame) when no other drop fits.
219
+ */
220
+ function findBestDropSegment(
221
+ energies: Float64Array,
222
+ totalFrames: number,
223
+ mainFrames: number,
224
+ leadInFrames: number,
225
+ dropWindowFrames: number,
226
+ excludeRanges: Array<{ start: number; endExclusive: number }>,
227
+ ): { startFrame: number; dropFrame: number } | null {
228
+ const prefix = new Float64Array(totalFrames + 1);
229
+ for (let i = 0; i < totalFrames; i++) {
230
+ prefix[i + 1] = prefix[i] + energies[i];
231
+ }
232
+ const meanEnergy = (from: number, to: number) => {
233
+ const a = Math.max(0, from);
234
+ const b = Math.min(totalFrames, to);
235
+ return b > a ? (prefix[b] - prefix[a]) / (b - a) : 0;
236
+ };
237
+ const scores = new Float64Array(totalFrames);
238
+ for (let f = 0; f < totalFrames; f++) {
239
+ scores[f] = meanEnergy(f, f + dropWindowFrames) - meanEnergy(f - dropWindowFrames, f);
240
+ }
241
+ const dropFrames: number[] = [];
242
+ for (let f = 1; f < totalFrames; f++) {
243
+ if (!(scores[f] > 0)) {
244
+ continue;
245
+ }
246
+ let isPeak = true;
247
+ const from = Math.max(0, f - dropWindowFrames);
248
+ const to = Math.min(totalFrames - 1, f + dropWindowFrames);
249
+ for (let g = from; g <= to && isPeak; g++) {
250
+ // Ties go to the earliest frame, where the rise starts.
251
+ isPeak = scores[g] < scores[f] || (scores[g] === scores[f] && g >= f);
252
+ }
253
+ if (isPeak) {
254
+ dropFrames.push(f);
255
+ }
256
+ }
257
+ const maxScore = dropFrames.reduce((max, f) => Math.max(max, scores[f]), 0);
258
+ const strongDropFrames = dropFrames.filter(f => scores[f] >= maxScore * MIN_DROP_SCORE_SHARE);
259
+
260
+ const findBest = (allowBeforeTrackStart: boolean) => {
261
+ let best: { startFrame: number; dropFrame: number } | null = null;
262
+ let bestScore = -Infinity;
263
+ for (const dropFrame of strongDropFrames) {
264
+ const startFrame = dropFrame - leadInFrames;
265
+ if (startFrame < 0 && !allowBeforeTrackStart) {
266
+ continue;
267
+ }
268
+ if (
269
+ dropFrame + mainFrames > totalFrames ||
270
+ rangesOverlap(startFrame, dropFrame + mainFrames, excludeRanges)
271
+ ) {
272
+ continue;
273
+ }
274
+ if (scores[dropFrame] > bestScore) {
275
+ bestScore = scores[dropFrame];
276
+ best = { startFrame, dropFrame };
277
+ }
278
+ }
279
+ return best;
280
+ };
281
+ return findBest(false) ?? findBest(true);
282
+ }
283
+
284
+ /**
285
+ * Moves a drop (and its window start with it) onto the nearest beat within `DROP_BEAT_SNAP_SHARE`
286
+ * of a beat. Only done with a tempo grid: raw onsets alone are too unreliable to override the
287
+ * energy onset. A lead-in that fit in the track is never pushed back past its start.
288
+ */
289
+ export function snapDropToBeat(
290
+ drop: { startFrame: number; dropFrame: number },
291
+ beatFrames: number[],
292
+ periodFrames?: number,
293
+ ): void {
294
+ if (!periodFrames || !(periodFrames > 0) || !isFinite(periodFrames)) {
295
+ return;
296
+ }
297
+ const beat = nearestOnset(beatFrames, drop.dropFrame);
298
+ if (beat === null || Math.abs(beat - drop.dropFrame) > periodFrames * DROP_BEAT_SNAP_SHARE) {
299
+ return;
300
+ }
301
+ const startFrame = drop.startFrame + (beat - drop.dropFrame);
302
+ if (startFrame < 0 && drop.startFrame >= 0) {
303
+ return;
304
+ }
305
+ drop.startFrame = startFrame;
306
+ drop.dropFrame = beat;
307
+ }
308
+
309
+ function nearestOnset(sortedOnsets: number[], frame: number): number | null {
310
+ let lo = 0;
311
+ let hi = sortedOnsets.length;
312
+ while (lo < hi) {
313
+ const mid = (lo + hi) >> 1;
314
+ if (sortedOnsets[mid] < frame) {
315
+ lo = mid + 1;
316
+ } else {
317
+ hi = mid;
318
+ }
319
+ }
320
+ let best: number | null = null;
321
+ for (const i of [lo - 1, lo]) {
322
+ if (i >= 0 && i < sortedOnsets.length) {
323
+ const v = sortedOnsets[i];
324
+ if (best === null || Math.abs(v - frame) < Math.abs(best - frame)) {
325
+ best = v;
326
+ }
327
+ }
328
+ }
329
+ return best;
330
+ }
331
+
332
+ /**
333
+ * Beat frames (sorted, unique, within [0, totalFrames]) where a highlight may start or end.
334
+ * With a tempo grid: tracked beats from the analyzed region, then the regular grid beyond it
335
+ * (each grid beat nudged onto a nearby detected onset to follow slight tempo drift).
336
+ * Without one: the detected onset beats.
337
+ */
338
+ export function buildBeatCandidates(
339
+ fps: number,
340
+ totalFrames: number,
341
+ beatEvents: BeatFrameEvent[],
342
+ beatGrid?: HighlightBeatGrid | null,
343
+ ): number[] {
344
+ const onsets = Array.from(new Set(beatEvents.map(b => b.frameIndex)))
345
+ .filter(f => f >= 0 && f <= totalFrames)
346
+ .sort((a, b) => a - b);
347
+ const period = beatGrid?.periodFrames ?? 0;
348
+ if (!beatGrid || !(period > 0) || !isFinite(period)) {
349
+ return onsets;
350
+ }
351
+
352
+ const frames = new Set<number>();
353
+ let lastTracked = -Infinity;
354
+ for (const t of beatGrid.beatsSec ?? []) {
355
+ const f = Math.round(t * fps);
356
+ if (f >= 0 && f <= totalFrames) {
357
+ frames.add(f);
358
+ lastTracked = Math.max(lastTracked, f);
359
+ }
360
+ }
361
+
362
+ const maxOnsetShift = period * GRID_ONSET_SNAP_SHARE;
363
+ const firstN = Math.ceil(-beatGrid.phaseFrame / period);
364
+ for (let n = firstN; ; n++) {
365
+ const gridFrame = beatGrid.phaseFrame + n * period;
366
+ if (gridFrame > totalFrames) {
367
+ break;
368
+ }
369
+ if (gridFrame <= lastTracked + period / 2) {
370
+ continue;
371
+ }
372
+ const onset = nearestOnset(onsets, gridFrame);
373
+ const f = onset !== null && Math.abs(onset - gridFrame) <= maxOnsetShift
374
+ ? onset
375
+ : Math.round(gridFrame);
376
+ if (f >= 0 && f <= totalFrames) {
377
+ frames.add(f);
378
+ }
379
+ }
380
+ return Array.from(frames).sort((a, b) => a - b);
381
+ }
167
382
 
168
383
  /**
169
- * Picks up to `segmentCount` non-overlapping 15s windows with highest summed spectral energy each,
170
- * sorts them chronologically, concatenates spectrums, and remaps beat indices.
384
+ * Moves a fixed-length window's start and end onto beats (each by at most `maxShiftFrames`),
385
+ * preferring lengths that span whole bars when the beat period is known.
386
+ * Boundaries without a nearby beat stay where they are.
387
+ */
388
+ export function snapSegmentToBeats(
389
+ seg: { startFrame: number; highlightFrames: number },
390
+ totalFrames: number,
391
+ beatFrames: number[],
392
+ maxShiftFrames: number,
393
+ periodFrames?: number,
394
+ /** Keep the start where it is and only move the end. */
395
+ lockStart = false,
396
+ ): { startFrame: number; highlightFrames: number } {
397
+ const rawStart = seg.startFrame;
398
+ const rawEnd = seg.startFrame + seg.highlightFrames;
399
+ const near = (target: number, max: number) =>
400
+ beatFrames.filter(f => Math.abs(f - target) <= maxShiftFrames && f >= 0 && f <= max);
401
+ const startCands = lockStart ? [rawStart] : near(rawStart, totalFrames - 1);
402
+ const endCands = near(rawEnd, totalFrames);
403
+ if (startCands.length === 0) {
404
+ startCands.push(rawStart);
405
+ }
406
+ if (endCands.length === 0) {
407
+ endCands.push(Math.min(rawEnd, totalFrames));
408
+ }
409
+
410
+ const period = periodFrames && periodFrames > 0 && isFinite(periodFrames) ? periodFrames : 0;
411
+ const shiftUnit = period > 0 ? period : Math.max(1, maxShiftFrames);
412
+ let best: { startFrame: number; endFrame: number } | null = null;
413
+ let bestCost = Infinity;
414
+ for (const s of startCands) {
415
+ for (const e of endCands) {
416
+ if (e - s < seg.highlightFrames / 2) {
417
+ continue;
418
+ }
419
+ let cost = (Math.abs(s - rawStart) + Math.abs(e - rawEnd)) / shiftUnit;
420
+ if (period > 0) {
421
+ const beats = Math.round((e - s) / period);
422
+ const offBar = Math.abs(beats - BEATS_PER_BAR * Math.round(beats / BEATS_PER_BAR));
423
+ cost += offBar * OFF_BAR_PENALTY;
424
+ }
425
+ if (cost < bestCost) {
426
+ bestCost = cost;
427
+ best = { startFrame: s, endFrame: e };
428
+ }
429
+ }
430
+ }
431
+ if (!best) {
432
+ return seg;
433
+ }
434
+ return { startFrame: best.startFrame, highlightFrames: best.endFrame - best.startFrame };
435
+ }
436
+
437
+ /**
438
+ * Picks up to `segmentCount` non-overlapping ~15s windows with highest summed spectral energy each,
439
+ * moves their boundaries onto beats (see `snapSegmentToBeats`), sorts them chronologically,
440
+ * concatenates spectrums, and remaps beat indices.
441
+ *
442
+ * With `leadInFrames` (e.g. a hook video's length), each window is instead built around a detected
443
+ * drop: it starts `leadInFrames` before the drop, so the whole lead-in plays and ends on the drop
444
+ * (see `HighlightRun.leadInFrames`), and runs ~15s past the drop, so the lead-in is added on top of
445
+ * the ~15s rather than taken out of it. With a tempo grid, the drop is moved onto a nearby beat
446
+ * (see `snapDropToBeat`), so the hook ends on the beat. When no drop fits, the lead-in is put before the energy
447
+ * window instead. The lead-in is never shortened: where it reaches back past the track start,
448
+ * the run starts at a negative frame and that part is silent (see `buildHighlightRun`).
171
449
  */
172
450
  export async function computeHighlightSlice(
173
451
  fps: number,
@@ -175,6 +453,8 @@ export async function computeHighlightSlice(
175
453
  spectrums: number[][],
176
454
  beatEvents: BeatFrameEvent[],
177
455
  segmentCount = 1,
456
+ beatGrid?: HighlightBeatGrid | null,
457
+ leadInFrames?: number,
178
458
  ): Promise<{
179
459
  startFrame: number;
180
460
  highlightFrames: number;
@@ -203,23 +483,21 @@ export async function computeHighlightSlice(
203
483
  }
204
484
 
205
485
  if (totalFrames <= highlightFramesTarget) {
206
- const audioSegments: HighlightAudioSegment[] = [
207
- {
208
- seekSeconds: 0,
209
- durationSeconds: totalFrames / fps,
210
- },
211
- ];
212
- const fullSeg = { startFrame: 0, highlightFrames: totalFrames };
486
+ // The whole track follows the lead-in, which is silent as it all comes before the track start.
487
+ const leadIn = leadInFrames !== undefined && leadInFrames > 0 ? leadInFrames : 0;
488
+ const fullSeg = leadIn > 0
489
+ ? { startFrame: -leadIn, highlightFrames: leadIn + totalFrames, leadInFrames: leadIn }
490
+ : { startFrame: 0, highlightFrames: totalFrames };
213
491
  const fullRun = buildHighlightRun(fps, fullSeg, spectrums, beatEvents);
214
492
  return {
215
- startFrame: 0,
216
- highlightFrames: totalFrames,
217
- spectrums: spectrums.slice(),
493
+ startFrame: fullRun.startFrame,
494
+ highlightFrames: fullRun.highlightFrames,
495
+ spectrums: fullRun.spectrums,
218
496
  beatFrameIndices: fullRun.beatFrameIndices,
219
497
  beatIntensities: fullRun.beatIntensities,
220
- audioSeekSeconds: 0,
221
- audioDurationSeconds: totalFrames / fps,
222
- audioSegments,
498
+ audioSeekSeconds: fullRun.audioSegment.seekSeconds,
499
+ audioDurationSeconds: fullRun.audioSegment.durationSeconds,
500
+ audioSegments: [fullRun.audioSegment],
223
501
  runs: [fullRun],
224
502
  };
225
503
  }
@@ -227,10 +505,48 @@ export async function computeHighlightSlice(
227
505
  const highlightFrames = highlightFramesTarget;
228
506
  const energies = await buildFrameEnergies(spectrums, totalFrames);
229
507
  const excludeRanges: Array<{ start: number; endExclusive: number }> = [];
230
- const rawSegments: Array<{ startFrame: number; highlightFrames: number }> = [];
508
+ const rawSegments: Array<{ startFrame: number; highlightFrames: number; leadInFrames?: number }> = [];
509
+ const beatFrames = buildBeatCandidates(fps, totalFrames, beatEvents, beatGrid);
510
+ const maxShiftFrames = Math.round(MAX_BEAT_SNAP_SEC * fps);
511
+ const useDrops = leadInFrames !== undefined && leadInFrames > 0;
512
+ const dropWindowFrames = Math.round(DROP_WINDOW_SEC * fps);
231
513
 
232
514
  const n = Math.max(1, Math.floor(segmentCount));
233
515
  for (let k = 0; k < n; k++) {
516
+ const drop = useDrops
517
+ ? findBestDropSegment(
518
+ energies,
519
+ totalFrames,
520
+ highlightFrames,
521
+ leadInFrames as number,
522
+ dropWindowFrames,
523
+ excludeRanges,
524
+ )
525
+ : null;
526
+ if (drop) {
527
+ snapDropToBeat(drop, beatFrames, beatGrid?.periodFrames);
528
+ // Only the part from the drop on is ~15s; its end is snapped to a beat.
529
+ const main = snapSegmentToBeats(
530
+ { startFrame: drop.dropFrame, highlightFrames },
531
+ totalFrames,
532
+ beatFrames,
533
+ maxShiftFrames,
534
+ beatGrid?.periodFrames,
535
+ true,
536
+ );
537
+ const dropLeadIn = drop.dropFrame - drop.startFrame;
538
+ rawSegments.push({
539
+ startFrame: drop.startFrame,
540
+ highlightFrames: dropLeadIn + main.highlightFrames,
541
+ leadInFrames: dropLeadIn,
542
+ });
543
+ excludeRanges.push({
544
+ start: drop.startFrame - maxShiftFrames,
545
+ endExclusive: main.startFrame + main.highlightFrames + maxShiftFrames,
546
+ });
547
+ await waitForEventLoop();
548
+ continue;
549
+ }
234
550
  const startFrame = await findBestHighlightStart(
235
551
  energies,
236
552
  totalFrames,
@@ -240,8 +556,28 @@ export async function computeHighlightSlice(
240
556
  if (startFrame === null) {
241
557
  break;
242
558
  }
243
- rawSegments.push({ startFrame, highlightFrames });
244
- excludeRanges.push({ start: startFrame, endExclusive: startFrame + highlightFrames });
559
+ const snapped = snapSegmentToBeats(
560
+ { startFrame, highlightFrames },
561
+ totalFrames,
562
+ beatFrames,
563
+ maxShiftFrames,
564
+ beatGrid?.periodFrames,
565
+ );
566
+ // The whole lead-in plays before the window, even over an earlier window (each highlight with
567
+ // a lead-in is its own output) or before the track start (as silence).
568
+ const windowLeadIn = useDrops ? leadInFrames as number : 0;
569
+ rawSegments.push(windowLeadIn > 0
570
+ ? {
571
+ startFrame: snapped.startFrame - windowLeadIn,
572
+ highlightFrames: windowLeadIn + snapped.highlightFrames,
573
+ leadInFrames: windowLeadIn,
574
+ }
575
+ : snapped);
576
+ // Padded so the next raw window, once snapped, cannot reach back into this one.
577
+ excludeRanges.push({
578
+ start: snapped.startFrame - maxShiftFrames,
579
+ endExclusive: snapped.startFrame + snapped.highlightFrames + maxShiftFrames,
580
+ });
245
581
  }
246
582
 
247
583
  rawSegments.sort((a, b) => a.startFrame - b.startFrame);
@@ -255,25 +591,15 @@ export async function computeHighlightSlice(
255
591
  const beatIntensities: number[] = [];
256
592
  let outOffset = 0;
257
593
 
258
- for (const seg of rawSegments) {
259
- const chunk = spectrums.slice(seg.startFrame, seg.startFrame + seg.highlightFrames);
260
- slicedSpectrums.push(...chunk);
261
-
262
- const beatsInSeg = beatEvents
263
- .filter(b => b.frameIndex >= seg.startFrame && b.frameIndex < seg.startFrame + seg.highlightFrames)
264
- .sort((a, b) => a.frameIndex - b.frameIndex);
265
- for (const b of beatsInSeg) {
266
- beatFrameIndices.push(b.frameIndex - seg.startFrame + outOffset);
267
- beatIntensities.push(b.intensity);
268
- }
269
- outOffset += seg.highlightFrames;
594
+ for (const run of runs) {
595
+ slicedSpectrums.push(...run.spectrums);
596
+ beatFrameIndices.push(...run.beatFrameIndices.map(frameIndex => frameIndex + outOffset));
597
+ beatIntensities.push(...run.beatIntensities);
598
+ outOffset += run.highlightFrames;
270
599
  }
271
600
 
272
601
  const totalHighlightFrames = slicedSpectrums.length;
273
- const audioSegments: HighlightAudioSegment[] = rawSegments.map(seg => ({
274
- seekSeconds: seg.startFrame / fps,
275
- durationSeconds: seg.highlightFrames / fps,
276
- }));
602
+ const audioSegments: HighlightAudioSegment[] = runs.map(run => run.audioSegment);
277
603
 
278
604
  const first = audioSegments[0] ?? { seekSeconds: 0, durationSeconds: 0 };
279
605