nodejs-audio-visualizer 4.5.1 → 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.
package/src/image.ts CHANGED
@@ -593,6 +593,104 @@ export const getVideoFrameColor = (bgrBuffer: Buffer, width: number, height: num
593
593
  export const invertColor = (color: Color) =>
594
594
  ({ red: 255 - color.red, green: 255 - color.green, blue: 255 - color.blue });
595
595
 
596
+ /** Pixel shift strength for the cut frame and the frames after it. */
597
+ export const CUT_SHAKE_AMPLITUDE_BY_OFFSET = [14, 9, 5, 3, 1] as const;
598
+ export const CUT_SHAKE_PEAK_AMPLITUDE = 14;
599
+ export const MIN_CUT_SHAKE_FRAMES = 3;
600
+ export const SHAKE_DURATION_BEAT_FRACTION = 1 / 3;
601
+ /** Camera shake fires on every Nth beat of the tempo grid (half a bar of 4/4). */
602
+ export const CAMERA_SHAKE_EVERY_BEATS = 2;
603
+
604
+ export const buildCutShakeAmplitudes = (periodFrames: number): number[] => {
605
+ if (!(periodFrames > 0)) {
606
+ return Array.from(CUT_SHAKE_AMPLITUDE_BY_OFFSET);
607
+ }
608
+ const frameCount = Math.max(
609
+ MIN_CUT_SHAKE_FRAMES,
610
+ Math.round(periodFrames * SHAKE_DURATION_BEAT_FRACTION),
611
+ );
612
+ if (frameCount === 1) {
613
+ return [CUT_SHAKE_PEAK_AMPLITUDE];
614
+ }
615
+ const decayTo = 1 / CUT_SHAKE_PEAK_AMPLITUDE;
616
+ const amplitudes: number[] = [];
617
+ for (let i = 0; i < frameCount; i++) {
618
+ const t = i / (frameCount - 1);
619
+ amplitudes.push(Math.max(1, Math.round(CUT_SHAKE_PEAK_AMPLITUDE * Math.pow(decayTo, t))));
620
+ }
621
+ return amplitudes;
622
+ };
623
+
624
+ const clampInt = (value: number, min: number, max: number) => {
625
+ if (value < min) {
626
+ return min;
627
+ }
628
+ if (value > max) {
629
+ return max;
630
+ }
631
+ return value;
632
+ };
633
+
634
+ export const getCutShakeOffset = (
635
+ frameIndex: number,
636
+ cutFrames: ReadonlySet<number>,
637
+ amplitudes: ReadonlyArray<number> = CUT_SHAKE_AMPLITUDE_BY_OFFSET,
638
+ ): { x: number; y: number } => {
639
+ if (amplitudes.length === 0) {
640
+ return { x: 0, y: 0 };
641
+ }
642
+ let framesAfterCut = -1;
643
+ let originCut = -1;
644
+ for (let offset = 0; offset < amplitudes.length; offset++) {
645
+ const cut = frameIndex - offset;
646
+ if (cutFrames.has(cut) && (framesAfterCut === -1 || offset < framesAfterCut)) {
647
+ framesAfterCut = offset;
648
+ originCut = cut;
649
+ }
650
+ }
651
+ if (framesAfterCut === -1) {
652
+ return { x: 0, y: 0 };
653
+ }
654
+ const amplitude = amplitudes[framesAfterCut];
655
+ const angle = originCut * 2.399 + framesAfterCut * 2.1;
656
+ return {
657
+ x: Math.round(Math.cos(angle) * amplitude),
658
+ y: Math.round(Math.sin(angle) * amplitude),
659
+ };
660
+ };
661
+
662
+ export const applyCameraShake = (
663
+ imageDstBuffer: EncodedBmp,
664
+ width: number,
665
+ height: number,
666
+ offsetX: number,
667
+ offsetY: number,
668
+ ) => {
669
+ const ox = Math.round(offsetX);
670
+ const oy = Math.round(offsetY);
671
+ if (ox === 0 && oy === 0) {
672
+ return;
673
+ }
674
+ const { shiftPos, rowBytes, data } = imageDstBuffer;
675
+ const pixelBytes = height * rowBytes;
676
+ const sourcePixels = Buffer.allocUnsafe(pixelBytes);
677
+ data.copy(sourcePixels, 0, shiftPos, shiftPos + pixelBytes);
678
+
679
+ for (let y = 0; y < height; y++) {
680
+ const srcY = clampInt(y - oy, 0, height - 1);
681
+ const dstRow = shiftPos + y * rowBytes;
682
+ const srcRow = srcY * rowBytes;
683
+ for (let x = 0; x < width; x++) {
684
+ const srcX = clampInt(x - ox, 0, width - 1);
685
+ const dstIndex = dstRow + x * 3;
686
+ const srcIndex = srcRow + srcX * 3;
687
+ data[dstIndex] = sourcePixels[srcIndex];
688
+ data[dstIndex + 1] = sourcePixels[srcIndex + 1];
689
+ data[dstIndex + 2] = sourcePixels[srcIndex + 2];
690
+ }
691
+ }
692
+ };
693
+
596
694
  const hexToRgb = (hex: String) => {
597
695
  const red = parseInt(hex.substring(1, 3), 16);
598
696
  const green = parseInt(hex.substring(3, 5), 16);
package/src/index.ts CHANGED
@@ -36,13 +36,13 @@ import {
36
36
  getAudioAutoHighlight,
37
37
  getAudioAutoHighlightCount,
38
38
  } from './config';
39
- import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor } from './audio';
40
- import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps } from './image';
39
+ import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor, pcmU8ToFloatSamples } from './audio';
40
+ import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps, applyCameraShake, getCutShakeOffset, buildCutShakeAmplitudes, CAMERA_SHAKE_EVERY_BEATS } from './image';
41
41
  import { normalizeInlineSubtitlesToSrt, lrcToSrt } from './subtitleConvert';
