nodejs-audio-visualizer 5.0.0 → 5.1.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.
@@ -1,7 +1,7 @@
1
1
  import { expect } from 'chai';
2
2
  import { Writable, Readable, Pipe } from 'stream';
3
3
  import { EventEmitter } from 'events';
4
- import { spawnFfmpegVideoWriter, waitDrain, readVideoFrame, waitForProcessExit, spawnConcatVideoFrameReader } from '../video';
4
+ import { spawnFfmpegVideoWriter, waitDrain, readVideoFrame, waitForProcessExit, buildBeatSyncedSegments, getCutFrameIndices, selectAutoEditCutFrames, snapBeatsToTempoGrid, projectBeatsOntoTempoGrid, spawnConcatVideoFrameReader } from '../video';
5
5
  import { createSandbox, SinonStub } from 'sinon';
6
6
  import child_process, { ChildProcessWithoutNullStreams } from 'child_process';
7
7
 
@@ -282,4 +282,166 @@ describe('video', function () {
282
282
  expect(exitCode).equal(1);
283
283
  expect(reason).equal('waitForProcessExit timeout (5ms)');
284
284
  });
285
+
286
+ it('getCutFrameIndices skips the opening segment', function () {
287
+ const segments = buildBeatSyncedSegments(
288
+ [12, 24, 36],
289
+ 48,
290
+ [
291
+ { frameNumber: 0, pts: 0, ptsTime: 1 },
292
+ { frameNumber: 1, pts: 0, ptsTime: 4 },
293
+ { frameNumber: 2, pts: 0, ptsTime: 8 },
294
+ ],
295
+ 12,
296
+ 30,
297
+ { minCutIntervalSeconds: 0 },
298
+ );
299
+ expect(getCutFrameIndices(segments)).deep.equal([12, 24, 36]);
300
+ });
301
+
302
+ it('getCutFrameIndices is empty when auto-edit has no scene changes', function () {
303
+ const segments = buildBeatSyncedSegments([10, 20], 30, [], 8, 30);
304
+ expect(segments).deep.equal([
305
+ { outputStartFrame: 0, videoSeekSeconds: 0, frameCount: 30 },
306
+ ]);
307
+ expect(getCutFrameIndices(segments)).deep.equal([]);
308
+ });
309
+
310
+ it('selectAutoEditCutFrames keeps at least 2 seconds between cuts', function () {
311
+ const fps = 30;
312
+ const beats = [15, 30, 45, 60, 75, 90, 105, 120].map(frameIndex => ({ frameIndex }));
313
+ expect(selectAutoEditCutFrames(beats, fps, 150)).deep.equal([60, 120]);
314
+ });
315
+
316
+ it('selectAutoEditCutFrames prefers the strongest beat in the allowed window', function () {
317
+ const fps = 30;
318
+ const beats = [
319
+ { frameIndex: 60, intensity: 0.2 },
320
+ { frameIndex: 90, intensity: 0.9 },
321
+ { frameIndex: 100, intensity: 0.4 },
322
+ ];
323
+ expect(selectAutoEditCutFrames(beats, fps, 180)).deep.equal([90]);
324
+ });
325
+
326
+ it('buildBeatSyncedSegments spaces auto-edit cuts instead of cutting every beat', function () {
327
+ const segments = buildBeatSyncedSegments(
328
+ [15, 30, 45, 60, 75, 90, 105, 120],
329
+ 150,
330
+ [
331
+ { frameNumber: 0, pts: 0, ptsTime: 1 },
332
+ { frameNumber: 1, pts: 0, ptsTime: 4 },
333
+ ],
334
+ 12,
335
+ 30,
336
+ );
337
+ expect(getCutFrameIndices(segments)).deep.equal([60, 120]);
338
+ });
339
+
340
+ it('snapBeatsToTempoGrid snaps onsets within a quarter-beat to the grid', function () {
341
+ const snapped = snapBeatsToTempoGrid(
342
+ [
343
+ { frameIndex: 29, intensity: 0.8 },
344
+ { frameIndex: 50, intensity: 0.4 },
345
+ ],
346
+ { periodFrames: 14, phaseFrame: 0 },
347
+ 200,
348
+ );
349
+ expect(snapped.map(beat => beat.frameIndex)).deep.equal([28, 50]);
350
+ expect(snapped[0].intensity).equal(0.8);
351
+ });
352
+
353
+ it('projectBeatsOntoTempoGrid fills the grid and copies nearby onset intensity', function () {
354
+ const projected = projectBeatsOntoTempoGrid(
355
+ [
356
+ { frameIndex: 29, intensity: 0.8 },
357
+ { frameIndex: 50, intensity: 0.4 },
358
+ ],
359
+ { periodFrames: 14, phaseFrame: 0 },
360
+ 50,
361
+ );
362
+ expect(projected.map(beat => beat.frameIndex)).deep.equal([14, 28, 42]);
363
+ expect(projected.map(beat => beat.intensity)).deep.equal([0, 0.8, 0]);
364
+ });
365
+
366
+ it('buildBeatSyncedSegments uses 2-4 beat spacing when tempo is set', function () {
367
+ const fps = 30;
368
+ const bpm = 90;
369
+ const periodFrames = fps * 60 / bpm;
370
+ const segments = buildBeatSyncedSegments(
371
+ [20, 40, 60, 80, 100, 120, 140, 160],
372
+ 200,
373
+ [
374
+ { frameNumber: 0, pts: 0, ptsTime: 1 },
375
+ { frameNumber: 1, pts: 0, ptsTime: 4 },
376
+ ],
377
+ 12,
378
+ fps,
379
+ { tempo: { bpm, periodFrames, phaseFrame: 0 } },
380
+ );
381
+ expect(getCutFrameIndices(segments)).deep.equal([40, 80, 120, 160]);
382
+ });
383
+
384
+ it('buildBeatSyncedSegments places scene cuts on the BPM grid', function () {
385
+ const fps = 30;
386
+ const bpm = 128;
387
+ const periodFrames = 14;
388
+ const totalFrames = 180;
389
+ const segments = buildBeatSyncedSegments(
390
+ [57, 113],
391
+ totalFrames,
392
+ [
393
+ { frameNumber: 0, pts: 0, ptsTime: 1 },
394
+ { frameNumber: 1, pts: 0, ptsTime: 4 },
395
+ ],
396
+ 12,
397
+ fps,
398
+ { tempo: { bpm, periodFrames, phaseFrame: 0 } },
399
+ );
400
+ const cuts = getCutFrameIndices(segments);
401
+ expect(cuts.length).greaterThan(0);
402
+ for (const cut of cuts) {
403
+ expect(cut % periodFrames).equal(0);
404
+ }
405
+ });
406
+
407
+ it('buildBeatSyncedSegments cuts on the BPM grid even without detected onsets', function () {
408
+ const fps = 30;
409
+ const bpm = 90;
410
+ const periodFrames = fps * 60 / bpm;
411
+ const segments = buildBeatSyncedSegments(
412
+ [],
413
+ 200,
414
+ [
415
+ { frameNumber: 0, pts: 0, ptsTime: 1 },
416
+ { frameNumber: 1, pts: 0, ptsTime: 4 },
417
+ ],
418
+ 12,
419
+ fps,
420
+ { tempo: { bpm, periodFrames, phaseFrame: 0 } },
421
+ );
422
+ expect(getCutFrameIndices(segments)).deep.equal([40, 80, 120, 160]);
423
+ });
424
+
425
+ it('buildBeatSyncedSegments prefers the strongest on-grid beat in the cut window', function () {
426
+ const fps = 30;
427
+ const bpm = 90;
428
+ const periodFrames = fps * 60 / bpm;
429
+ const beats = [20, 40, 60, 80, 100, 120, 140, 160];
430
+ const intensities = [0.1, 0.1, 0.9, 0.1, 0.1, 0.8, 0.1, 0.1];
431
+ const segments = buildBeatSyncedSegments(
432
+ beats,
433
+ 160,
434
+ [
435
+ { frameNumber: 0, pts: 0, ptsTime: 1 },
436
+ { frameNumber: 1, pts: 0, ptsTime: 4 },
437
+ ],
438
+ 12,
439
+ fps,
440
+ {
441
+ beatIntensities: intensities,
442
+ tempo: { bpm, periodFrames, phaseFrame: 0 },
443
+ },
444
+ );
445
+ expect(getCutFrameIndices(segments)).deep.equal([60, 120]);
446
+ });
285
447
  });
