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.
Files changed (53) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -2
  3. package/dist/audio.d.ts +13 -12
  4. package/dist/audio.js +128 -120
  5. package/dist/audio.js.map +1 -1
  6. package/dist/beats.d.ts +39 -19
  7. package/dist/beats.js +220 -43
  8. package/dist/beats.js.map +1 -1
  9. package/dist/bpmEncoder.d.ts +14 -14
  10. package/dist/bpmEncoder.js +97 -97
  11. package/dist/config.d.ts +78 -77
  12. package/dist/config.js +377 -369
  13. package/dist/config.js.map +1 -1
  14. package/dist/dsp.d.ts +16 -16
  15. package/dist/dsp.js +76 -76
  16. package/dist/highlight.d.ts +34 -32
  17. package/dist/highlight.js +334 -325
  18. package/dist/highlight.js.map +1 -1
  19. package/dist/image.d.ts +97 -79
  20. package/dist/image.js +545 -458
  21. package/dist/image.js.map +1 -1
  22. package/dist/index.d.ts +87 -79
  23. package/dist/index.js +597 -534
  24. package/dist/index.js.map +1 -1
  25. package/dist/subtitleConvert.d.ts +3 -3
  26. package/dist/subtitleConvert.js +61 -61
  27. package/dist/video.d.ts +150 -80
  28. package/dist/video.js +677 -434
  29. package/dist/video.js.map +1 -1
  30. package/dist/waitForEventLoop.d.ts +1 -1
  31. package/dist/waitForEventLoop.js +5 -5
  32. package/package.json +62 -61
  33. package/src/audio.ts +129 -120
  34. package/src/beats.ts +279 -68
  35. package/src/bpmEncoder.ts +97 -97
  36. package/src/config.ts +487 -476
  37. package/src/dsp.ts +95 -95
  38. package/src/highlight.ts +291 -284
  39. package/src/image.ts +707 -602
  40. package/src/index.ts +124 -31
  41. package/src/subtitleConvert.ts +63 -63
  42. package/src/tests/audio.test.ts +102 -96
  43. package/src/tests/beats.test.ts +134 -0
  44. package/src/tests/config.test.ts +534 -496
  45. package/src/tests/dsp.test.ts +24 -24
  46. package/src/tests/highlight.test.ts +178 -178
  47. package/src/tests/image.test.ts +319 -222
  48. package/src/tests/index.test.ts +180 -180
  49. package/src/tests/subtitleConvert.test.ts +23 -23
  50. package/src/tests/video.test.ts +535 -285
  51. package/src/typings.d.ts +28 -6
  52. package/src/video.ts +856 -550
  53. 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
- getBackgroundVideoPath,
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
- path: string;
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
- backgroundVideoPath: string | undefined;
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
- backgroundVideoPath,
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 && backgroundVideoPath) {
297
- const videoInfo = await getVideoInfo(backgroundVideoPath);
298
- const sceneChanges = autoEditVideo
299
- ? await detectSceneChanges(backgroundVideoPath)
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
- const segments = buildBeatSyncedSegments(
308
- beatFrameIndices,
309
- framesCount,
310
- sceneChanges,
311
- videoInfo.duration,
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 concatFilePath = writeConcatFile(segments, backgroundVideoPath, videoInfo.duration, fps);
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: videoInfo.width,
324
- sourceHeight: videoInfo.height,
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: ${backgroundVideoPath}`);
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 backgroundVideoPath = getBackgroundVideoPath(config);
503
+ const backgroundVideoPaths = getBackgroundVideoPaths(config);
456
504
  const backgroundImagePath = getBackgroundImagePath(config);
457
- const useVideoBackground = !!backgroundVideoPath;
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 (const pass of passes) {
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
- backgroundVideoPath,
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: getAutoEditVideo(config),
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(
@@ -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;
@@ -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
- spawnFfmpegAudioReader,
8
- skipEvery,
9
- getPeaks,
10
- correctPeaks,
11
- createSpectrumsProcessor,
12
- } from '../audio';
13
- import { createSandbox } from 'sinon';
14
-
15
- let childProcessStream = {
16
- stdin: new Writable(),
17
- stderr: new Readable(),
18
- };
19
-
20
- const videoSandbox = createSandbox();
21
-
22
- describe('audio', function () {
23
-
24
- this.beforeAll(function () {
25
- videoSandbox.stub(child_process, 'spawn').returns(childProcessStream as ChildProcessWithoutNullStreams);
26
- });
27
-
28
- this.afterAll(function () {
29
- videoSandbox.restore();
30
- });
31
-
32
- it('bufferToUInt8', function () {
33
- const expected = [1, 2, 3, 4, 5];
34
- const buffer = Buffer.from(new Uint8Array(expected));
35
- const result = bufferToUInt8(buffer, 0, 5);
36
- expect(result).deep.equal(expected);
37
- });
38
-
39
- it('normalizeAudioData', function () {
40
- const result = normalizeAudioData([4, 5, 23, 78, 2]);
41
- const expected = [-0.96875, -0.9609375, -0.8203125, -0.390625, -0.984375];
42
- expect(result).deep.equal(expected);
43
- });
44
-
45
- it('spawnFfmpegAudioReader', function (done) {
46
- const childProcessReadableStream = new Readable();
47
- const childProcessWritableStream = new Writable();
48
- childProcessReadableStream._read = () => { done(); };
49
- (<Pipe>childProcessWritableStream.pipe) = () => childProcessWritableStream;
50
-
51
- childProcessStream.stdin = childProcessWritableStream;
52
- childProcessStream.stderr = childProcessReadableStream;
53
-
54
- const ffmpegProcess = spawnFfmpegAudioReader('filename', 'format');
55
- ffmpegProcess.stderr.on('data', (data) => {
56
- expect(data).equal('some data');
57
- });
58
-
59
- childProcessStream.stderr.emit('data', 'some data');
60
- });
61
-
62
- it('skipEvery', () => {
63
- const sampleData = [1, 2, 3, 4];
64
- const result = sampleData.filter(skipEvery(2));
65
- const expected = [1, 3];
66
- expect(result).deep.equal(expected);
67
- });
68
-
69
- it('getPeaks', () => {
70
- const sampleData = [1, 2, 3, 4];
71
- const prevPeaks = [2, 2, 2, 5];
72
- const resultNoPrevPeaks = getPeaks(sampleData);
73
- expect(resultNoPrevPeaks).deep.equal(sampleData);
74
- const resultWithPrevPeaks = getPeaks(sampleData, prevPeaks);
75
- const expectedWithPrevPeaks = [2, 2, 3, 5];
76
- expect(resultWithPrevPeaks).deep.equal(expectedWithPrevPeaks);
77
- });
78
-
79
- it('correctPeaks', () => {
80
- const spectrums = [1, 2, 3, 4];
81
- const peaks = [2, 2, 3, 5];
82
- const result = correctPeaks(spectrums, peaks);
83
- const expected = [1 / 3, 2 / 3, 1, 0.8];
84
- expect(result).deep.equal(expected);
85
- });
86
-
87
-
88
- it('createSpectrumsProcessor', () => {
89
- const spectrumsProcessor = createSpectrumsProcessor(44100);
90
-
91
- const frame0 = spectrumsProcessor(() => ([1, 2, 3, 4]));
92
- expect(frame0).to.have.length(24);
93
- const frame1 = spectrumsProcessor(() => ([2, 2, 6, 2]));
94
- expect(frame1).to.have.length(24);
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
+ });