nodejs-audio-visualizer 5.1.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 -13
- package/dist/audio.js +128 -128
- package/dist/beats.d.ts +39 -39
- package/dist/beats.js +220 -220
- 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 -34
- package/dist/highlight.js +334 -334
- package/dist/image.d.ts +97 -92
- package/dist/image.js +545 -538
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +87 -79
- package/dist/index.js +597 -566
- 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 -120
- package/dist/video.js +677 -582
- package/dist/video.js.map +1 -1
- package/dist/waitForEventLoop.d.ts +1 -1
- package/dist/waitForEventLoop.js +5 -5
- package/package.json +62 -62
- package/src/audio.ts +129 -129
- package/src/beats.ts +279 -279
- package/src/bpmEncoder.ts +97 -97
- package/src/config.ts +487 -476
- package/src/dsp.ts +95 -95
- package/src/highlight.ts +291 -291
- package/src/image.ts +707 -700
- package/src/index.ts +79 -37
- package/src/subtitleConvert.ts +63 -63
- package/src/tests/audio.test.ts +102 -102
- package/src/tests/beats.test.ts +134 -134
- 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 -308
- package/src/tests/index.test.ts +180 -180
- package/src/tests/subtitleConvert.test.ts +23 -23
- package/src/tests/video.test.ts +535 -447
- package/src/typings.d.ts +28 -28
- package/src/video.ts +856 -722
- 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,13 +33,14 @@ import {
|
|
|
33
33
|
getPolarColor,
|
|
34
34
|
getPolarOpacityParsed,
|
|
35
35
|
getAutoEditVideo,
|
|
36
|
+
getCameraShakeEnabled,
|
|
36
37
|
getAudioAutoHighlight,
|
|
37
38
|
getAudioAutoHighlightCount,
|
|
38
39
|
} from './config';
|
|
39
40
|
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
|
+
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, getCutFrameIndices, 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
45
|
import { createBeatDetector, estimateTempo, TempoEstimate, beatGridFrameIndices, tempoForWindow, MAX_TEMPO_ANALYSIS_SECONDS } from './beats';
|
|
45
46
|
export { BeatInfo, BeatDetectorOptions, TempoEstimate, estimateTempo, beatGridFrameIndices, shiftTempoPhase, tempoForWindow } from './beats';
|
|
@@ -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,7 +272,7 @@ 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[];
|
|
269
278
|
beatIntensities?: number[];
|
|
@@ -273,6 +282,8 @@ async function prepareBackgroundForRender(params: {
|
|
|
273
282
|
autoEditVideo: boolean;
|
|
274
283
|
readVideoFrameTimeout: number;
|
|
275
284
|
tempo?: TempoEstimate | null;
|
|
285
|
+
/** First video to play for `video.path` arrays without `autoEdit`. */
|
|
286
|
+
startVideoIndex?: number;
|
|
276
287
|
}): Promise<{
|
|
277
288
|
backgroundWidth: number;
|
|
278
289
|
backgroundHeight: number;
|
|
@@ -286,7 +297,7 @@ async function prepareBackgroundForRender(params: {
|
|
|
286
297
|
}> {
|
|
287
298
|
const {
|
|
288
299
|
useVideoBackground,
|
|
289
|
-
|
|
300
|
+
backgroundVideoPaths,
|
|
290
301
|
backgroundImagePath,
|
|
291
302
|
beatFrameIndices,
|
|
292
303
|
beatIntensities,
|
|
@@ -296,32 +307,56 @@ async function prepareBackgroundForRender(params: {
|
|
|
296
307
|
autoEditVideo,
|
|
297
308
|
readVideoFrameTimeout,
|
|
298
309
|
tempo,
|
|
310
|
+
startVideoIndex = 0,
|
|
299
311
|
} = params;
|
|
300
312
|
|
|
301
|
-
if (useVideoBackground &&
|
|
302
|
-
const
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const backgroundWidth = outputResolution?.width ?? videoInfo.width;
|
|
307
|
-
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;
|
|
308
318
|
|
|
309
319
|
const videoFrameSize = backgroundWidth * backgroundHeight * 3;
|
|
310
320
|
const encodeVideoFrame = createBgrFrameEncoder({ width: backgroundWidth, height: backgroundHeight });
|
|
311
321
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
+
}
|
|
323
357
|
|
|
324
|
-
const
|
|
358
|
+
const videoSources = videoInfos.map((info, i) => ({ path: backgroundVideoPaths[i], duration: info.duration }));
|
|
359
|
+
const concatFilePath = writeConcatFile(segments, videoSources, fps);
|
|
325
360
|
const cutFrameIndices = autoEditVideo ? getCutFrameIndices(segments) : [];
|
|
326
361
|
|
|
327
362
|
const videoFrameReader = spawnConcatVideoFrameReader({
|
|
@@ -331,8 +366,8 @@ async function prepareBackgroundForRender(params: {
|
|
|
331
366
|
...(outputResolution && {
|
|
332
367
|
width: backgroundWidth,
|
|
333
368
|
height: backgroundHeight,
|
|
334
|
-
sourceWidth:
|
|
335
|
-
sourceHeight:
|
|
369
|
+
sourceWidth: primaryVideoInfo.width,
|
|
370
|
+
sourceHeight: primaryVideoInfo.height,
|
|
336
371
|
}),
|
|
337
372
|
});
|
|
338
373
|
|
|
@@ -342,7 +377,7 @@ async function prepareBackgroundForRender(params: {
|
|
|
342
377
|
readVideoFrameTimeout,
|
|
343
378
|
);
|
|
344
379
|
if (!firstFrame) {
|
|
345
|
-
throw new Error(`Could not read frames from video: ${
|
|
380
|
+
throw new Error(`Could not read frames from video: ${backgroundVideoPaths.join(', ')}`);
|
|
346
381
|
}
|
|
347
382
|
const defaultColor = getVideoFrameColor(firstFrame, backgroundWidth, backgroundHeight);
|
|
348
383
|
const staticBackgroundBuffer = encodeVideoFrame(firstFrame);
|
|
@@ -465,9 +500,9 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
465
500
|
subtitleFileIsTemporary = true;
|
|
466
501
|
}
|
|
467
502
|
}
|
|
468
|
-
const
|
|
503
|
+
const backgroundVideoPaths = getBackgroundVideoPaths(config);
|
|
469
504
|
const backgroundImagePath = getBackgroundImagePath(config);
|
|
470
|
-
const useVideoBackground = !!
|
|
505
|
+
const useVideoBackground = !!backgroundVideoPaths && backgroundVideoPaths.length > 0;
|
|
471
506
|
|
|
472
507
|
const audioReader = await createAudioBuffer(audioFilePath, FFMPEG_FORMAT);
|
|
473
508
|
const audioBuffer = audioReader.audioBuffer;
|
|
@@ -543,6 +578,8 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
543
578
|
}
|
|
544
579
|
|
|
545
580
|
const autoEditVideo = getAutoEditVideo(config);
|
|
581
|
+
const cameraShakeEnabled = getCameraShakeEnabled(config);
|
|
582
|
+
const shakeDelayFrames = Math.round(FPS * CAMERA_SHAKE_DELAY_SECONDS);
|
|
546
583
|
let trackTempo: TempoEstimate | null = null;
|
|
547
584
|
if (autoEditVideo) {
|
|
548
585
|
const sampleRateNum = Number(sampleRate);
|
|
@@ -618,7 +655,8 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
618
655
|
let exitReason: string | undefined;
|
|
619
656
|
const outputVideoFiles: string[] = [];
|
|
620
657
|
try {
|
|
621
|
-
passLoop: for (
|
|
658
|
+
passLoop: for (let passIndex = 0; passIndex < passes.length; passIndex++) {
|
|
659
|
+
const pass = passes[passIndex];
|
|
622
660
|
const tempo = trackTempo
|
|
623
661
|
? tempoForWindow(trackTempo, pass.startFrame, FPS, pass.frameCount, pass.beatIndices)
|
|
624
662
|
: null;
|
|
@@ -634,9 +672,10 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
634
672
|
videoFrameSize,
|
|
635
673
|
encodeVideoFrame,
|
|
636
674
|
concatFilePath,
|
|
675
|
+
cutFrameIndices,
|
|
637
676
|
} = await prepareBackgroundForRender({
|
|
638
677
|
useVideoBackground,
|
|
639
|
-
|
|
678
|
+
backgroundVideoPaths,
|
|
640
679
|
backgroundImagePath,
|
|
641
680
|
beatFrameIndices: pass.beatIndices,
|
|
642
681
|
beatIntensities: pass.beatIntensities,
|
|
@@ -646,14 +685,17 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
646
685
|
autoEditVideo,
|
|
647
686
|
readVideoFrameTimeout: videoTimeouts.readVideoFrame,
|
|
648
687
|
...(tempo ? { tempo } : {}),
|
|
688
|
+
// Each separate highlight output opens with a different video from the array.
|
|
689
|
+
startVideoIndex: passIndex,
|
|
649
690
|
});
|
|
650
691
|
reportRenderProgress();
|
|
651
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));
|
|
652
696
|
const shakeFrames = new Set(
|
|
653
|
-
autoEditVideo
|
|
654
|
-
? (
|
|
655
|
-
? beatGridFrameIndices(tempo, pass.frameCount, CAMERA_SHAKE_EVERY_BEATS)
|
|
656
|
-
: pass.beatIndices.filter(frameIndex => frameIndex > 0).filter((_, i) => i % CAMERA_SHAKE_EVERY_BEATS === 0))
|
|
697
|
+
autoEditVideo && cameraShakeEnabled
|
|
698
|
+
? (cutFrameIndices.length > 0 ? cutFrameIndices : beatShakeFrames())
|
|
657
699
|
: [],
|
|
658
700
|
);
|
|
659
701
|
const createVisualizerFrame = createVisualizerFrameGenerator(
|
|
@@ -695,7 +737,7 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
695
737
|
spectrum,
|
|
696
738
|
};
|
|
697
739
|
const frameImage = createVisualizerFrame(commonVisualizerFrameProps);
|
|
698
|
-
const shakeOffset = getCutShakeOffset(i, shakeFrames, shakeAmplitudes);
|
|
740
|
+
const shakeOffset = getCutShakeOffset(i, shakeFrames, shakeAmplitudes, shakeDelayFrames);
|
|
699
741
|
if (shakeOffset.x !== 0 || shakeOffset.y !== 0) {
|
|
700
742
|
applyCameraShake(frameImage, backgroundWidth, backgroundHeight, shakeOffset.x, shakeOffset.y);
|
|
701
743
|
}
|
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,102 +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
|
-
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
|
-
});
|
|
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
|
+
});
|