42
- import { spawnFfmpegVideoWriter, waitDrain, waitForProcessExit, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile } from './video';
42
+ import { spawnFfmpegVideoWriter, waitDrain, waitForProcessExit, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, getCutFrameIndices, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile } from './video';
43
43
  import { createBpmEncoder, createBgrFrameEncoder, EncodedBmp } from './bpmEncoder';
44
- import { createBeatDetector } from './beats';
45
- export { BeatInfo, BeatDetectorOptions } from './beats';
44
+ import { createBeatDetector, estimateTempo, TempoEstimate, beatGridFrameIndices, tempoForWindow, MAX_TEMPO_ANALYSIS_SECONDS } from './beats';
45
+ export { BeatInfo, BeatDetectorOptions, TempoEstimate, estimateTempo, beatGridFrameIndices, shiftTempoPhase, tempoForWindow } from './beats';
46
46
  import { computeHighlightSlice, HIGHLIGHT_DURATION_SEC } from './highlight';
47
47
  import { waitForEventLoop } from './waitForEventLoop';
48
48
  export { computeHighlightSlice, HIGHLIGHT_DURATION_SEC, BeatFrameEvent, HighlightAudioSegment, HighlightRun } from './highlight';
@@ -138,7 +138,32 @@ const createVisualizerFrameGenerator = (
138
138
  defaultColor: Color,
139
139
  spectrumBusMargin: number
140
140
  ): (params: CommonVisualizerFrameProps) => EncodedBmp => {
141
- if (!config.outVideo.spectrum) {
141
+ if (config.outVideo.spectrum) {
142
+ const createSpectrumVisualizerFrame = createSpectrumVisualizerFrameGenerator();
143
+ const spectrumWidth = getSpectrumWidthAbsolute(config, backgroundWidth);
144
+ const spectrumHeight = getSpectrumHeightAbsolute(config, backgroundHeight);
145
+ const spectrumX = getSpectrumXAbsolute(config, spectrumWidth, backgroundWidth);
146
+ const spectrumY = getSpectrumYAbsolute(config, spectrumHeight, backgroundHeight);
147
+ const spectrumRotation = getSpectrumRotation(config);
148
+ const spectrumColor = getSpectrumColor(config) || invertColor(defaultColor);
149
+ const spectrumEffect = getSpectrumEffect(config);
150
+ const spectrumOpacity = getSpectrumOpacityParsed(config);
151
+
152
+ return (params: CommonVisualizerFrameProps) => {
153
+ return createSpectrumVisualizerFrame({
154
+ ...params,
155
+ size: { width: spectrumWidth, height: spectrumHeight },
156
+ position: { x: spectrumX, y: spectrumY },
157
+ rotation: spectrumRotation,
158
+ margin: spectrumBusMargin,
159
+ color: spectrumColor,
160
+ opacity: spectrumOpacity,
161
+ spectrumEffect,
162
+ });
163
+ };
164
+ }
165
+
166
+ if (config.outVideo.polar) {
142
167
  const createPolarVisualizerFrame = createPolarVisualizerFrameGenerator();
143
168
  const polarX = getPolarXAbsolute(config, backgroundWidth);
144
169
  const polarY = getPolarYAbsolute(config, backgroundHeight);
@@ -163,28 +188,8 @@ const createVisualizerFrameGenerator = (
163
188
  });
164
189
  };
165
190
  }
