nodejs-audio-visualizer 4.0.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  getAudioFilePath,
3
3
  getBackgroundImagePath,
4
+ getBackgroundVideoPath,
4
5
  getOutVideoPath,
5
6
  getFPS,
6
7
  getSpectrumBusMargin,
@@ -13,6 +14,7 @@ import {
13
14
  getFfmpeg_cfr,
14
15
  getFfmpeg_preset,
15
16
  getFrame_processing_delay,
17
+ getOutputResolution,
16
18
  rotationAliasValues,
17
19
  getSpectrumRotation,
18
20
  getSpectrumEffect,
@@ -25,12 +27,14 @@ import {
25
27
  getPolarEffect,
26
28
  getPolarColor,
27
29
  getPolarOpacityParsed,
30
+ getAutoEditVideo,
28
31
  } from './config';
29
32
  import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor } from './audio';
30
- import { parseImage, getImageColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps } from './image';
31
- import { spawnFfmpegVideoWriter, getProgress, calculateProgress, waitDrain } from './video';
32
- import { createBpmEncoder, EncodedBmp } from './bpmEncoder';
33
- import { BmpDecoder } from 'bmp-js';
33
+ import { parseImage, getImageColor, getVideoFrameColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps } from './image';
34
+ import { spawnFfmpegVideoWriter, getProgress, calculateProgress, waitDrain, getVideoInfo, spawnVideoFrameReader, readVideoFrame, detectSceneChanges, buildBeatSyncedSegments, writeConcatFile, spawnConcatVideoFrameReader, cleanupConcatFile } from './video';
35
+ import { createBpmEncoder, createBgrFrameEncoder, EncodedBmp } from './bpmEncoder';
36
+ import { createBeatDetector } from './beats';
37
+ export { BeatInfo, BeatDetectorOptions } from './beats';
34
38
 