package/src/typings.d.ts CHANGED
@@ -4,4 +4,26 @@ declare module 'fft-js' {
4
4
  fftFreq(phasors: [number, number][], sampleRate: number): number[];
5
5
  fftMag(phasors: [number, number][]): number[];
6
6
  };
7
+ }
8
+
9
+ declare module 'music-tempo' {
10
+ export interface MusicTempoParams {
11
+ bufferSize?: number;
12
+ hopSize?: number;
13
+ timeStep?: number;
14
+ decayRate?: number;
15
+ peakFindingWindow?: number;
16
+ meanWndMultiplier?: number;
17
+ peakThreshold?: number;
18
+ minBeatInterval?: number;
19
+ maxBeatInterval?: number;
20
+ expiryTime?: number;
21
+ }
22
+
23
+ export default class MusicTempo {
24
+ constructor(audioData: number[] | Float32Array, params?: MusicTempoParams);
25
+ tempo: string | number;
26
+ beats: number[];
27
+ beatInterval: number;
28
+ }
7
29
  }
package/src/video.ts CHANGED
@@ -5,6 +5,7 @@ import { resolve as resolvePath, join as joinPath } from 'path';
5
5
  import { tmpdir } from 'os';
6
6
  import ffmpegPath from 'ffmpeg-static';