166
- const createSpectrumVisualizerFrame = createSpectrumVisualizerFrameGenerator();
167
- const spectrumWidth = getSpectrumWidthAbsolute(config, backgroundWidth);
168
- const spectrumHeight = getSpectrumHeightAbsolute(config, backgroundHeight);
169
- const spectrumX = getSpectrumXAbsolute(config, spectrumWidth, backgroundWidth);
170
- const spectrumY = getSpectrumYAbsolute(config, spectrumHeight, backgroundHeight);
171
- const spectrumRotation = getSpectrumRotation(config);
172
- const spectrumColor = getSpectrumColor(config) || invertColor(defaultColor);
173
- const spectrumEffect = getSpectrumEffect(config);
174
- const spectrumOpacity = getSpectrumOpacityParsed(config);
175
-
176
- return (params: CommonVisualizerFrameProps) => {
177
- return createSpectrumVisualizerFrame({
178
- ...params,
179
- size: { width: spectrumWidth, height: spectrumHeight },
180
- position: { x: spectrumX, y: spectrumY },
181
- rotation: spectrumRotation,
182
- margin: spectrumBusMargin,
183
- color: spectrumColor,
184
- opacity: spectrumOpacity,
185
- spectrumEffect,
186
- });
187
- };
191
+
192
+ return (params: CommonVisualizerFrameProps) => params.backgroundImageBuffer;
188
193
  };
189
194
 