35
39
  export const PCM_FORMAT = {
36
40
  bit: 8,
@@ -44,12 +48,20 @@ export interface Config {
44
48
  audio: {
45
49
  path: string
46
50
  };
47
- image: {
51
+ image?: {
48
52
  path: string;
49
53
  };
54
+ video?: {
55
+ path: string;
56
+ autoEdit?: boolean;
57
+ };
50
58
  outVideo: {
51
59
  path: string;
52
60
  fps?: number;
61
+ resolution?: {
62
+ width: number;
63
+ height: number;
64
+ };
53
65
  spectrum?: {
54
66
  width?: SpectrumSizeValue;
55
67
  height?: SpectrumSizeValue;
@@ -95,17 +107,19 @@ const sleep = (timeout: number) =>
95
107
 
96
108
  const createVisualizerFrameGenerator = (
97
109
  config: Config,
98
- backgroundImage: BmpDecoder,
110
+ backgroundWidth: number,
111
+ backgroundHeight: number,
112
+ defaultColor: Color,
99
113
  spectrumBusMargin: number
100
114
  ): (params: CommonVisualizerFrameProps) => EncodedBmp => {
101
115
  if (!config.outVideo.spectrum) {
102
116
  const createPolarVisualizerFrame = createPolarVisualizerFrameGenerator();
103
- const polarX = getPolarXAbsolute(config, backgroundImage.width);
104
- const polarY = getPolarYAbsolute(config, backgroundImage.height);
117
+ const polarX = getPolarXAbsolute(config, backgroundWidth);
118
+ const polarY = getPolarYAbsolute(config, backgroundHeight);
105
119
  const polarInnerRadius = getPolarInnerRadius(config);
106
120
  const polarMaxBarLength = getPolarMaxBarLength(config);
107
121
  const polarBarWidth = getPolarBarWidth(config);
108
- const polarColor = getPolarColor(config) || invertColor(getImageColor(backgroundImage));
122
+ const polarColor = getPolarColor(config) || invertColor(defaultColor);
109
123
  const polarEffect = getPolarEffect(config);
110
124
  const polarOpacity = getPolarOpacityParsed(config);
111
125
 
@@ -123,18 +137,18 @@ const createVisualizerFrameGenerator = (
123
137
  });
124
138
  };
125
139
  }
126
- const createPolarVisualizerFrame = createSpectrumVisualizerFrameGenerator();
127
- const spectrumWidth = getSpectrumWidthAbsolute(config, backgroundImage.width);
128
- const spectrumHeight = getSpectrumHeightAbsolute(config, backgroundImage.height);
129
- const spectrumX = getSpectrumXAbsolute(config, spectrumWidth, backgroundImage.width);
130
- const spectrumY = getSpectrumYAbsolute(config, spectrumHeight, backgroundImage.height);
140
+ const createSpectrumVisualizerFrame = createSpectrumVisualizerFrameGenerator();
141
+ const spectrumWidth = getSpectrumWidthAbsolute(config, backgroundWidth);
142
+ const spectrumHeight = getSpectrumHeightAbsolute(config, backgroundHeight);
143
+ const spectrumX = getSpectrumXAbsolute(config, spectrumWidth, backgroundWidth);
144
+ const spectrumY = getSpectrumYAbsolute(config, spectrumHeight, backgroundHeight);
131
145
  const spectrumRotation = getSpectrumRotation(config);
132
- const spectrumColor = getSpectrumColor(config) || invertColor(getImageColor(backgroundImage));
146
+ const spectrumColor = getSpectrumColor(config) || invertColor(defaultColor);
133
147
  const spectrumEffect = getSpectrumEffect(config);
134
148
  const spectrumOpacity = getSpectrumOpacityParsed(config);
135
149
 
136
150
  return (params: CommonVisualizerFrameProps) => {
137
- return createPolarVisualizerFrame({
151
+ return createSpectrumVisualizerFrame({
138
152
  ...params,
139
153
  size: { width: spectrumWidth, height: spectrumHeight },
140
154
  position: { x: spectrumX, y: spectrumY },
@@ -147,19 +161,201 @@ const createVisualizerFrameGenerator = (
147
161
  };
148
162
  };
149
163
 
164
+ interface PreProcessedAudio {
165
+ spectrums: number[][];
166
+ beatFrameIndices: number[];
167
+ }
168
+
169
+ const PREPROCESS_YIELD_EVERY = 256;
170
+
171
+ const preProcessAudio = async (
172
+ audioBuffer: Buffer,
173
+ sampleRate: number,
174
+ fps: number,
175
+ framesCount: number,
176
+ ): Promise<PreProcessedAudio> => {
177
+ const audioDataStep = Math.trunc(audioBuffer.length / framesCount);
178
+ const processingBuffer = new Float32Array(PROCESSING_BUFFER_SIZE).fill(0);
179
+ const skipFramesCount = fps < 45 ? 1 : 2;
180
+ const processSpectrum = createSpectrumsProcessor(sampleRate, skipFramesCount);
181
+ const detectBeat = createBeatDetector(fps);
182
+
183
+ const spectrums: number[][] = [];
184
+ const beatFrameIndices: number[] = [];
185
+
186
+ for (let i = 0; i < framesCount; i++) {
187
+ const currentFrameData = PCM_FORMAT.parseFunction(audioBuffer, i * audioDataStep, i * audioDataStep + audioDataStep);
188
+ processingBuffer.copyWithin(0, currentFrameData.length);
189
+ processingBuffer.set(currentFrameData, PROCESSING_BUFFER_SIZE - currentFrameData.length);
190
+
191
+ const audioDataParser = () => Array.from(processingBuffer);
192
+ const spectrum = processSpectrum(audioDataParser);
193
+ const beat = detectBeat(spectrum);
194
+
195
+ spectrums.push(spectrum);
196
+ if (beat.isBeat) {
197
+ beatFrameIndices.push(i);
198
+ }
199
+
200
+ if ((i + 1) % PREPROCESS_YIELD_EVERY === 0) {
201
+ await new Promise<void>(resolve => setImmediate(resolve));
202
+ }
203
+ }
204
+
205
+ return { spectrums, beatFrameIndices };
206
+ };
207
+
208
+ async function prepareBackgroundForRender(params: {
209
+ useVideoBackground: boolean;
210
+ backgroundVideoPath: string | undefined;
211
+ backgroundImagePath: string | undefined;
212
+ beatFrameIndices: number[];
213
+ framesCount: number;
214
+ outputResolution: ReturnType<typeof getOutputResolution>;
215
+ fps: number;
216
+ autoEditVideo: boolean;
217
+ }): Promise<{
218
+ backgroundWidth: number;
219
+ backgroundHeight: number;
220
+ defaultColor: Color;
221
+ staticBackgroundBuffer: EncodedBmp;
222
+ videoFrameReader?: ReturnType<typeof spawnVideoFrameReader>;
223
+ videoFrameSize: number;
224
+ encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
225
+ concatFilePath?: string;
226
+ }> {
227
+ const {
228
+ useVideoBackground,
229
+ backgroundVideoPath,
230
+ backgroundImagePath,
231
+ beatFrameIndices,
232
+ framesCount,
233
+ outputResolution,
234
+ fps,
235
+ autoEditVideo,
236
+ } = params;
237
+
238
+ if (useVideoBackground && backgroundVideoPath) {
239
+ const videoInfo = await getVideoInfo(backgroundVideoPath);
240
+ const sceneChanges = autoEditVideo
241
+ ? await detectSceneChanges(backgroundVideoPath)
242
+ : [];
243
+ const backgroundWidth = outputResolution?.width ?? videoInfo.width;
244
+ const backgroundHeight = outputResolution?.height ?? videoInfo.height;
245
+
246
+ const videoFrameSize = backgroundWidth * backgroundHeight * 3;
247
+ const encodeVideoFrame = createBgrFrameEncoder({ width: backgroundWidth, height: backgroundHeight });
248
+
249
+ const segments = buildBeatSyncedSegments(
250
+ beatFrameIndices,
251
+ framesCount,
252
+ sceneChanges,
253
+ videoInfo.duration,
254
+ );
255
+
256
+ const concatFilePath = writeConcatFile(segments, backgroundVideoPath, videoInfo.duration, fps);
257
+
258
+ const videoFrameReader = spawnConcatVideoFrameReader({
259
+ concatFilePath,
260
+ fps,
261
+ totalFrames: framesCount,
262
+ ...(outputResolution && {
263
+ width: backgroundWidth,
264
+ height: backgroundHeight,
265
+ sourceWidth: videoInfo.width,
266
+ sourceHeight: videoInfo.height,
267
+ }),
268
+ });
269
+
270
+ const firstFrame = await readVideoFrame(videoFrameReader.stdout, videoFrameSize);
271
+ if (!firstFrame) {
272
+ throw new Error(`Could not read frames from video: ${backgroundVideoPath}`);
273
+ }
274
+ const defaultColor = getVideoFrameColor(firstFrame, backgroundWidth, backgroundHeight);
275
+ const staticBackgroundBuffer = encodeVideoFrame(firstFrame);
276
+ return {
277
+ backgroundWidth,
278
+ backgroundHeight,
279
+ defaultColor,
280
+ staticBackgroundBuffer,
281
+ videoFrameReader,
282
+ videoFrameSize,
283
+ encodeVideoFrame,
284
+ concatFilePath,
285
+ };
286
+ }
287
+
288
+ if (!backgroundImagePath) {
289
+ throw new Error('Background image path is required when not using video background.');
290
+ }
291
+ const backgroundImageBmpBuffer = await convertToBmp(
292
+ backgroundImagePath,
293
+ outputResolution?.width,
294
+ outputResolution?.height,
295
+ );
296
+ const backgroundImage = parseImage(backgroundImageBmpBuffer);
297
+ const backgroundWidth = backgroundImage.width;
298
+ const backgroundHeight = backgroundImage.height;
299
+ const defaultColor = getImageColor(backgroundImage);
300
+
301
+ const bpmEncoder = createBpmEncoder({ width: backgroundWidth, height: backgroundHeight });
302
+ const staticBackgroundBuffer = bpmEncoder(backgroundImage.data);
303
+ return {
304
+ backgroundWidth,
305
+ backgroundHeight,
306
+ defaultColor,
307
+ staticBackgroundBuffer,
308
+ videoFrameSize: 0,
309
+ };
310
+ }
311
+
312
+ async function resolveBackgroundFrameBuffer(params: {
313
+ frameIndex: number;
314
+ useVideoBackground: boolean;
315
+ videoFrameReader?: ReturnType<typeof spawnVideoFrameReader>;
316
+ videoFrameSize: number;
317
+ encodeVideoFrame?: (bgrBuffer: Buffer) => EncodedBmp;
318
+ staticBackgroundBuffer: EncodedBmp;
319
+ }): Promise<EncodedBmp> {
320
+ const {
321
+ frameIndex,
322
+ useVideoBackground,
323
+ videoFrameReader,
324
+ videoFrameSize,
325
+ encodeVideoFrame,
326
+ staticBackgroundBuffer,
327
+ } = params;
328
+
329
+ if (useVideoBackground && videoFrameReader && encodeVideoFrame) {
330
+ if (frameIndex === 0) {
331
+ return staticBackgroundBuffer;
332
+ }
333
+ const videoFrame = await readVideoFrame(videoFrameReader.stdout, videoFrameSize);
334
+ return videoFrame
335
+ ? encodeVideoFrame(videoFrame)
336
+ : staticBackgroundBuffer;
337
+ }
338
+ return staticBackgroundBuffer;
339
+ }
340
+
150
341
  export const renderAudioVisualizer = (config: Config, onProgress?: (progress: number) => any, shouldStop?: () => boolean) =>
151
342
  new Promise<number>(async (resolve) => {
152
- // Validate that only one visualizer type is specified
153
343
  if (config.outVideo.spectrum && config.outVideo.polar) {
154
344
  throw new Error('Cannot use both "spectrum" and "polar" options. Please specify only one visualizer type.');
155
345
  }
346
+ if (!config.image && !config.video) {
347
+ throw new Error('Either "image" or "video" must be specified as the background source.');
348
+ }
349
+ if (config.image && config.video) {
350
+ throw new Error('Cannot use both "image" and "video" options. Please specify only one background source.');
351
+ }
156
352
 
157
353
  const audioFilePath = getAudioFilePath(config);
158
- const backgroundImagePath = getBackgroundImagePath(config);
159
354
  const outVideoPath = getOutVideoPath(config);
355
+ const backgroundVideoPath = getBackgroundVideoPath(config);
356
+ const backgroundImagePath = getBackgroundImagePath(config);
357
+ const useVideoBackground = !!backgroundVideoPath;
160
358
 
161
- const backgroundImageBmpBuffer = await convertToBmp(backgroundImagePath);
162
- const backgroundImage = parseImage(backgroundImageBmpBuffer);
163
359
  const audioReader = await createAudioBuffer(audioFilePath, FFMPEG_FORMAT);
164
360
  const audioBuffer = audioReader.audioBuffer;
165
361
  const sampleRate = audioReader.sampleRate;
@@ -173,12 +369,38 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
173
369
  const ffmpeg_preset = getFfmpeg_preset(config);
174
370
  const frame_processing_delay = getFrame_processing_delay(config);
175
371
 
176
- const createVisualizerFrame = createVisualizerFrameGenerator(config, backgroundImage, spectrumBusMargin);
177
-
178
372
  const audioDuration = audioBuffer.length / sampleRate;
179
373
  const framesCount = Math.trunc(audioDuration * FPS);
180
- const audioDataStep = Math.trunc(audioBuffer.length / framesCount);
181
- const processingBuffer = new Float32Array(PROCESSING_BUFFER_SIZE).fill(0);
374
+ const outputResolution = getOutputResolution(config);
375
+
376
+ if (onProgress) {
377
+ onProgress(0);
378
+ }
379
+ const preprocessed = await preProcessAudio(audioBuffer, sampleRate, FPS, framesCount);
380
+
381
+ const {
382
+ backgroundWidth,
383
+ backgroundHeight,
384
+ defaultColor,
385
+ staticBackgroundBuffer,
386
+ videoFrameReader,
387
+ videoFrameSize,
388
+ encodeVideoFrame,
389
+ concatFilePath,
390
+ } = await prepareBackgroundForRender({
391
+ useVideoBackground,
392
+ backgroundVideoPath,
393
+ backgroundImagePath,
394
+ beatFrameIndices: preprocessed.beatFrameIndices,
395
+ framesCount,
396
+ outputResolution,
397
+ fps: FPS,
398
+ autoEditVideo: getAutoEditVideo(config),
399
+ });
400
+
401
+ const createVisualizerFrame = createVisualizerFrameGenerator(
402
+ config, backgroundWidth, backgroundHeight, defaultColor, spectrumBusMargin
403
+ );
182
404
 
183
405
  const ffmpegVideoWriter = spawnFfmpegVideoWriter({
184
406
  audioFilename: audioFilePath,
@@ -190,18 +412,18 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
190
412
  });
191
413
  ffmpegVideoWriter.on('exit', (code: number) => resolve(code));
192
414
 
193
- const bpmEncoder = createBpmEncoder({ width: backgroundImage.width, height: backgroundImage.height });
194
- const backgroundImageBuffer = bpmEncoder(backgroundImage.data);
195
- const skipFramesCount = FPS < 45 ? 1 : 2;
196
- const processSpectrum = createSpectrumsProcessor(sampleRate, skipFramesCount);
197
-
198
415
  for (let i = 0; i < framesCount; i++) {
199
- const currentFrameData = PCM_FORMAT.parseFunction(audioBuffer, i * audioDataStep, i * audioDataStep + audioDataStep);
200
- processingBuffer.copyWithin(0, currentFrameData.length);
201
- processingBuffer.set(currentFrameData, PROCESSING_BUFFER_SIZE - currentFrameData.length);
416
+ const spectrum = preprocessed.spectrums[i];
417
+
418
+ const backgroundImageBuffer = await resolveBackgroundFrameBuffer({
419
+ frameIndex: i,
420
+ useVideoBackground,
421
+ videoFrameReader,
422
+ videoFrameSize,
423
+ encodeVideoFrame,
424
+ staticBackgroundBuffer,
425
+ });
202
426
 
203
- const audioDataParser = () => Array.from(processingBuffer);
204
- const spectrum = processSpectrum(audioDataParser);
205
427
  const commonVisualizerFrameProps: CommonVisualizerFrameProps = {
206
428
  backgroundImageBuffer,
207
429
  spectrum,
@@ -219,5 +441,11 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
219
441
  }
220
442
  }
221
443
 
444
+ if (videoFrameReader) {
445
+ videoFrameReader.kill();
446
+ }
447
+ if (concatFilePath) {
448
+ cleanupConcatFile(concatFilePath);
449
+ }
222
450
  ffmpegVideoWriter.stdin.end();
223
451
  });