nodejs-audio-visualizer 5.0.0 → 5.2.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/LICENSE +21 -21
- package/README.md +3 -2
- package/dist/audio.d.ts +13 -12
- package/dist/audio.js +128 -120
- package/dist/audio.js.map +1 -1
- package/dist/beats.d.ts +39 -19
- package/dist/beats.js +220 -43
- package/dist/beats.js.map +1 -1
- package/dist/bpmEncoder.d.ts +14 -14
- package/dist/bpmEncoder.js +97 -97
- package/dist/config.d.ts +78 -77
- package/dist/config.js +377 -369
- package/dist/config.js.map +1 -1
- package/dist/dsp.d.ts +16 -16
- package/dist/dsp.js +76 -76
- package/dist/highlight.d.ts +34 -32
- package/dist/highlight.js +334 -325
- package/dist/highlight.js.map +1 -1
- package/dist/image.d.ts +97 -79
- package/dist/image.js +545 -458
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +87 -79
- package/dist/index.js +597 -534
- package/dist/index.js.map +1 -1
- package/dist/subtitleConvert.d.ts +3 -3
- package/dist/subtitleConvert.js +61 -61
- package/dist/video.d.ts +150 -80
- package/dist/video.js +677 -434
- package/dist/video.js.map +1 -1
- package/dist/waitForEventLoop.d.ts +1 -1
- package/dist/waitForEventLoop.js +5 -5
- package/package.json +62 -61
- package/src/audio.ts +129 -120
- package/src/beats.ts +279 -68
- package/src/bpmEncoder.ts +97 -97
- package/src/config.ts +487 -476
- package/src/dsp.ts +95 -95
- package/src/highlight.ts +291 -284
- package/src/image.ts +707 -602
- package/src/index.ts +124 -31
- package/src/subtitleConvert.ts +63 -63
- package/src/tests/audio.test.ts +102 -96
- package/src/tests/beats.test.ts +134 -0
- package/src/tests/config.test.ts +534 -496
- package/src/tests/dsp.test.ts +24 -24
- package/src/tests/highlight.test.ts +178 -178
- package/src/tests/image.test.ts +319 -222
- package/src/tests/index.test.ts +180 -180
- package/src/tests/subtitleConvert.test.ts +23 -23
- package/src/tests/video.test.ts +535 -285
- package/src/typings.d.ts +28 -6
- package/src/video.ts +856 -550
- package/src/waitForEventLoop.ts +2 -2
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync } from 'fs';
|
|
|
3
3
|
import {
|
|
4
4
|
getAudioFilePath,
|
|
5
5
|
getBackgroundImagePath,
|
|
6
|
-
|
|
6
|
+
getBackgroundVideoPaths,
|
|
7
7
|
getOutVideoPath,
|
|
8
8
|
getSubtitleRenderSpec,
|
|
9
9
|
subtitleAlignmentToAss,
|
|
@@ -33,16 +33,17 @@ import {
|
|
|
33
33
|
getPolarColor,
|
|
34
34
|
getPolarOpacityParsed,
|
|
35
35
|
getAutoEditVideo,
|
|
36
|
+
getCameraShakeEnabled,
|
|
36
37
|
getAudioAutoHighlight,
|
|
37
38
|
getAudioAutoHighlightCount,
|
|
38
39
|
} from './config';
|
|
39
|
-
import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor } from './audio';
|
|
40
|
-
import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps } from './image';
|
|
40
|
+
import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor, pcmU8ToFloatSamples } from './audio';
|
|
41
|
+
import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps, applyCameraShake, getCutShakeOffset, buildCutShakeAmplitudes, CAMERA_SHAKE_DELAY_SECONDS, CAMERA_SHAKE_EVERY_BEATS } from './image';
|
|
41
42
|
import { normalizeInlineSubtitlesToSrt, lrcToSrt } from './subtitleConvert';
|
|
42
|
-
import { spawnFfmpegVideoWriter, waitDrain, waitForProcessExit, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile } from './video';
|
|
43
|
+
import { spawnFfmpegVideoWriter, waitDrain, waitForProcessExit, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, buildSequentialVideoSegments, buildShuffledVideoSegments, getCutFrameIndices, writeConcatFile, writeSubtitlesFile, spawnConcatVideoFrameReader, cleanupConcatFile, cleanupTempFile, VideoSegment } from './video';
|
|
43
44
|
import { createBpmEncoder, createBgrFrameEncoder, EncodedBmp } from './bpmEncoder';
|
|
44
|
-
import { createBeatDetector } from './beats';
|
|
45
|
-
export { BeatInfo, BeatDetectorOptions } from './beats';
|
|
45
|
+
import { createBeatDetector, estimateTempo, TempoEstimate, beatGridFrameIndices, tempoForWindow, MAX_TEMPO_ANALYSIS_SECONDS } from './beats';
|
|
46
|
+
export { BeatInfo, BeatDetectorOptions, TempoEstimate, estimateTempo, beatGridFrameIndices, shiftTempoPhase, tempoForWindow } from './beats';
|
|
46
47
|
import { computeHighlightSlice, HIGHLIGHT_DURATION_SEC } from './highlight';
|
|
47
48
|
import { waitForEventLoop } from './waitForEventLoop';
|
|
48
49
|
export { computeHighlightSlice, HIGHLIGHT_DURATION_SEC, BeatFrameEvent, HighlightAudioSegment, HighlightRun } from './highlight';
|
|
@@ -66,8 +67,16 @@ export interface Config {
|
|
|
66
67
|
path: string;
|
|
67
68
|
};
|
|
68
69
|
video?: {
|
|
69
|
-
|
|
70
|
+
/**
|
|
71
|
+
* A single video path, or an array of video paths.
|
|
72
|
+
* With an array: when `autoEdit` is false, the videos play one after another (looping the
|
|
73
|
+
* sequence as needed); when `autoEdit` is true, cuts switch (shuffle) between the given
|
|
74
|
+
* videos instead of auto-detecting scene changes within a single video.
|
|
75
|
+
*/
|
|
76
|
+
path: string | string[];
|
|
70
77
|
autoEdit?: boolean;
|
|
78
|
+
/** When `autoEdit` is true, disables the brief camera shake applied on cuts, or every 2 beats when there are no cuts (default true). */
|
|
79
|
+
cameraShake?: boolean;
|
|
71
80
|
};
|
|
72
81
|
outVideo: {
|
|
73
82
|
path: string;
|
|
@@ -263,14 +272,18 @@ const preProcessAudio = async (
|
|
|
263
272
|
|
|
264
273
|
async function prepareBackgroundForRender(params: {
|
|
265
274
|
useVideoBackground: boolean;
|
|
266
|
-
|
|
275
|
+
backgroundVideoPaths: string[] | undefined;
|
|
267
276
|
backgroundImagePath: string | undefined;
|
|
268
277
|
beatFrameIndices: number[];
|
|
278
|
+
beatIntensities?: number[];
|
|
269
279
|
framesCount: number;
|
|
270
280
|
outputResolution: ReturnType<typeof getOutputResolution>;
|
|
271
281
|
fps: number;
|
|
272
282
|
autoEditVideo: boolean;
|
|
273
283
|
readVideoFrameTimeout: number;
|
|
284
|
+
tempo?: TempoEstimate | null;
|
|
285
|
+
/** First video to play for `video.path` arrays without `autoEdit`. */
|
|
286
|
+
startVideoIndex?: number;
|
|
274
287
|
}): Promise<{
|
|
275
288
|
backgroundWidth: number;
|
|
276
289
|
backgroundHeight: number;
|
|
@@ -280,38 +293,71 @@ async function prepareBackgroundForRender(params: {
|
|
|
280
293
|
videoFrameSize: number;
|
|
281
294
|
encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
|
|
282
295
|
concatFilePath?: string;
|
|
296
|
+
cutFrameIndices: number[];
|
|
283
297
|
}> {
|
|
284
298
|
const {
|
|
285
299
|
useVideoBackground,
|
|
286
|
-
|
|
300
|
+
backgroundVideoPaths,
|
|
287
301
|
backgroundImagePath,
|
|
288
302
|
beatFrameIndices,
|
|
303
|
+
beatIntensities,
|
|
289
304
|
framesCount,
|
|
290
305
|
outputResolution,
|
|
291
306
|
fps,
|
|
292
307
|
autoEditVideo,
|
|
293
308
|
readVideoFrameTimeout,
|
|
309
|
+
tempo,
|
|
310
|
+
startVideoIndex = 0,
|
|
294
311
|
} = params;
|
|
295
312
|
|
|
296
|
-
if (useVideoBackground &&
|
|
297
|
-
const
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const backgroundWidth = outputResolution?.width ?? videoInfo.width;
|
|
302
|
-
const backgroundHeight = outputResolution?.height ?? videoInfo.height;
|
|
313
|
+
if (useVideoBackground && backgroundVideoPaths && backgroundVideoPaths.length > 0) {
|
|
314
|
+
const videoInfos = await Promise.all(backgroundVideoPaths.map(videoPath => getVideoInfo(videoPath)));
|
|
315
|
+
const primaryVideoInfo = videoInfos[0];
|
|
316
|
+
const backgroundWidth = outputResolution?.width ?? primaryVideoInfo.width;
|
|
317
|
+
const backgroundHeight = outputResolution?.height ?? primaryVideoInfo.height;
|
|
303
318
|
|
|
304
319
|
const videoFrameSize = backgroundWidth * backgroundHeight * 3;
|
|
305
320
|
const encodeVideoFrame = createBgrFrameEncoder({ width: backgroundWidth, height: backgroundHeight });
|
|
306
321
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
322
|
+
let segments: VideoSegment[];
|
|
323
|
+
if (backgroundVideoPaths.length > 1) {
|
|
324
|
+
segments = autoEditVideo
|
|
325
|
+
? buildShuffledVideoSegments(
|
|
326
|
+
beatFrameIndices,
|
|
327
|
+
framesCount,
|
|
328
|
+
backgroundVideoPaths.length,
|
|
329
|
+
fps,
|
|
330
|
+
{
|
|
331
|
+
beatIntensities,
|
|
332
|
+
...(tempo ? { tempo } : {}),
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
: buildSequentialVideoSegments(
|
|
336
|
+
framesCount,
|
|
337
|
+
videoInfos.map(info => info.duration),
|
|
338
|
+
fps,
|
|
339
|
+
startVideoIndex % backgroundVideoPaths.length,
|
|
340
|
+
);
|
|
341
|
+
} else {
|
|
342
|
+
const sceneChanges = autoEditVideo
|
|
343
|
+
? await detectSceneChanges(backgroundVideoPaths[0])
|
|
344
|
+
: [];
|
|
345
|
+
segments = buildBeatSyncedSegments(
|
|
346
|
+
beatFrameIndices,
|
|
347
|
+
framesCount,
|
|
348
|
+
sceneChanges,
|
|
349
|
+
primaryVideoInfo.duration,
|
|
350
|
+
fps,
|
|
351
|
+
{
|
|
352
|
+
beatIntensities,
|
|
353
|
+
...(autoEditVideo && tempo ? { tempo } : {}),
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
}
|
|
313
357
|
|
|
314
|
-
const
|
|
358
|
+
const videoSources = videoInfos.map((info, i) => ({ path: backgroundVideoPaths[i], duration: info.duration }));
|
|
359
|
+
const concatFilePath = writeConcatFile(segments, videoSources, fps);
|
|
360
|
+
const cutFrameIndices = autoEditVideo ? getCutFrameIndices(segments) : [];
|
|
315
361
|
|
|
316
362
|
const videoFrameReader = spawnConcatVideoFrameReader({
|
|
317
363
|
concatFilePath,
|
|
@@ -320,8 +366,8 @@ async function prepareBackgroundForRender(params: {
|
|
|
320
366
|
...(outputResolution && {
|
|
321
367
|
width: backgroundWidth,
|
|
322
368
|
height: backgroundHeight,
|
|
323
|
-
sourceWidth:
|
|
324
|
-
sourceHeight:
|
|
369
|
+
sourceWidth: primaryVideoInfo.width,
|
|
370
|
+
sourceHeight: primaryVideoInfo.height,
|
|
325
371
|
}),
|
|
326
372
|
});
|
|
327
373
|
|
|
@@ -331,7 +377,7 @@ async function prepareBackgroundForRender(params: {
|
|
|
331
377
|
readVideoFrameTimeout,
|
|
332
378
|
);
|
|
333
379
|
if (!firstFrame) {
|
|
334
|
-
throw new Error(`Could not read frames from video: ${
|
|
380
|
+
throw new Error(`Could not read frames from video: ${backgroundVideoPaths.join(', ')}`);
|
|
335
381
|
}
|
|
336
382
|
const defaultColor = getVideoFrameColor(firstFrame, backgroundWidth, backgroundHeight);
|
|
337
383
|
const staticBackgroundBuffer = encodeVideoFrame(firstFrame);
|
|
@@ -344,6 +390,7 @@ async function prepareBackgroundForRender(params: {
|
|
|
344
390
|
videoFrameSize,
|
|
345
391
|
encodeVideoFrame,
|
|
346
392
|
concatFilePath,
|
|
393
|
+
cutFrameIndices,
|
|
347
394
|
};
|
|
348
395
|
}
|
|
349
396
|
|
|
@@ -368,6 +415,7 @@ async function prepareBackgroundForRender(params: {
|
|
|
368
415
|
defaultColor,
|
|
369
416
|
staticBackgroundBuffer,
|
|
370
417
|
videoFrameSize: 0,
|
|
418
|
+
cutFrameIndices: [],
|
|
371
419
|
};
|
|
372
420
|
}
|
|
373
421
|
|
|
@@ -452,9 +500,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
452
500
|
subtitleFileIsTemporary = true;
|
|
453
501
|
}
|
|
454
502
|
}
|
|
455
|
-
const
|
|
503
|
+
const backgroundVideoPaths = getBackgroundVideoPaths(config);
|
|
456
504
|
const backgroundImagePath = getBackgroundImagePath(config);
|
|
457
|
-
const useVideoBackground = !!
|
|
505
|
+
const useVideoBackground = !!backgroundVideoPaths && backgroundVideoPaths.length > 0;
|
|
458
506
|
|
|
459
507
|
const audioReader = await createAudioBuffer(audioFilePath, FFMPEG_FORMAT);
|
|
460
508
|
const audioBuffer = audioReader.audioBuffer;
|
|
@@ -502,6 +550,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
502
550
|
const autoHighlight = getAudioAutoHighlight(config);
|
|
503
551
|
let spectrumsForRender = preprocessed.spectrums;
|
|
504
552
|
let beatIndicesForRender = preprocessed.beatFrameIndices;
|
|
553
|
+
let beatIntensitiesForRender = preprocessed.beatEvents.map(event => event.intensity);
|
|
505
554
|
let framesCountForRender = framesCount;
|
|
506
555
|
type HighlightSliceResult = ReturnType<typeof computeHighlightSlice> extends Promise<
|
|
507
556
|
infer R
|
|
@@ -521,12 +570,26 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
521
570
|
);
|
|
522
571
|
spectrumsForRender = highlightSlice.spectrums;
|
|
523
572
|
beatIndicesForRender = highlightSlice.beatFrameIndices;
|
|
573
|
+
beatIntensitiesForRender = highlightSlice.beatIntensities;
|
|
524
574
|
framesCountForRender = highlightSlice.highlightFrames;
|
|
525
575
|
reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
|
|
526
576
|
} else {
|
|
527
577
|
reportProgress(PRE_PROCESS_PROGRESS_SHARE + POST_AUDIO_PROGRESS_SHARE);
|
|
528
578
|
}
|
|
529
579
|
|
|
580
|
+
const autoEditVideo = getAutoEditVideo(config);
|
|
581
|
+
const cameraShakeEnabled = getCameraShakeEnabled(config);
|
|
582
|
+
const shakeDelayFrames = Math.round(FPS * CAMERA_SHAKE_DELAY_SECONDS);
|
|
583
|
+
let trackTempo: TempoEstimate | null = null;
|
|
584
|
+
if (autoEditVideo) {
|
|
585
|
+
const sampleRateNum = Number(sampleRate);
|
|
586
|
+
const maxSamples = Math.floor(sampleRateNum * MAX_TEMPO_ANALYSIS_SECONDS);
|
|
587
|
+
const tempoBuffer = audioBuffer.length > maxSamples
|
|
588
|
+
? audioBuffer.slice(0, maxSamples)
|
|
589
|
+
: audioBuffer;
|
|
590
|
+
trackTempo = estimateTempo(pcmU8ToFloatSamples(tempoBuffer), sampleRateNum, FPS);
|
|
591
|
+
}
|
|
592
|
+
|
|
530
593
|
const separateHighlightFiles =
|
|
531
594
|
autoHighlight &&
|
|
532
595
|
highlightSlice &&
|
|
@@ -536,7 +599,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
536
599
|
outPath: string;
|
|
537
600
|
spectrums: number[][];
|
|
538
601
|
beatIndices: number[];
|
|
602
|
+
beatIntensities: number[];
|
|
539
603
|
frameCount: number;
|
|
604
|
+
startFrame: number;
|
|
540
605
|
audioSegment: import('./highlight').HighlightAudioSegment | undefined;
|
|
541
606
|
};
|
|
542
607
|
|
|
@@ -551,7 +616,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
551
616
|
outPath: numberedPath,
|
|
552
617
|
spectrums: run.spectrums,
|
|
553
618
|
beatIndices: run.beatFrameIndices,
|
|
619
|
+
beatIntensities: run.beatIntensities,
|
|
554
620
|
frameCount: run.highlightFrames,
|
|
621
|
+
startFrame: run.startFrame,
|
|
555
622
|
audioSegment: run.audioSegment,
|
|
556
623
|
};
|
|
557
624
|
})
|
|
@@ -560,7 +627,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
560
627
|
outPath: outVideoPath,
|
|
561
628
|
spectrums: spectrumsForRender,
|
|
562
629
|
beatIndices: beatIndicesForRender,
|
|
630
|
+
beatIntensities: beatIntensitiesForRender,
|
|
563
631
|
frameCount: framesCountForRender,
|
|
632
|
+
startFrame: highlightSlice?.startFrame ?? 0,
|
|
564
633
|
audioSegment:
|
|
565
634
|
autoHighlight &&
|
|
566
635
|
highlightSlice &&
|
|
@@ -586,7 +655,14 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
586
655
|
let exitReason: string | undefined;
|
|
587
656
|
const outputVideoFiles: string[] = [];
|
|
588
657
|
try {
|
|
589
|
-
passLoop: for (
|
|
658
|
+
passLoop: for (let passIndex = 0; passIndex < passes.length; passIndex++) {
|
|
659
|
+
const pass = passes[passIndex];
|
|
660
|
+
const tempo = trackTempo
|
|
661
|
+
? tempoForWindow(trackTempo, pass.startFrame, FPS, pass.frameCount, pass.beatIndices)
|
|
662
|
+
: null;
|
|
663
|
+
const shakeAmplitudes = tempo
|
|
664
|
+
? buildCutShakeAmplitudes(tempo.periodFrames)
|
|
665
|
+
: undefined;
|
|
590
666
|
const {
|
|
591
667
|
backgroundWidth,
|
|
592
668
|
backgroundHeight,
|
|
@@ -596,19 +672,32 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
596
672
|
videoFrameSize,
|
|
597
673
|
encodeVideoFrame,
|
|
598
674
|
concatFilePath,
|
|
675
|
+
cutFrameIndices,
|
|
599
676
|
} = await prepareBackgroundForRender({
|
|
600
677
|
useVideoBackground,
|
|
601
|
-
|
|
678
|
+
backgroundVideoPaths,
|
|
602
679
|
backgroundImagePath,
|
|
603
680
|
beatFrameIndices: pass.beatIndices,
|
|
681
|
+
beatIntensities: pass.beatIntensities,
|
|
604
682
|
framesCount: pass.frameCount,
|
|
605
683
|
outputResolution,
|
|
606
684
|
fps: FPS,
|
|
607
|
-
autoEditVideo
|
|
685
|
+
autoEditVideo,
|
|
608
686
|
readVideoFrameTimeout: videoTimeouts.readVideoFrame,
|
|
687
|
+
...(tempo ? { tempo } : {}),
|
|
688
|
+
// Each separate highlight output opens with a different video from the array.
|
|
689
|
+
startVideoIndex: passIndex,
|
|
609
690
|
});
|
|
610
691
|
reportRenderProgress();
|
|
611
692
|
|
|
693
|
+
const beatShakeFrames = () => (tempo
|
|
694
|
+
? beatGridFrameIndices(tempo, pass.frameCount, CAMERA_SHAKE_EVERY_BEATS)
|
|
695
|
+
: pass.beatIndices.filter(frameIndex => frameIndex > 0).filter((_, i) => i % CAMERA_SHAKE_EVERY_BEATS === 0));
|
|
696
|
+
const shakeFrames = new Set(
|
|
697
|
+
autoEditVideo && cameraShakeEnabled
|
|
698
|
+
? (cutFrameIndices.length > 0 ? cutFrameIndices : beatShakeFrames())
|
|
699
|
+
: [],
|
|
700
|
+
);
|
|
612
701
|
const createVisualizerFrame = createVisualizerFrameGenerator(
|
|
613
702
|
config, backgroundWidth, backgroundHeight, defaultColor, spectrumBusMargin
|
|
614
703
|
);
|
|
@@ -648,6 +737,10 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
648
737
|
spectrum,
|
|
649
738
|
};
|
|
650
739
|
const frameImage = createVisualizerFrame(commonVisualizerFrameProps);
|
|
740
|
+
const shakeOffset = getCutShakeOffset(i, shakeFrames, shakeAmplitudes, shakeDelayFrames);
|
|
741
|
+
if (shakeOffset.x !== 0 || shakeOffset.y !== 0) {
|
|
742
|
+
applyCameraShake(frameImage, backgroundWidth, backgroundHeight, shakeOffset.x, shakeOffset.y);
|
|
743
|
+
}
|
|
651
744
|
const isFrameProcessed = ffmpegVideoWriter.stdin.write(frameImage.data);
|
|
652
745
|
if (!isFrameProcessed) {
|
|
653
746
|
const isDrained = await waitDrain(
|
package/src/subtitleConvert.ts
CHANGED
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
/** LRC timestamp line: [mm:ss.xx] text — hundredths optional after dot. */
|
|
2
|
-
const LRC_LINE = /^\[(\d+):(\d+)(?:\.(\d+))?\]\s*(.*)$/;
|
|
3
|
-
|
|
4
|
-
export const isLikelyLrc = (text: string): boolean => {
|
|
5
|
-
const body = text.replace(/^\uFEFF/, '');
|
|
6
|
-
for (const line of body.split(/\r?\n/)) {
|
|
7
|
-
const t = line.trim();
|
|
8
|
-
if (t.length === 0) {
|
|
9
|
-
continue;
|
|
10
|
-
}
|
|
11
|
-
return LRC_LINE.test(t);
|
|
12
|
-
}
|
|
13
|
-
return false;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const msToSrtTime = (ms: number): string => {
|
|
17
|
-
const h = Math.floor(ms / 3600000);
|
|
18
|
-
const m = Math.floor((ms % 3600000) / 60000);
|
|
19
|
-
const s = Math.floor((ms % 60000) / 1000);
|
|
20
|
-
const milli = ms % 1000;
|
|
21
|
-
const pad = (n: number, w: number) => String(n).padStart(w, '0');
|
|
22
|
-
return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)},${pad(milli, 3)}`;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const DEFAULT_TAIL_MS = 2000;
|
|
26
|
-
|
|
27
|
-
export const lrcToSrt = (lrc: string): string => {
|
|
28
|
-
const body = lrc.replace(/^\uFEFF/, '');
|
|
29
|
-
const entries: { startMs: number; text: string }[] = [];
|
|
30
|
-
for (const line of body.split(/\r?\n/)) {
|
|
31
|
-
const match = line.trim().match(LRC_LINE);
|
|
32
|
-
if (!match) {
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
const min = parseInt(match[1], 10);
|
|
36
|
-
const sec = parseInt(match[2], 10);
|
|
37
|
-
const centisec =
|
|
38
|
-
match[3] !== undefined
|
|
39
|
-
? parseInt(match[3].padEnd(2, '0').slice(0, 2), 10)
|
|
40
|
-
: 0;
|
|
41
|
-
const startMs = (min * 60 + sec) * 1000 + centisec * 10;
|
|
42
|
-
const text = (match[4] || '').trim();
|
|
43
|
-
if (text.length > 0) {
|
|
44
|
-
entries.push({ startMs, text });
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
entries.sort((a, b) => a.startMs - b.startMs);
|
|
48
|
-
let out = '';
|
|
49
|
-
for (let i = 0; i < entries.length; i++) {
|
|
50
|
-
const start = entries[i].startMs;
|
|
51
|
-
const end =
|
|
52
|
-
i + 1 < entries.length
|
|
53
|
-
? Math.max(entries[i + 1].startMs, start + 1)
|
|
54
|
-
: start + DEFAULT_TAIL_MS;
|
|
55
|
-
out += `${i + 1}\n`;
|
|
56
|
-
out += `${msToSrtTime(start)} --> ${msToSrtTime(end)}\n`;
|
|
57
|
-
out += `${entries[i].text}\n\n`;
|
|
58
|
-
}
|
|
59
|
-
return out.length > 0 ? out.replace(/\n*$/, '\n') : '';
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
export const normalizeInlineSubtitlesToSrt = (text: string): string =>
|
|
63
|
-
isLikelyLrc(text) ? lrcToSrt(text) : text;
|
|
1
|
+
/** LRC timestamp line: [mm:ss.xx] text — hundredths optional after dot. */
|
|
2
|
+
const LRC_LINE = /^\[(\d+):(\d+)(?:\.(\d+))?\]\s*(.*)$/;
|
|
3
|
+
|
|
4
|
+
export const isLikelyLrc = (text: string): boolean => {
|
|
5
|
+
const body = text.replace(/^\uFEFF/, '');
|
|
6
|
+
for (const line of body.split(/\r?\n/)) {
|
|
7
|
+
const t = line.trim();
|
|
8
|
+
if (t.length === 0) {
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
return LRC_LINE.test(t);
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const msToSrtTime = (ms: number): string => {
|
|
17
|
+
const h = Math.floor(ms / 3600000);
|
|
18
|
+
const m = Math.floor((ms % 3600000) / 60000);
|
|
19
|
+
const s = Math.floor((ms % 60000) / 1000);
|
|
20
|
+
const milli = ms % 1000;
|
|
21
|
+
const pad = (n: number, w: number) => String(n).padStart(w, '0');
|
|
22
|
+
return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)},${pad(milli, 3)}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TAIL_MS = 2000;
|
|
26
|
+
|
|
27
|
+
export const lrcToSrt = (lrc: string): string => {
|
|
28
|
+
const body = lrc.replace(/^\uFEFF/, '');
|
|
29
|
+
const entries: { startMs: number; text: string }[] = [];
|
|
30
|
+
for (const line of body.split(/\r?\n/)) {
|
|
31
|
+
const match = line.trim().match(LRC_LINE);
|
|
32
|
+
if (!match) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const min = parseInt(match[1], 10);
|
|
36
|
+
const sec = parseInt(match[2], 10);
|
|
37
|
+
const centisec =
|
|
38
|
+
match[3] !== undefined
|
|
39
|
+
? parseInt(match[3].padEnd(2, '0').slice(0, 2), 10)
|
|
40
|
+
: 0;
|
|
41
|
+
const startMs = (min * 60 + sec) * 1000 + centisec * 10;
|
|
42
|
+
const text = (match[4] || '').trim();
|
|
43
|
+
if (text.length > 0) {
|
|
44
|
+
entries.push({ startMs, text });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
entries.sort((a, b) => a.startMs - b.startMs);
|
|
48
|
+
let out = '';
|
|
49
|
+
for (let i = 0; i < entries.length; i++) {
|
|
50
|
+
const start = entries[i].startMs;
|
|
51
|
+
const end =
|
|
52
|
+
i + 1 < entries.length
|
|
53
|
+
? Math.max(entries[i + 1].startMs, start + 1)
|
|
54
|
+
: start + DEFAULT_TAIL_MS;
|
|
55
|
+
out += `${i + 1}\n`;
|
|
56
|
+
out += `${msToSrtTime(start)} --> ${msToSrtTime(end)}\n`;
|
|
57
|
+
out += `${entries[i].text}\n\n`;
|
|
58
|
+
}
|
|
59
|
+
return out.length > 0 ? out.replace(/\n*$/, '\n') : '';
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const normalizeInlineSubtitlesToSrt = (text: string): string =>
|
|
63
|
+
isLikelyLrc(text) ? lrcToSrt(text) : text;
|
package/src/tests/audio.test.ts
CHANGED
|
@@ -1,96 +1,102 @@
|
|
|
1
|
-
import { expect } from 'chai';
|
|
2
|
-
import { Readable, Writable, Pipe } from 'stream';
|
|
3
|
-
import child_process, { ChildProcessWithoutNullStreams } from 'child_process';
|
|
4
|
-
import {
|
|
5
|
-
bufferToUInt8,
|
|
6
|
-
normalizeAudioData,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import { Readable, Writable, Pipe } from 'stream';
|
|
3
|
+
import child_process, { ChildProcessWithoutNullStreams } from 'child_process';
|
|
4
|
+
import {
|
|
5
|
+
bufferToUInt8,
|
|
6
|
+
normalizeAudioData,
|
|
7
|
+
pcmU8ToFloatSamples,
|
|
8
|
+
spawnFfmpegAudioReader,
|
|
9
|
+
skipEvery,
|
|
10
|
+
getPeaks,
|
|
11
|
+
correctPeaks,
|
|
12
|
+
createSpectrumsProcessor,
|
|
13
|
+
} from '../audio';
|
|
14
|
+
import { createSandbox } from 'sinon';
|
|
15
|
+
|
|
16
|
+
let childProcessStream = {
|
|
17
|
+
stdin: new Writable(),
|
|
18
|
+
stderr: new Readable(),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const videoSandbox = createSandbox();
|
|
22
|
+
|
|
23
|
+
describe('audio', function () {
|
|
24
|
+
|
|
25
|
+
this.beforeAll(function () {
|
|
26
|
+
videoSandbox.stub(child_process, 'spawn').returns(childProcessStream as ChildProcessWithoutNullStreams);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
this.afterAll(function () {
|
|
30
|
+
videoSandbox.restore();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('bufferToUInt8', function () {
|
|
34
|
+
const expected = [1, 2, 3, 4, 5];
|
|
35
|
+
const buffer = Buffer.from(new Uint8Array(expected));
|
|
36
|
+
const result = bufferToUInt8(buffer, 0, 5);
|
|
37
|
+
expect(result).deep.equal(expected);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('normalizeAudioData', function () {
|
|
41
|
+
const result = normalizeAudioData([4, 5, 23, 78, 2]);
|
|
42
|
+
const expected = [-0.96875, -0.9609375, -0.8203125, -0.390625, -0.984375];
|
|
43
|
+
expect(result).deep.equal(expected);
|
|
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
|
+
|
|
51
|
+
it('spawnFfmpegAudioReader', function (done) {
|
|
52
|
+
const childProcessReadableStream = new Readable();
|
|
53
|
+
const childProcessWritableStream = new Writable();
|
|
54
|
+
childProcessReadableStream._read = () => { done(); };
|
|
55
|
+
(<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
|
|
56
|
+
|
|
57
|
+
childProcessStream.stdin = childProcessWritableStream;
|
|
58
|
+
childProcessStream.stderr = childProcessReadableStream;
|
|
59
|
+
|
|
60
|
+
const ffmpegProcess = spawnFfmpegAudioReader('filename', 'format');
|
|
61
|
+
ffmpegProcess.stderr.on('data', (data) => {
|
|
62
|
+
expect(data).equal('some data');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
childProcessStream.stderr.emit('data', 'some data');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('skipEvery', () => {
|
|
69
|
+
const sampleData = [1, 2, 3, 4];
|
|
70
|
+
const result = sampleData.filter(skipEvery(2));
|
|
71
|
+
const expected = [1, 3];
|
|
72
|
+
expect(result).deep.equal(expected);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('getPeaks', () => {
|
|
76
|
+
const sampleData = [1, 2, 3, 4];
|
|
77
|
+
const prevPeaks = [2, 2, 2, 5];
|
|
78
|
+
const resultNoPrevPeaks = getPeaks(sampleData);
|
|
79
|
+
expect(resultNoPrevPeaks).deep.equal(sampleData);
|
|
80
|
+
const resultWithPrevPeaks = getPeaks(sampleData, prevPeaks);
|
|
81
|
+
const expectedWithPrevPeaks = [2, 2, 3, 5];
|
|
82
|
+
expect(resultWithPrevPeaks).deep.equal(expectedWithPrevPeaks);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('correctPeaks', () => {
|
|
86
|
+
const spectrums = [1, 2, 3, 4];
|
|
87
|
+
const peaks = [2, 2, 3, 5];
|
|
88
|
+
const result = correctPeaks(spectrums, peaks);
|
|
89
|
+
const expected = [1 / 3, 2 / 3, 1, 0.8];
|
|
90
|
+
expect(result).deep.equal(expected);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
it('createSpectrumsProcessor', () => {
|
|
95
|
+
const spectrumsProcessor = createSpectrumsProcessor(44100);
|
|
96
|
+
|
|
97
|
+
const frame0 = spectrumsProcessor(() => ([1, 2, 3, 4]));
|
|
98
|
+
expect(frame0).to.have.length(24);
|
|
99
|
+
const frame1 = spectrumsProcessor(() => ([2, 2, 6, 2]));
|
|
100
|
+
expect(frame1).to.have.length(24);
|
|
101
|
+
});
|
|
102
|
+
});
|