190
195
  interface PreProcessedAudio {
@@ -261,11 +266,13 @@ async function prepareBackgroundForRender(params: {
261
266
  backgroundVideoPath: string | undefined;
262
267
  backgroundImagePath: string | undefined;
263
268
  beatFrameIndices: number[];
269
+ beatIntensities?: number[];
264
270
  framesCount: number;
265
271
  outputResolution: ReturnType<typeof getOutputResolution>;
266
272
  fps: number;
267
273
  autoEditVideo: boolean;
268
274
  readVideoFrameTimeout: number;
275
+ tempo?: TempoEstimate | null;
269
276
  }): Promise<{
270
277
  backgroundWidth: number;
271
278
  backgroundHeight: number;
@@ -275,17 +282,20 @@ async function prepareBackgroundForRender(params: {
275
282
  videoFrameSize: number;
276
283
  encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
277
284
  concatFilePath?: string;
285
+ cutFrameIndices: number[];
278
286
  }> {
279
287
  const {
280
288
  useVideoBackground,
281
289
  backgroundVideoPath,
282
290
  backgroundImagePath,
283
291
  beatFrameIndices,
292
+ beatIntensities,
284
293
  framesCount,
285
294
  outputResolution,
286
295
  fps,
287
296
  autoEditVideo,
288
297
  readVideoFrameTimeout,
298
+ tempo,
289
299
  } = params;
290
300
 
291
301
  if (useVideoBackground && backgroundVideoPath) {
@@ -304,9 +314,15 @@ async function prepareBackgroundForRender(params: {
304
314
  framesCount,
305
315
  sceneChanges,
306
316
  videoInfo.duration,
317
+ fps,
318
+ {
319
+ beatIntensities,
320
+ ...(autoEditVideo && tempo ? { tempo } : {}),
321
+ },
307
322
  );
308
323
 
309
324
  const concatFilePath = writeConcatFile(segments, backgroundVideoPath, videoInfo.duration, fps);
325
+ const cutFrameIndices = autoEditVideo ? getCutFrameIndices(segments) : [];
310
326
 
311
327
  const videoFrameReader = spawnConcatVideoFrameReader({
312
328
  concatFilePath,
@@ -339,6 +355,7 @@ async function prepareBackgroundForRender(params: {
339
355
  videoFrameSize,
340
356
  encodeVideoFrame,
341
357
  concatFilePath,
358
+ cutFrameIndices,
342
359
  };
343
360
  }
344
361
 
@@ -363,6 +380,7 @@ async function prepareBackgroundForRender(params: {
363
380
  defaultColor,
364
381
  staticBackgroundBuffer,
365
382
  videoFrameSize: 0,
383
+ cutFrameIndices: [],
366
384
  };
367
385
  }
368
386
 
@@ -497,6 +515,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
497
515
  const autoHighlight = getAudioAutoHighlight(config);
498
516
  let spectrumsForRender = preprocessed.spectrums;
499
517
  let beatIndicesForRender = preprocessed.beatFrameIndices;
518
+ let beatIntensitiesForRender = preprocessed.beatEvents.map(event => event.intensity);
500
519
  let framesCountForRender = framesCount;
501
520
  type HighlightSliceResult = ReturnType<typeof computeHighlightSlice> extends Promise<
502
521
  infer R
@@ -516,12 +535,24 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
516
535
  );
517
536
  spectrumsForRender = highlightSlice.spectrums;
518
537
  beatIndicesForRender = highlightSlice.beatFrameIndices;
538
+ beatIntensitiesForRender = highlightSlice.beatIntensities;
519
539
  framesCountForRender = highlightSlice.highlightFrames;
520
540
  reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
521
541
  } else {
522
542
  reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
523
543
  }
524
544
 
545
+ const autoEditVideo = getAutoEditVideo(config);
546
+ let trackTempo: TempoEstimate | null = null;
547
+ if (autoEditVideo) {
548
+ const sampleRateNum = Number(sampleRate);
549
+ const maxSamples = Math.floor(sampleRateNum * MAX_TEMPO_ANALYSIS_SECONDS);
550
+ const tempoBuffer = audioBuffer.length > maxSamples
551
+ ? audioBuffer.slice(0, maxSamples)
552
+ : audioBuffer;
553
+ trackTempo = estimateTempo(pcmU8ToFloatSamples(tempoBuffer), sampleRateNum, FPS);
554
+ }
555
+
525
556
  const separateHighlightFiles =
526
557
  autoHighlight &&
527
558
  highlightSlice &&
@@ -531,7 +562,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
531
562
  outPath: string;
532
563
  spectrums: number[][];
533
564
  beatIndices: number[];
565
+ beatIntensities: number[];
534
566
  frameCount: number;
567
+ startFrame: number;
535
568
  audioSegment: import('./highlight').HighlightAudioSegment | undefined;
536
569
  };
537
570
 
@@ -546,7 +579,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
546
579
  outPath: numberedPath,
547
580
  spectrums: run.spectrums,
548
581
  beatIndices: run.beatFrameIndices,
582
+ beatIntensities: run.beatIntensities,
549
583
  frameCount: run.highlightFrames,
584
+ startFrame: run.startFrame,
550
585
  audioSegment: run.audioSegment,
551
586
  };
552
587
  })
@@ -555,7 +590,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
555
590
  outPath: outVideoPath,
556
591
  spectrums: spectrumsForRender,
557
592
  beatIndices: beatIndicesForRender,
593
+ beatIntensities: beatIntensitiesForRender,
558
594
  frameCount: framesCountForRender,
595
+ startFrame: highlightSlice?.startFrame ?? 0,
559
596
  audioSegment:
560
597
  autoHighlight &&
561
598
  highlightSlice &&
@@ -582,6 +619,12 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
582
619
  const outputVideoFiles: string[] = [];
583
620
  try {
584
621
  passLoop: for (const pass of passes) {
622
+ const tempo = trackTempo
623
+ ? tempoForWindow(trackTempo, pass.startFrame, FPS, pass.frameCount, pass.beatIndices)
624
+ : null;
625
+ const shakeAmplitudes = tempo
626
+ ? buildCutShakeAmplitudes(tempo.periodFrames)
627
+ : undefined;
585
628
  const {
586
629
  backgroundWidth,
587
630
  backgroundHeight,
@@ -596,14 +639,23 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
596
639
  backgroundVideoPath,
597
640
  backgroundImagePath,
598
641
  beatFrameIndices: pass.beatIndices,
642
+ beatIntensities: pass.beatIntensities,
599
643
  framesCount: pass.frameCount,
600
644
  outputResolution,
601
645
  fps: FPS,
602
- autoEditVideo: getAutoEditVideo(config),
646
+ autoEditVideo,
603
647
  readVideoFrameTimeout: videoTimeouts.readVideoFrame,
648
+ ...(tempo ? { tempo } : {}),
604
649
  });
605
650
  reportRenderProgress();
606
651
 
652
+ const shakeFrames = new Set(
653
+ autoEditVideo
654
+ ? (tempo
655
+ ? beatGridFrameIndices(tempo, pass.frameCount, CAMERA_SHAKE_EVERY_BEATS)
656
+ : pass.beatIndices.filter(frameIndex => frameIndex > 0).filter((_, i) => i % CAMERA_SHAKE_EVERY_BEATS === 0))
657
+ : [],
658
+ );
607
659
  const createVisualizerFrame = createVisualizerFrameGenerator(
608
660
  config, backgroundWidth, backgroundHeight, defaultColor, spectrumBusMargin
609
661
  );
@@ -643,6 +695,10 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
643
695
  spectrum,
644
696
  };
645
697
  const frameImage = createVisualizerFrame(commonVisualizerFrameProps);
698
+ const shakeOffset = getCutShakeOffset(i, shakeFrames, shakeAmplitudes);
699
+ if (shakeOffset.x !== 0 || shakeOffset.y !== 0) {
700
+ applyCameraShake(frameImage, backgroundWidth, backgroundHeight, shakeOffset.x, shakeOffset.y);
701
+ }
646
702
  const isFrameProcessed = ffmpegVideoWriter.stdin.write(frameImage.data);
647
703
  if (!isFrameProcessed) {
648
704
  const isDrained = await waitDrain(
@@ -4,6 +4,7 @@ import child_process, { ChildProcessWithoutNullStreams } from 'child_process';
4
4
  import {
5
5
  bufferToUInt8,
6
6
  normalizeAudioData,
7
+ pcmU8ToFloatSamples,
7
8
  spawnFfmpegAudioReader,
8
9
  skipEvery,
9
10
  getPeaks,
@@ -42,6 +43,11 @@ describe('audio', function () {
42
43
  expect(result).deep.equal(expected);
43
44
  });
44
45
 
46
+ it('pcmU8ToFloatSamples', function () {
47
+ const buffer = Buffer.from([128, 255, 0]);
48
+ expect(pcmU8ToFloatSamples(buffer)).deep.equal([0, 127 / 128, -1]);
49
+ });
50
+
45
51
  it('spawnFfmpegAudioReader', function (done) {
46
52
  const childProcessReadableStream = new Readable();
47
53
  const childProcessWritableStream = new Writable();
@@ -0,0 +1,134 @@
1
+ import { expect } from 'chai';
2
+ import { estimateTempo, beatGridFrameIndices, shiftTempoPhase, tempoForWindow } from '../beats';
3
+
4
+ const clickTrain = (
5
+ sampleRate: number,
6
+ bpm: number,
7
+ durationSec: number,
8
+ phaseSec = 0,
9
+ halfPeriodGhost = 0,
10
+ ): number[] => {
11
+ const n = Math.round(sampleRate * durationSec);
12
+ const samples = new Array<number>(n).fill(0);
13
+ const period = 60 / bpm;
14
+ const clickLen = Math.round(sampleRate * 0.01);
15
+ const addClick = (timeSec: number, amplitude: number) => {
16
+ const start = Math.round(timeSec * sampleRate);
17
+ for (let i = 0; i < clickLen && start + i < n; i++) {
18
+ const env = Math.exp(-i / (clickLen * 0.25));
19
+ samples[start + i] += amplitude * env * (i % 2 === 0 ? 1 : -0.7);
20
+ }
21
+ };
22
+ for (let t = phaseSec; t < durationSec; t += period) {
23
+ addClick(t, 1);
24
+ if (halfPeriodGhost > 0) {
25
+ addClick(t + period / 2, halfPeriodGhost);
26
+ }
27
+ }
28
+ return samples;
29
+ };
30
+
31
+ describe('beats', function () {
32
+ this.timeout(10000);
33
+
34
+ it('estimateTempo detects 128 BPM from a click train', function () {
35
+ const sampleRate = 22050;
36
+ const fps = 30;
37
+ const tempo = estimateTempo(clickTrain(sampleRate, 128, 8), sampleRate, fps);
38
+ expect(tempo).not.equal(null);
39
+ expect(tempo!.bpm).closeTo(128, 8);
40
+ expect(tempo!.periodFrames).closeTo(fps * 60 / 128, 1);
41
+ });
42
+
43
+ it('estimateTempo recovers phase of the click train', function () {
44
+ const sampleRate = 22050;
45
+ const fps = 30;
46
+ const phaseSec = 0.1;
47
+ const tempo = estimateTempo(clickTrain(sampleRate, 128, 8, phaseSec), sampleRate, fps);
48
+ expect(tempo).not.equal(null);
49
+ expect(tempo!.phaseFrame).closeTo(phaseSec * fps, 2);
50
+ });
51
+
52
+ it('estimateTempo prefers ~128 BPM when half-period ghosts are present', function () {
53
+ const sampleRate = 22050;
54
+ const fps = 30;
55
+ const tempo = estimateTempo(clickTrain(sampleRate, 128, 8, 0, 0.35), sampleRate, fps);
56
+ expect(tempo).not.equal(null);
57
+ expect(tempo!.bpm).closeTo(128, 8);
58
+ });
59
+
60
+ it('estimateTempo returns null for silence', function () {
61
+ const sampleRate = 22050;
62
+ const samples = new Array<number>(sampleRate * 2).fill(0);
63
+ expect(estimateTempo(samples, sampleRate, 30)).equal(null);
64
+ });
65
+
66
+ it('estimateTempo returns null for empty or invalid input', function () {
67
+ expect(estimateTempo([], 22050, 30)).equal(null);
68
+ expect(estimateTempo(clickTrain(22050, 128, 8), 0, 30)).equal(null);
69
+ expect(estimateTempo(clickTrain(22050, 128, 8), 22050, 0)).equal(null);
70
+ });
71
+
72
+ it('shiftTempoPhase wraps the beat grid into a later window', function () {
73
+ expect(shiftTempoPhase({ bpm: 120, periodFrames: 15, phaseFrame: 3 }, 0)).deep.equal({
74
+ bpm: 120,
75
+ periodFrames: 15,
76
+ phaseFrame: 3,
77
+ });
78
+ expect(shiftTempoPhase({ bpm: 120, periodFrames: 15, phaseFrame: 3 }, 3).phaseFrame).equal(0);
79
+ expect(shiftTempoPhase({ bpm: 120, periodFrames: 15, phaseFrame: 3 }, 18).phaseFrame).equal(0);
80
+ });
81
+
82
+ it('shiftTempoPhase keeps a non-integer period aligned in a late window', function () {
83
+ const tempo = { bpm: 128, periodFrames: 14.0625, phaseFrame: 3 };
84
+ const startFrame = 2000;
85
+ const shifted = shiftTempoPhase(tempo, startFrame);
86
+ const expected: number[] = [];
87
+ for (let n = 0; n < 400; n++) {
88
+ const local = Math.round(tempo.phaseFrame + n * tempo.periodFrames) - startFrame;
89
+ if (local > 0 && local < 60) {
90
+ expected.push(local);
91
+ }
92
+ }
93
+ expect(beatGridFrameIndices(shifted, 60)).deep.equal(expected);
94
+ });
95
+
96
+ it('tempoForWindow locks phase to beat times inside the edited window', function () {
97
+ const fps = 30;
98
+ const periodFrames = 15;
99
+ const startFrame = 90;
100
+ const tempo = {
101
+ bpm: 120,
102
+ periodFrames,
103
+ phaseFrame: 1,
104
+ beatsSec: [0.1, 3.2, 3.7, 4.2, 4.7],
105
+ };
106
+ const windowed = tempoForWindow(tempo, startFrame, fps, 60);
107
+ expect(windowed.phaseFrame).closeTo(6, 1);
108
+ expect(shiftTempoPhase(tempo, startFrame).phaseFrame).closeTo(1, 0.001);
109
+ expect(beatGridFrameIndices(windowed, 50)[0]).closeTo(6, 1);
110
+ expect(tempoForWindow(tempo, startFrame, fps, 60, [1, 16, 31]).phaseFrame).closeTo(6, 1);
111
+ });
112
+
113
+ it('tempoForWindow falls back to local onsets when beat times are outside the window', function () {
114
+ const tempo = {
115
+ bpm: 120,
116
+ periodFrames: 15,
117
+ phaseFrame: 1,
118
+ beatsSec: [0.1, 0.6, 1.1],
119
+ };
120
+ const windowed = tempoForWindow(tempo, 90, 30, 60, [7, 22, 37]);
121
+ expect(windowed.phaseFrame).closeTo(7, 1);
122
+ });
123
+
124
+ it('beatGridFrameIndices emits every beat and skips frame 0', function () {
125
+ expect(beatGridFrameIndices({ periodFrames: 14, phaseFrame: 0 }, 50)).deep.equal([14, 28, 42]);
126
+ expect(beatGridFrameIndices({ periodFrames: 14, phaseFrame: 3 }, 32)).deep.equal([3, 17, 31]);
127
+ expect(beatGridFrameIndices({ periodFrames: 14, phaseFrame: 0 }, 14)).deep.equal([]);
128
+ });
129
+
130
+ it('beatGridFrameIndices stride emits every Nth beat', function () {
131
+ expect(beatGridFrameIndices({ periodFrames: 14, phaseFrame: 0 }, 200, 4)).deep.equal([14, 70, 126, 182]);
132
+ expect(beatGridFrameIndices({ periodFrames: 14, phaseFrame: 3 }, 200, 4)).deep.equal([3, 59, 115, 171]);
133
+ });
134
+ });
@@ -9,6 +9,9 @@ import {
9
9
  convertToBmp,
10
10
  mixValues,
11
11
  mixColors,
12
+ applyCameraShake,
13
+ getCutShakeOffset,
14
+ buildCutShakeAmplitudes,
12
15
  } from '../image';
13
16
  import { createBpmEncoder } from '../bpmEncoder';
14
17
 
@@ -77,6 +80,89 @@ describe('image', function () {
77
80
  expect(imageDstBuffer.data).deep.equal(expectedImageData);
78
81
  });
79
82
 
83
+ it('getCutShakeOffset peaks on the cut and decays on the next frames', function () {
84
+ const cutFrames = new Set([10, 20]);
85
+ const magnitude = (offset: { x: number; y: number }) => Math.hypot(offset.x, offset.y);
86
+ expect(getCutShakeOffset(9, cutFrames)).deep.equal({ x: 0, y: 0 });
87
+ expect(magnitude(getCutShakeOffset(10, cutFrames))).greaterThan(magnitude(getCutShakeOffset(11, cutFrames)));
88
+ expect(magnitude(getCutShakeOffset(11, cutFrames))).greaterThan(magnitude(getCutShakeOffset(12, cutFrames)));
89
+ expect(magnitude(getCutShakeOffset(12, cutFrames))).greaterThan(magnitude(getCutShakeOffset(13, cutFrames)));
90
+ expect(getCutShakeOffset(15, cutFrames)).deep.equal({ x: 0, y: 0 });
91
+ expect(magnitude(getCutShakeOffset(20, cutFrames))).greaterThan(0);
92
+ });
93
+
94
+ it('getCutShakeOffset uses the closer overlapping cut', function () {
95
+ const cutFrames = new Set([10, 11]);
96
+ expect(getCutShakeOffset(11, cutFrames)).deep.equal(getCutShakeOffset(11, new Set([11])));
97
+ });
98
+
99
+ it('buildCutShakeAmplitudes lasts about one-third of a beat', function () {
100
+ const at128 = buildCutShakeAmplitudes(14);
101
+ expect(at128.length).equal(5);
102
+ expect(at128[0]).equal(14);
103
+ expect(at128[at128.length - 1]).equal(1);
104
+ expect(buildCutShakeAmplitudes(30).length).equal(10);
105
+ expect(buildCutShakeAmplitudes(6).length).equal(3);
106
+ });
107
+
108
+ it('getCutShakeOffset uses a longer envelope when amplitudes are passed', function () {
109
+ const cutFrames = new Set([10]);
110
+ const longAmp = buildCutShakeAmplitudes(30);
111
+ const magnitude = (offset: { x: number; y: number }) => Math.hypot(offset.x, offset.y);
112
+ expect(getCutShakeOffset(15, cutFrames)).deep.equal({ x: 0, y: 0 });
113
+ expect(magnitude(getCutShakeOffset(15, cutFrames, longAmp))).greaterThan(0);
114
+ expect(getCutShakeOffset(10 + longAmp.length, cutFrames, longAmp)).deep.equal({ x: 0, y: 0 });
115
+ expect(magnitude(getCutShakeOffset(10, cutFrames, longAmp))).greaterThan(
116
+ magnitude(getCutShakeOffset(12, cutFrames, longAmp)),
117
+ );
118
+ });
119
+
120
+ it('applyCameraShake shifts pixels and clamps edges', function () {
121
+ const width = 3;
122
+ const height = 2;
123
+ const extraBytes = width % 4;
124
+ const rowBytes = 3 * width + extraBytes;
125
+ const shiftPos = 4;
126
+ const data = Buffer.alloc(shiftPos + height * rowBytes, 0);
127
+ data[0] = 9;
128
+ data[1] = 8;
129
+ data[2] = 7;
130
+ data[3] = 6;
131
+ let value = 1;
132
+ for (let y = 0; y < height; y++) {
133
+ for (let x = 0; x < width; x++) {
134
+ const pixelIndex = shiftPos + y * rowBytes + x * 3;
135
+ data[pixelIndex] = value;
136
+ data[pixelIndex + 1] = value + 1;
137
+ data[pixelIndex + 2] = value + 2;
138
+ value += 3;
139
+ }
140
+ }
141
+
142
+ applyCameraShake({ shiftPos, rowBytes, data }, width, height, 1, 0);
143
+
144
+ expect(data[0]).equal(9);
145
+ expect(data[1]).equal(8);
146
+ expect(data[2]).equal(7);
147
+ expect(data[3]).equal(6);
148
+ const pixelAt = (x: number, y: number) => {
149
+ const pixelIndex = shiftPos + y * rowBytes + x * 3;
150
+ return [data[pixelIndex], data[pixelIndex + 1], data[pixelIndex + 2]];
151
+ };
152
+ expect(pixelAt(0, 0)).deep.equal([1, 2, 3]);
153
+ expect(pixelAt(1, 0)).deep.equal([1, 2, 3]);
154
+ expect(pixelAt(2, 0)).deep.equal([4, 5, 6]);
155
+ expect(pixelAt(0, 1)).deep.equal([10, 11, 12]);
156
+ expect(pixelAt(1, 1)).deep.equal([10, 11, 12]);
157
+ expect(pixelAt(2, 1)).deep.equal([13, 14, 15]);
158
+ });
159
+
160
+ it('applyCameraShake is a no-op at zero offset', function () {
161
+ const data = Buffer.from([1, 2, 3, 10, 20, 30]);
162
+ applyCameraShake({ shiftPos: 3, rowBytes: 3, data }, 1, 1, 0, 0);
163
+ expect(Array.from(data)).deep.equal([1, 2, 3, 10, 20, 30]);
164
+ });
165
+
80
166
  it('createSpectrumVisualizerFrameGenerator', async function () {
81
167
  const expectedImageLength = 6220854;
82
168
  const backgroundImagePath = path.resolve('example/media/horses.png');
@@ -77,6 +77,74 @@ describe('index', function () {
77
77
  expect(outputVideoFiles).deep.equal([path.resolve('outVideoPath')]);
78
78
  });
79
79
 
80
+ it('does not render a visualizer when neither spectrum nor polar is specified', async function () {
81
+ const polarSpy = sandbox.spy(image, 'createPolarVisualizerFrameGenerator');
82
+ const spectrumSpy = sandbox.spy(image, 'createSpectrumVisualizerFrameGenerator');
83
+ const config: Config = {
84
+ audio: {
85
+ path: 'audioPath'
86
+ },
87
+ image: {
88
+ path: 'example/media/horses.png'
89
+ },
90
+ outVideo: {
91
+ path: 'outVideoPath',
92
+ fps: 1
93
+ }
94
+ };
95
+
96
+ const { exitCode } = await renderAudioVisualizer(config);
97
+ expect(exitCode).equal(EXIT_CODE);
98
+ expect(polarSpy.called).equal(false);
99
+ expect(spectrumSpy.called).equal(false);
100
+ });
101
+
102
+ it('renders a polar visualizer when polar is specified', async function () {
103
+ const polarSpy = sandbox.spy(image, 'createPolarVisualizerFrameGenerator');
104
+ const spectrumSpy = sandbox.spy(image, 'createSpectrumVisualizerFrameGenerator');
105
+ const config: Config = {
106
+ audio: {
107
+ path: 'audioPath'
108
+ },
109
+ image: {
110
+ path: 'example/media/horses.png'
111
+ },
112
+ outVideo: {
113
+ path: 'outVideoPath',
114
+ fps: 1,
115
+ polar: {}
116
+ }
117
+ };
118
+
119
+ const { exitCode } = await renderAudioVisualizer(config);
120
+ expect(exitCode).equal(EXIT_CODE);
121
+ expect(polarSpy.calledOnce).equal(true);
122
+ expect(spectrumSpy.called).equal(false);
123
+ });
124
+
125
+ it('renders a spectrum visualizer when spectrum is specified', async function () {
126
+ const polarSpy = sandbox.spy(image, 'createPolarVisualizerFrameGenerator');
127
+ const spectrumSpy = sandbox.spy(image, 'createSpectrumVisualizerFrameGenerator');
128
+ const config: Config = {
129
+ audio: {
130
+ path: 'audioPath'
131
+ },
132
+ image: {
133
+ path: 'example/media/horses.png'
134
+ },
135
+ outVideo: {
136
+ path: 'outVideoPath',
137
+ fps: 1,
138
+ spectrum: {}
139
+ }
140
+ };
141
+
142
+ const { exitCode } = await renderAudioVisualizer(config);
143
+ expect(exitCode).equal(EXIT_CODE);
144
+ expect(spectrumSpy.calledOnce).equal(true);
145
+ expect(polarSpy.called).equal(false);
146
+ });
147
+
80
148
  it('resolves and does not report 100 when process completion times out', async function () {
81
149
  (this as any).setMockWriter(false);
82
150
  sandbox.stub(video, 'waitForProcessExit').callsFake(