7
7
  import { defaults } from './config';
8
+ import { TempoEstimate, beatGridFrameIndices } from './beats';
8
9
 
9
10
  export interface AudioMuxSegment {
10
11
  seekSeconds: number;
@@ -426,11 +427,156 @@ const mergeSmallScenes = (sceneChanges: SceneChange[], videoDuration: number, mi
426
427
  return merged;
427
428
  };
428
429
 
430
+ /** Minimum time between auto-edit cuts; cuts still land on beats. */
431
+ export const DEFAULT_AUTO_EDIT_MIN_CUT_INTERVAL_SECONDS = 2;
432
+ /** Window after the minimum gap in which the strongest beat is chosen. */
433
+ export const DEFAULT_AUTO_EDIT_MAX_CUT_INTERVAL_SECONDS = 4;
434
+ /** Half a bar of 4/4, used as the minimum gap when tempo is known. */
435
+ export const AUTO_EDIT_MIN_CUT_BEATS = 2;
436
+ /** One bar of 4/4, used as the maximum gap when tempo is known. */
437
+ export const AUTO_EDIT_MAX_CUT_BEATS = 4;
438
+ /** Snap an onset to the tempo grid when it is within this fraction of a beat. */
439
+ export const BEAT_SNAP_TOLERANCE = 0.25;
440
+
441
+ export const cutIntervalSecondsFromTempo = (bpm: number) => ({
442
+ minCutIntervalSeconds: AUTO_EDIT_MIN_CUT_BEATS * (60 / bpm),
443
+ maxCutIntervalSeconds: AUTO_EDIT_MAX_CUT_BEATS * (60 / bpm),
444
+ });
445
+
446
+ export const snapBeatsToTempoGrid = (
447
+ beats: Array<{ frameIndex: number; intensity?: number }>,
448
+ tempo: Pick<TempoEstimate, 'periodFrames' | 'phaseFrame'>,
449
+ totalFrames: number,
450
+ ): Array<{ frameIndex: number; intensity?: number }> => {
451
+ const period = tempo.periodFrames;
452
+ if (!(period > 0)) {
453
+ return beats.slice();
454
+ }
455
+ const maxSnap = Math.max(1, period * BEAT_SNAP_TOLERANCE);
456
+ const merged: Array<{ frameIndex: number; intensity?: number }> = [];
457
+ const push = (beat: { frameIndex: number; intensity?: number }) => {
458
+ for (let i = 0; i < merged.length; i++) {
459
+ if (merged[i].frameIndex === beat.frameIndex) {
460
+ if ((beat.intensity ?? 0) > (merged[i].intensity ?? 0)) {
461
+ merged[i] = beat;
462
+ }
463
+ return;
464
+ }
465
+ }
466
+ merged.push(beat);
467
+ };
468
+ for (const beat of beats) {
469
+ const n = Math.round((beat.frameIndex - tempo.phaseFrame) / period);
470
+ const grid = Math.round(tempo.phaseFrame + n * period);
471
+ const dist = Math.abs(beat.frameIndex - grid);
472
+ if (dist <= maxSnap && grid > 0 && grid < totalFrames) {
473
+ push({ frameIndex: grid, intensity: beat.intensity });
474
+ } else {
475
+ push(beat);
476
+ }
477
+ }
478
+ return merged.sort((a, b) => a.frameIndex - b.frameIndex);
479
+ };
480
+
481
+ /** Every BPM-grid beat, with onset intensity copied onto the nearest grid frame. */
482
+ export const projectBeatsOntoTempoGrid = (
483
+ beats: Array<{ frameIndex: number; intensity?: number }>,
484
+ tempo: Pick<TempoEstimate, 'periodFrames' | 'phaseFrame'>,
485
+ totalFrames: number,
486
+ ): Array<{ frameIndex: number; intensity?: number }> => {
487
+ const grid = beatGridFrameIndices(tempo, totalFrames);
488
+ if (grid.length === 0) {
489
+ return [];
490
+ }
491
+ const period = tempo.periodFrames;
492
+ const maxSnap = Math.max(1, period * BEAT_SNAP_TOLERANCE);
493
+ const intensityByFrame = new Map<number, number>();
494
+ for (const beat of beats) {
495
+ const n = Math.round((beat.frameIndex - tempo.phaseFrame) / period);
496
+ const gridFrame = Math.round(tempo.phaseFrame + n * period);
497
+ if (Math.abs(beat.frameIndex - gridFrame) > maxSnap) {
498
+ continue;
499
+ }
500
+ if (gridFrame <= 0 || gridFrame >= totalFrames) {
501
+ continue;
502
+ }
503
+ const intensity = beat.intensity ?? 0;
504
+ if (intensity >= (intensityByFrame.get(gridFrame) ?? 0)) {
505
+ intensityByFrame.set(gridFrame, intensity);
506
+ }
507
+ }
508
+ return grid.map(frameIndex => ({
509
+ frameIndex,
510
+ intensity: intensityByFrame.get(frameIndex) ?? 0,
511
+ }));
512
+ };
513
+
514
+ export const selectAutoEditCutFrames = (
515
+ beats: Array<{ frameIndex: number; intensity?: number }>,
516
+ fps: number,
517
+ totalFrames: number,
518
+ minIntervalSeconds = DEFAULT_AUTO_EDIT_MIN_CUT_INTERVAL_SECONDS,
519
+ maxIntervalSeconds = DEFAULT_AUTO_EDIT_MAX_CUT_INTERVAL_SECONDS,
520
+ ): number[] => {
521
+ const sorted = beats
522
+ .filter(beat => beat.frameIndex > 0 && beat.frameIndex < totalFrames)
523
+ .sort((a, b) => a.frameIndex - b.frameIndex);
524
+ if (sorted.length === 0 || fps <= 0) {
525
+ return [];
526
+ }
527
+ if (minIntervalSeconds <= 0) {
528
+ return sorted.map(beat => beat.frameIndex);
529
+ }
530
+
531
+ const minGap = Math.max(1, Math.round(fps * minIntervalSeconds));
532
+ const maxGap = Math.max(minGap, Math.round(fps * maxIntervalSeconds));
533
+ const selected: number[] = [];
534
+ let lastCut = 0;
535
+
536
+ while (lastCut + minGap < totalFrames) {
537
+ const windowStart = lastCut + minGap;
538
+ const windowEnd = Math.min(lastCut + maxGap, totalFrames);
539
+
540
+ let best: { frameIndex: number; intensity?: number } | undefined;
541
+ let nextAfterWindow: { frameIndex: number; intensity?: number } | undefined;
542
+
543
+ for (const beat of sorted) {
544
+ if (beat.frameIndex < windowStart) {
545
+ continue;
546
+ }
547
+ if (beat.frameIndex >= windowEnd) {
548
+ nextAfterWindow = beat;
549
+ break;
550
+ }
551
+ const intensity = beat.intensity ?? 0;
552
+ if (!best || intensity > (best.intensity ?? 0)) {
553
+ best = beat;
554
+ }
555
+ }
556
+
557
+ const chosen = best ?? nextAfterWindow;
558
+ if (!chosen) {
559
+ break;
560
+ }
561
+ selected.push(chosen.frameIndex);
562
+ lastCut = chosen.frameIndex;
563
+ }
564
+
565
+ return selected;
566
+ };
567
+
429
568
  export const buildBeatSyncedSegments = (
430
569
  beatFrameIndices: number[],
431
570
  totalFrames: number,
432
571
  sceneChanges: SceneChange[],
433
572
  videoDuration: number,
573
+ fps: number,
574
+ options?: {
575
+ beatIntensities?: number[];
576
+ minCutIntervalSeconds?: number;
577
+ maxCutIntervalSeconds?: number;
578
+ tempo?: TempoEstimate;
579
+ },
434
580
  ): VideoSegment[] => {
435
581
  if (sceneChanges.length === 0) {
436
582
  return [
@@ -451,7 +597,27 @@ export const buildBeatSyncedSegments = (
451
597
  seekPositions = Array.from({ length: count }, (_, i) => (i * videoDuration) / count);
452
598
  }
453
599
 
454
- const boundaries = [0, ...beatFrameIndices];
600
+ const tempo = options?.tempo;
601
+ const tempoIntervals = tempo && tempo.bpm > 0
602
+ ? cutIntervalSecondsFromTempo(tempo.bpm)
603
+ : undefined;
604
+ let beats: Array<{ frameIndex: number; intensity?: number }> = beatFrameIndices.map((frameIndex, i) => ({
605
+ frameIndex,
606
+ intensity: options?.beatIntensities?.[i],
607
+ }));
608
+ if (tempo) {
609
+ beats = projectBeatsOntoTempoGrid(beats, tempo, totalFrames);
610
+ }
611
+
612
+ const cutBeats = selectAutoEditCutFrames(
613
+ beats,
614
+ fps,
615
+ totalFrames,
616
+ options?.minCutIntervalSeconds ?? tempoIntervals?.minCutIntervalSeconds,
617
+ options?.maxCutIntervalSeconds ?? tempoIntervals?.maxCutIntervalSeconds,
618
+ );
619
+
620
+ const boundaries = [0, ...cutBeats];
455
621
  if (boundaries[boundaries.length - 1] !== totalFrames) {
456
622
  boundaries.push(totalFrames);
457
623
  }
@@ -468,6 +634,12 @@ export const buildBeatSyncedSegments = (
468
634
  return segments;
469
635
  };
470
636
 
637
+ export const getCutFrameIndices = (segments: VideoSegment[]): number[] =>
638
+ segments
639
+ .slice(1)
640
+ .map(segment => segment.outputStartFrame)
641
+ .filter(frameIndex => frameIndex > 0);
642
+
471
643
  export const writeConcatFile = (
472
644
  segments: VideoSegment[],
473
645
  videoPath: string,