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/README.md +1 -1
- package/dist/audio.d.ts +1 -0
- package/dist/audio.js +8 -0
- package/dist/audio.js.map +1 -1
- package/dist/beats.d.ts +20 -0
- package/dist/beats.js +180 -3
- package/dist/beats.js.map +1 -1
- package/dist/highlight.d.ts +2 -0
- package/dist/highlight.js +33 -24
- package/dist/highlight.js.map +1 -1
- package/dist/image.d.ts +13 -0
- package/dist/image.js +80 -0
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +66 -34
- package/dist/index.js.map +1 -1
- package/dist/video.d.ts +41 -1
- package/dist/video.js +150 -2
- package/dist/video.js.map +1 -1
- package/package.json +3 -2
- package/src/audio.ts +9 -0
- package/src/beats.ts +214 -3
- package/src/highlight.ts +21 -14
- package/src/image.ts +98 -0
- package/src/index.ts +57 -6
- package/src/tests/audio.test.ts +6 -0
- package/src/tests/beats.test.ts +134 -0
- package/src/tests/image.test.ts +86 -0
- package/src/tests/video.test.ts +163 -1
- package/src/typings.d.ts +22 -0
- package/src/video.ts +173 -1
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
|
|
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(
|
package/src/tests/audio.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/tests/image.test.ts
CHANGED
|
@@ -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');
|