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.
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';
@@ -266,11 +266,13 @@ async function prepareBackgroundForRender(params: {
266
266
  backgroundVideoPath: string | undefined;
267
267
  backgroundImagePath: string | undefined;
268
268
  beatFrameIndices: number[];
269
+ beatIntensities?: number[];
269
270
  framesCount: number;
270
271
  outputResolution: ReturnType<typeof getOutputResolution>;
271
272
  fps: number;
272
273
  autoEditVideo: boolean;
273
274
  readVideoFrameTimeout: number;
275
+ tempo?: TempoEstimate | null;
274
276
  }): Promise<{
275
277
  backgroundWidth: number;
276
278
  backgroundHeight: number;
@@ -280,17 +282,20 @@ async function prepareBackgroundForRender(params: {
280
282
  videoFrameSize: number;
281
283
  encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
282
284
  concatFilePath?: string;
285
+ cutFrameIndices: number[];
283
286
  }> {
284
287
  const {
285
288
  useVideoBackground,
286
289
  backgroundVideoPath,
287
290
  backgroundImagePath,
288
291
  beatFrameIndices,
292
+ beatIntensities,
289
293
  framesCount,
290
294
  outputResolution,
291
295
  fps,
292
296
  autoEditVideo,
293
297
  readVideoFrameTimeout,
298
+ tempo,
294
299
  } = params;
295
300
 
296
301
  if (useVideoBackground && backgroundVideoPath) {
@@ -309,9 +314,15 @@ async function prepareBackgroundForRender(params: {
309
314
  framesCount,
310
315
  sceneChanges,
311
316
  videoInfo.duration,
317
+ fps,
318
+ {
319
+ beatIntensities,
320
+ ...(autoEditVideo && tempo ? { tempo } : {}),
321
+ },
312
322
  );
313
323
 
314
324
  const concatFilePath = writeConcatFile(segments, backgroundVideoPath, videoInfo.duration, fps);
325
+ const cutFrameIndices = autoEditVideo ? getCutFrameIndices(segments) : [];
315
326
 
316
327
  const videoFrameReader = spawnConcatVideoFrameReader({
317
328
  concatFilePath,
@@ -344,6 +355,7 @@ async function prepareBackgroundForRender(params: {
344
355
  videoFrameSize,
345
356
  encodeVideoFrame,
346
357
  concatFilePath,
358
+ cutFrameIndices,
347
359
  };
348
360
  }
349
361
 
@@ -368,6 +380,7 @@ async function prepareBackgroundForRender(params: {
368
380
  defaultColor,
369
381
  staticBackgroundBuffer,
370
382
  videoFrameSize: 0,
383
+ cutFrameIndices: [],
371
384
  };
372
385
  }
373
386
 
@@ -502,6 +515,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
502
515
  const autoHighlight = getAudioAutoHighlight(config);
503
516
  let spectrumsForRender = preprocessed.spectrums;
504
517
  let beatIndicesForRender = preprocessed.beatFrameIndices;
518
+ let beatIntensitiesForRender = preprocessed.beatEvents.map(event => event.intensity);
505
519
  let framesCountForRender = framesCount;
506
520
  type HighlightSliceResult = ReturnType<typeof computeHighlightSlice> extends Promise<
507
521
  infer R
@@ -521,12 +535,24 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
521
535
  );
522
536
  spectrumsForRender = highlightSlice.spectrums;
523
537
  beatIndicesForRender = highlightSlice.beatFrameIndices;
538
+ beatIntensitiesForRender = highlightSlice.beatIntensities;
524
539
  framesCountForRender = highlightSlice.highlightFrames;
525
540
  reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
526
541
  } else {
527
542
  reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
528
543
  }
529
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
+
530
556
  const separateHighlightFiles =
531
557
  autoHighlight &&
532
558
  highlightSlice &&
@@ -536,7 +562,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
536
562
  outPath: string;
537
563
  spectrums: number[][];
538
564
  beatIndices: number[];
565
+ beatIntensities: number[];
539
566
  frameCount: number;
567
+ startFrame: number;
540
568
  audioSegment: import('./highlight').HighlightAudioSegment | undefined;
541
569
  };
542
570
 
@@ -551,7 +579,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
551
579
  outPath: numberedPath,
552
580
  spectrums: run.spectrums,
553
581
  beatIndices: run.beatFrameIndices,
582
+ beatIntensities: run.beatIntensities,
554
583
  frameCount: run.highlightFrames,
584
+ startFrame: run.startFrame,
555
585
  audioSegment: run.audioSegment,
556
586
  };
557
587
  })
@@ -560,7 +590,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
560
590
  outPath: outVideoPath,
561
591
  spectrums: spectrumsForRender,
562
592
  beatIndices: beatIndicesForRender,
593
+ beatIntensities: beatIntensitiesForRender,
563
594
  frameCount: framesCountForRender,
595
+ startFrame: highlightSlice?.startFrame ?? 0,
564
596
  audioSegment:
565
597
  autoHighlight &&
566
598
  highlightSlice &&
@@ -587,6 +619,12 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
587
619
  const outputVideoFiles: string[] = [];
588
620
  try {
589
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;
590
628
  const {
591
629
  backgroundWidth,
592
630
  backgroundHeight,
@@ -601,14 +639,23 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
601
639
  backgroundVideoPath,
602
640
  backgroundImagePath,
603
641
  beatFrameIndices: pass.beatIndices,
642
+ beatIntensities: pass.beatIntensities,
604
643
  framesCount: pass.frameCount,
605
644
  outputResolution,
606
645
  fps: FPS,
607
- autoEditVideo: getAutoEditVideo(config),
646
+ autoEditVideo,
608
647
  readVideoFrameTimeout: videoTimeouts.readVideoFrame,
648
+ ...(tempo ? { tempo } : {}),
609
649
  });
610
650
  reportRenderProgress();
611
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
+ );
612
659
  const createVisualizerFrame = createVisualizerFrameGenerator(
613
660
  config, backgroundWidth, backgroundHeight, defaultColor, spectrumBusMargin
614
661
  );
@@ -648,6 +695,10 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
648
695
  spectrum,
649
696
  };
650
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
+ }
651
702
  const isFrameProcessed = ffmpegVideoWriter.stdin.write(frameImage.data);
652
703
  if (!isFrameProcessed) {
653
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');