nodejs-audio-visualizer 2.1.4 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- package/dist/audio.d.ts +1 -2
- package/dist/audio.js +40 -30
- package/dist/audio.js.map +1 -1
- package/dist/config.d.ts +19 -5
- package/dist/config.js +103 -11
- package/dist/config.js.map +1 -1
- package/dist/dsp.d.ts +15 -1
- package/dist/dsp.js +58 -7
- package/dist/dsp.js.map +1 -1
- package/dist/image.d.ts +28 -2
- package/dist/image.js +255 -18
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +63 -55
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/src/audio.ts +35 -33
- package/src/config.ts +122 -12
- package/src/dsp.ts +76 -7
- package/src/image.ts +347 -22
- package/src/index.ts +100 -31
- package/src/tests/audio.test.ts +5 -14
- package/src/tests/config.test.ts +6 -8
- package/src/tests/dsp.test.ts +3 -4
- package/src/tests/image.test.ts +104 -11
- package/src/tests/index.test.ts +1 -1
- package/src/typings.d.ts +7 -0
- package/dist/spectrum.d.ts +0 -1
- package/dist/spectrum.js +0 -72
- package/dist/spectrum.js.map +0 -1
- package/src/spectrum.ts +0 -91
- package/src/tests/snapshots/frameSnapshotSpectrumDown.json +0 -1
- package/src/tests/snapshots/frameSnapshotSpectrumUp.json +0 -1
- package/src/tests/snapshots/frameSnapshotSpectrumUpOpacity50.json +0 -1
package/src/image.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { RotationAliasName } from './index';
|
|
|
2
2
|
import { decode, BmpDecoder } from 'bmp-js';
|
|
3
3
|
import { EncodedBmp } from './bpmEncoder';
|
|
4
4
|
import Jimp from 'jimp';
|
|
5
|
+
import { SpectrumEffect } from './config';
|
|
5
6
|
|
|
6
7
|
export interface Color {
|
|
7
8
|
red: number;
|
|
@@ -25,7 +26,7 @@ interface MixColorProps {
|
|
|
25
26
|
backgroundColor: Color;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export const mixValues =(value1: number, value1Opacity: number, value2: number) =>
|
|
29
|
+
export const mixValues = (value1: number, value1Opacity: number, value2: number) =>
|
|
29
30
|
Math.round(value2 * (1 - value1Opacity) + value1 * value1Opacity);
|
|
30
31
|
|
|
31
32
|
export const mixColors = ({
|
|
@@ -100,6 +101,126 @@ export const drawRect = ({
|
|
|
100
101
|
}
|
|
101
102
|
};
|
|
102
103
|
|
|
104
|
+
interface Edge {
|
|
105
|
+
yMin: number;
|
|
106
|
+
yMax: number;
|
|
107
|
+
x: number;
|
|
108
|
+
dx: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface FillRectProps {
|
|
112
|
+
imageDstBuffer: EncodedBmp;
|
|
113
|
+
points: [Position, Position, Position, Position];
|
|
114
|
+
color: Color;
|
|
115
|
+
opacity: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const buildQuadEdgeTable = (points: [Position, Position, Position, Position]): Edge[] => {
|
|
119
|
+
const edges: Edge[] = [];
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < points.length; i++) {
|
|
122
|
+
const p1 = points[i];
|
|
123
|
+
const p2 = points[(i + 1) % points.length];
|
|
124
|
+
|
|
125
|
+
if (p1.y === p2.y) continue; // Skip horizontal edges
|
|
126
|
+
|
|
127
|
+
const yMin = Math.min(p1.y, p2.y);
|
|
128
|
+
const yMax = Math.max(p1.y, p2.y);
|
|
129
|
+
const x = p1.y < p2.y ? p1.x : p2.x;
|
|
130
|
+
const dx = (p2.x - p1.x) / (p2.y - p1.y);
|
|
131
|
+
|
|
132
|
+
edges.push({ yMin, yMax, x, dx });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return edges.sort((a, b) => a.yMin - b.yMin);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fills a quadrilateral (4-sided polygon) using scan-line algorithm
|
|
140
|
+
* Only the outer edges are anti-aliased
|
|
141
|
+
*/
|
|
142
|
+
export const fillRect = ({
|
|
143
|
+
imageDstBuffer,
|
|
144
|
+
points,
|
|
145
|
+
color,
|
|
146
|
+
opacity,
|
|
147
|
+
}: FillRectProps) => {
|
|
148
|
+
// Build edge table from all 4 vertices
|
|
149
|
+
const edgeTable = buildQuadEdgeTable(points);
|
|
150
|
+
if (edgeTable.length === 0) return;
|
|
151
|
+
|
|
152
|
+
// Find the bounding box
|
|
153
|
+
const yMin = Math.min(...points.map(p => p.y));
|
|
154
|
+
const yMax = Math.max(...points.map(p => p.y));
|
|
155
|
+
|
|
156
|
+
// Active Edge Table for scan line algorithm
|
|
157
|
+
const activeEdges: Edge[] = [];
|
|
158
|
+
|
|
159
|
+
// Scan line algorithm: process each horizontal line
|
|
160
|
+
for (let y = Math.floor(yMin); y <= Math.ceil(yMax); y++) {
|
|
161
|
+
// Add edges that start at this scanline to AET
|
|
162
|
+
for (const edge of edgeTable) {
|
|
163
|
+
if (Math.floor(edge.yMin) === y) {
|
|
164
|
+
activeEdges.push({ ...edge });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Remove edges that end at this scanline from AET
|
|
169
|
+
for (let i = activeEdges.length - 1; i >= 0; i--) {
|
|
170
|
+
if (Math.ceil(activeEdges[i].yMax) <= y) {
|
|
171
|
+
activeEdges.splice(i, 1);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Sort active edges by x coordinate
|
|
176
|
+
activeEdges.sort((a, b) => a.x - b.x);
|
|
177
|
+
|
|
178
|
+
// Fill pixels between pairs of intersections
|
|
179
|
+
for (let i = 0; i < activeEdges.length; i += 2) {
|
|
180
|
+
if (i + 1 < activeEdges.length) {
|
|
181
|
+
const xStart = activeEdges[i].x;
|
|
182
|
+
const xEnd = activeEdges[i + 1].x;
|
|
183
|
+
|
|
184
|
+
// Anti-aliased rendering with sub-pixel precision
|
|
185
|
+
const xStartFloor = Math.floor(xStart);
|
|
186
|
+
const xEndCeil = Math.ceil(xEnd);
|
|
187
|
+
|
|
188
|
+
for (let x = xStartFloor; x < xEndCeil; x++) {
|
|
189
|
+
const pixelIndex = imageDstBuffer.shiftPos + y * imageDstBuffer.rowBytes + x * 3;
|
|
190
|
+
|
|
191
|
+
// Calculate coverage for anti-aliasing
|
|
192
|
+
let coverage = 1.0;
|
|
193
|
+
if (x === xStartFloor) {
|
|
194
|
+
// Left edge pixel - calculate fractional coverage
|
|
195
|
+
coverage = Math.min(1.0, 1.0 - (xStart - xStartFloor));
|
|
196
|
+
} else if (x === xEndCeil - 1) {
|
|
197
|
+
// Right edge pixel - calculate fractional coverage
|
|
198
|
+
coverage = Math.min(1.0, xEnd - Math.floor(xEnd));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Apply coverage to opacity for smooth edges
|
|
202
|
+
const effectiveOpacity = opacity * coverage;
|
|
203
|
+
|
|
204
|
+
const pixelColor = getRectPixelColor({
|
|
205
|
+
imageDstBuffer,
|
|
206
|
+
pixelIndex,
|
|
207
|
+
color,
|
|
208
|
+
opacity: effectiveOpacity,
|
|
209
|
+
});
|
|
210
|
+
imageDstBuffer.data[pixelIndex] = pixelColor.blue;
|
|
211
|
+
imageDstBuffer.data[pixelIndex + 1] = pixelColor.green;
|
|
212
|
+
imageDstBuffer.data[pixelIndex + 2] = pixelColor.red;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Update x coordinates for next scanline (x' = x + dx)
|
|
218
|
+
for (const edge of activeEdges) {
|
|
219
|
+
edge.x += edge.dx;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
103
224
|
interface DrawSpectrumProps {
|
|
104
225
|
imageDstBuffer: EncodedBmp;
|
|
105
226
|
spectrum: number[];
|
|
@@ -111,6 +232,27 @@ interface DrawSpectrumProps {
|
|
|
111
232
|
opacity: number;
|
|
112
233
|
}
|
|
113
234
|
|
|
235
|
+
const getRectY = (rotation: RotationAliasName, top: number, height: number, rectHeight: number) => {
|
|
236
|
+
switch (rotation) {
|
|
237
|
+
case 'up':
|
|
238
|
+
return top + height - rectHeight;
|
|
239
|
+
case 'mirror':
|
|
240
|
+
return top + Math.trunc((height - rectHeight) / 2);
|
|
241
|
+
default:
|
|
242
|
+
return top;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const minRectHeight = 2;
|
|
247
|
+
|
|
248
|
+
const getRectHeight = (height: number, spectrumValue: number) => {
|
|
249
|
+
const rectHeight = Math.trunc(height * spectrumValue);
|
|
250
|
+
if (rectHeight < minRectHeight) {
|
|
251
|
+
return minRectHeight;
|
|
252
|
+
}
|
|
253
|
+
return rectHeight;
|
|
254
|
+
};
|
|
255
|
+
|
|
114
256
|
const drawSpectrum = ({
|
|
115
257
|
imageDstBuffer,
|
|
116
258
|
spectrum,
|
|
@@ -133,8 +275,8 @@ const drawSpectrum = ({
|
|
|
133
275
|
}
|
|
134
276
|
|
|
135
277
|
const rectX = left + busWidth * spectrumX;
|
|
136
|
-
const rectHeight =
|
|
137
|
-
const rectY = (rotation
|
|
278
|
+
const rectHeight = getRectHeight(height, spectrumValue);
|
|
279
|
+
const rectY = getRectY(rotation, top, height, rectHeight);
|
|
138
280
|
drawRect({
|
|
139
281
|
imageDstBuffer,
|
|
140
282
|
position: { x: rectX + margin, y: rectY },
|
|
@@ -145,42 +287,225 @@ const drawSpectrum = ({
|
|
|
145
287
|
}
|
|
146
288
|
};
|
|
147
289
|
|
|
148
|
-
|
|
290
|
+
const calcCirclePoint = (angleRadians: number, radius: number): Position => {
|
|
291
|
+
const circleX = radius * Math.cos(angleRadians);
|
|
292
|
+
const circleY = radius * Math.sin(angleRadians);
|
|
293
|
+
return { x: circleX, y: circleY };
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const lerp = (start: number, end: number, t: number) =>
|
|
297
|
+
start + (end - start) * t;
|
|
298
|
+
|
|
299
|
+
interface DrawPolarSpectrumProps {
|
|
300
|
+
imageDstBuffer: EncodedBmp;
|
|
301
|
+
spectrum: number[];
|
|
302
|
+
centerX: number;
|
|
303
|
+
centerY: number;
|
|
304
|
+
innerRadius: number;
|
|
305
|
+
maxBarLength: number;
|
|
306
|
+
barWidth: number;
|
|
307
|
+
color: Color;
|
|
308
|
+
opacity: number;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const drawPolarSpectrum = ({
|
|
312
|
+
imageDstBuffer,
|
|
313
|
+
spectrum,
|
|
314
|
+
centerX,
|
|
315
|
+
centerY,
|
|
316
|
+
innerRadius,
|
|
317
|
+
maxBarLength,
|
|
318
|
+
barWidth,
|
|
319
|
+
color,
|
|
320
|
+
opacity,
|
|
321
|
+
}: DrawPolarSpectrumProps) => {
|
|
322
|
+
const angleStep = (2 * Math.PI) / spectrum.length;
|
|
323
|
+
const innerLength = innerRadius * 2 * Math.PI;
|
|
324
|
+
const radiansPerPixel = 2 * Math.PI / innerLength;
|
|
325
|
+
const barWidthInRadians = radiansPerPixel * barWidth;
|
|
326
|
+
const spectrumAverage = spectrum.reduce((a, b) => a + b, 0) / spectrum.length;
|
|
327
|
+
const scaleFactor = lerp(0.75, 1, spectrumAverage);
|
|
328
|
+
const scaledMaxOuterRadius = (innerRadius + maxBarLength) * scaleFactor;
|
|
329
|
+
const scaledInnerRadius = innerRadius * scaleFactor;
|
|
330
|
+
for (let i = 0; i < spectrum.length; i++) {
|
|
331
|
+
const currentSpectrumValue = spectrum[i] || 0;
|
|
332
|
+
const busRadius = lerp(scaledInnerRadius, scaledMaxOuterRadius, currentSpectrumValue);
|
|
333
|
+
const angle = i * angleStep;
|
|
334
|
+
const angleRadiansStart = angle;
|
|
335
|
+
const angleRadiansEnd = angle + barWidthInRadians;
|
|
336
|
+
|
|
337
|
+
const point1 = calcCirclePoint(angleRadiansStart, scaledInnerRadius);
|
|
338
|
+
const point2 = calcCirclePoint(angleRadiansStart, busRadius);
|
|
339
|
+
|
|
340
|
+
const point3 = calcCirclePoint(angleRadiansEnd, scaledInnerRadius);
|
|
341
|
+
const point4 = calcCirclePoint(angleRadiansEnd, busRadius);
|
|
342
|
+
|
|
343
|
+
fillRect({
|
|
344
|
+
imageDstBuffer,
|
|
345
|
+
points: [
|
|
346
|
+
{ x: centerX + Math.round(point1.x), y: centerY + Math.round(point1.y) },
|
|
347
|
+
{ x: centerX + Math.round(point2.x), y: centerY + Math.round(point2.y) },
|
|
348
|
+
{ x: centerX + Math.round(point4.x), y: centerY + Math.round(point4.y) },
|
|
349
|
+
{ x: centerX + Math.round(point3.x), y: centerY + Math.round(point3.y) },
|
|
350
|
+
],
|
|
351
|
+
color,
|
|
352
|
+
opacity,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
export interface CommonVisualizerFrameProps {
|
|
149
358
|
backgroundImageBuffer: EncodedBmp;
|
|
150
359
|
spectrum: number[];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export interface CreateVisualizerFrameProps {
|
|
151
363
|
size: Size;
|
|
152
364
|
position: Position;
|
|
153
365
|
rotation: RotationAliasName;
|
|
154
366
|
margin: number;
|
|
155
367
|
color: Color | string;
|
|
156
368
|
opacity: number;
|
|
369
|
+
spectrumEffect?: SpectrumEffect;
|
|
157
370
|
}
|
|
158
371
|
|
|
159
|
-
export
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
const imageDstBuffer = Object.assign({}, backgroundImageBuffer);
|
|
170
|
-
imageDstBuffer.data = Buffer.from(backgroundImageBuffer.data);
|
|
372
|
+
export interface CreatePolarVisualizerFrameProps {
|
|
373
|
+
centerX: number;
|
|
374
|
+
centerY: number;
|
|
375
|
+
innerRadius: number;
|
|
376
|
+
maxBarLength: number;
|
|
377
|
+
barWidth: number;
|
|
378
|
+
color: Color | string;
|
|
379
|
+
opacity: number;
|
|
380
|
+
spectrumEffect?: SpectrumEffect;
|
|
381
|
+
}
|
|
171
382
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
383
|
+
export const createSpectrumVisualizerFrameGenerator = () => {
|
|
384
|
+
let prevSpectrum: number[] | null = null;
|
|
385
|
+
|
|
386
|
+
return ({
|
|
387
|
+
backgroundImageBuffer,
|
|
175
388
|
spectrum,
|
|
176
389
|
size,
|
|
177
390
|
position,
|
|
178
391
|
rotation,
|
|
179
392
|
margin,
|
|
180
|
-
color
|
|
393
|
+
color,
|
|
181
394
|
opacity,
|
|
182
|
-
|
|
183
|
-
|
|
395
|
+
spectrumEffect,
|
|
396
|
+
}: CommonVisualizerFrameProps & CreateVisualizerFrameProps) => {
|
|
397
|
+
const imageDstBuffer = Object.assign({}, backgroundImageBuffer);
|
|
398
|
+
imageDstBuffer.data = Buffer.from(backgroundImageBuffer.data);
|
|
399
|
+
|
|
400
|
+
const rgbSpectrumColor = (typeof color === 'string') ? hexToRgb(color) : color;
|
|
401
|
+
if (spectrumEffect === 'volume') {
|
|
402
|
+
drawSpectrum({
|
|
403
|
+
imageDstBuffer,
|
|
404
|
+
spectrum,
|
|
405
|
+
size,
|
|
406
|
+
position: { x: position.x + 2, y: position.y + 2 },
|
|
407
|
+
rotation,
|
|
408
|
+
margin,
|
|
409
|
+
color: rgbSpectrumColor,
|
|
410
|
+
opacity: opacity * 0.5,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (spectrumEffect === 'smooth' && prevSpectrum) {
|
|
414
|
+
drawSpectrum({
|
|
415
|
+
imageDstBuffer,
|
|
416
|
+
spectrum: prevSpectrum,
|
|
417
|
+
size,
|
|
418
|
+
position,
|
|
419
|
+
rotation,
|
|
420
|
+
margin,
|
|
421
|
+
color: rgbSpectrumColor,
|
|
422
|
+
opacity: opacity * 0.3,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
drawSpectrum({
|
|
426
|
+
imageDstBuffer,
|
|
427
|
+
spectrum,
|
|
428
|
+
size,
|
|
429
|
+
position,
|
|
430
|
+
rotation,
|
|
431
|
+
margin,
|
|
432
|
+
color: rgbSpectrumColor,
|
|
433
|
+
opacity,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
prevSpectrum = spectrum;
|
|
437
|
+
|
|
438
|
+
return imageDstBuffer;
|
|
439
|
+
};
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
export const createPolarVisualizerFrameGenerator = () => {
|
|
443
|
+
let prevSpectrum: number[] | null = null;
|
|
444
|
+
|
|
445
|
+
return ({
|
|
446
|
+
backgroundImageBuffer,
|
|
447
|
+
spectrum,
|
|
448
|
+
centerX,
|
|
449
|
+
centerY,
|
|
450
|
+
innerRadius,
|
|
451
|
+
maxBarLength,
|
|
452
|
+
barWidth,
|
|
453
|
+
color,
|
|
454
|
+
opacity,
|
|
455
|
+
spectrumEffect,
|
|
456
|
+
}: CommonVisualizerFrameProps & CreatePolarVisualizerFrameProps) => {
|
|
457
|
+
const imageDstBuffer = Object.assign({}, backgroundImageBuffer);
|
|
458
|
+
imageDstBuffer.data = Buffer.from(backgroundImageBuffer.data);
|
|
459
|
+
|
|
460
|
+
const rgbSpectrumColor = (typeof color === 'string') ? hexToRgb(color) : color;
|
|
461
|
+
|
|
462
|
+
// Draw shadow/volume effect
|
|
463
|
+
if (spectrumEffect === 'volume') {
|
|
464
|
+
drawPolarSpectrum({
|
|
465
|
+
imageDstBuffer,
|
|
466
|
+
spectrum,
|
|
467
|
+
centerX: centerX + 2,
|
|
468
|
+
centerY: centerY + 2,
|
|
469
|
+
innerRadius,
|
|
470
|
+
maxBarLength,
|
|
471
|
+
barWidth,
|
|
472
|
+
color: rgbSpectrumColor,
|
|
473
|
+
opacity: opacity * 0.5,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Draw smooth/trail effect with previous frame
|
|
478
|
+
if (spectrumEffect === 'smooth' && prevSpectrum) {
|
|
479
|
+
drawPolarSpectrum({
|
|
480
|
+
imageDstBuffer,
|
|
481
|
+
spectrum: prevSpectrum,
|
|
482
|
+
centerX,
|
|
483
|
+
centerY,
|
|
484
|
+
innerRadius,
|
|
485
|
+
maxBarLength,
|
|
486
|
+
barWidth,
|
|
487
|
+
color: rgbSpectrumColor,
|
|
488
|
+
opacity: opacity * 0.3,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Draw main spectrum
|
|
493
|
+
drawPolarSpectrum({
|
|
494
|
+
imageDstBuffer,
|
|
495
|
+
spectrum,
|
|
496
|
+
centerX,
|
|
497
|
+
centerY,
|
|
498
|
+
innerRadius,
|
|
499
|
+
maxBarLength,
|
|
500
|
+
barWidth,
|
|
501
|
+
color: rgbSpectrumColor,
|
|
502
|
+
opacity,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
prevSpectrum = spectrum;
|
|
506
|
+
|
|
507
|
+
return imageDstBuffer;
|
|
508
|
+
};
|
|
184
509
|
};
|
|
185
510
|
|
|
186
511
|
export const convertToBmp = async (filePath: string) =>
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
getBackgroundImagePath,
|
|
4
4
|
getOutVideoPath,
|
|
5
5
|
getFPS,
|
|
6
|
-
getSpectrumBusesCount,
|
|
7
6
|
getSpectrumBusMargin,
|
|
8
7
|
getSpectrumWidthAbsolute,
|
|
9
8
|
getSpectrumHeightAbsolute,
|
|
@@ -15,12 +14,23 @@ import {
|
|
|
15
14
|
getFfmpeg_preset,
|
|
16
15
|
getFrame_processing_delay,
|
|
17
16
|
rotationAliasValues,
|
|
18
|
-
getSpectrumRotation
|
|
17
|
+
getSpectrumRotation,
|
|
18
|
+
getSpectrumEffect,
|
|
19
|
+
SpectrumEffect,
|
|
20
|
+
getPolarXAbsolute,
|
|
21
|
+
getPolarYAbsolute,
|
|
22
|
+
getPolarInnerRadius,
|
|
23
|
+
getPolarMaxBarLength,
|
|
24
|
+
getPolarBarWidth,
|
|
25
|
+
getPolarEffect,
|
|
26
|
+
getPolarColor,
|
|
27
|
+
getPolarOpacityParsed,
|
|
19
28
|
} from './config';
|
|
20
29
|
import { createAudioBuffer, bufferToUInt8, createSpectrumsProcessor } from './audio';
|
|
21
|
-
import { parseImage,
|
|
30
|
+
import { parseImage, getImageColor, invertColor, Color, convertToBmp, createSpectrumVisualizerFrameGenerator, createPolarVisualizerFrameGenerator, CreatePolarVisualizerFrameProps, CreateVisualizerFrameProps, CommonVisualizerFrameProps } from './image';
|
|
22
31
|
import { spawnFfmpegVideoWriter, getProgress, calculateProgress, waitDrain } from './video';
|
|
23
|
-
import { createBpmEncoder } from './bpmEncoder';
|
|
32
|
+
import { createBpmEncoder, EncodedBmp } from './bpmEncoder';
|
|
33
|
+
import { BmpDecoder } from 'bmp-js';
|
|
24
34
|
|
|
25
35
|
export const PCM_FORMAT = {
|
|
26
36
|
bit: 8,
|
|
@@ -28,6 +38,7 @@ export const PCM_FORMAT = {
|
|
|
28
38
|
parseFunction: bufferToUInt8
|
|
29
39
|
};
|
|
30
40
|
const FFMPEG_FORMAT = `${PCM_FORMAT.sign}${PCM_FORMAT.bit}`;
|
|
41
|
+
const PROCESSING_BUFFER_SIZE = Math.pow(2, 12);
|
|
31
42
|
|
|
32
43
|
export interface Config {
|
|
33
44
|
audio: {
|
|
@@ -45,6 +56,17 @@ export interface Config {
|
|
|
45
56
|
x?: number | PositionAliasName;
|
|
46
57
|
y?: number | PositionAliasName;
|
|
47
58
|
rotation?: RotationAliasName;
|
|
59
|
+
effect?: SpectrumEffect;
|
|
60
|
+
color?: Color | string;
|
|
61
|
+
opacity?: string;
|
|
62
|
+
};
|
|
63
|
+
polar?: {
|
|
64
|
+
x?: number | PositionAliasName;
|
|
65
|
+
y?: number | PositionAliasName;
|
|
66
|
+
innerRadius?: number;
|
|
67
|
+
maxBarLength?: number;
|
|
68
|
+
barWidth?: number;
|
|
69
|
+
effect?: SpectrumEffect;
|
|
48
70
|
color?: Color | string;
|
|
49
71
|
opacity?: string;
|
|
50
72
|
}
|
|
@@ -71,8 +93,67 @@ export type RotationAliasName = typeof rotationAliasValues[number];
|
|
|
71
93
|
const sleep = (timeout: number) =>
|
|
72
94
|
new Promise(resolve => setTimeout(resolve, timeout));
|
|
73
95
|
|
|
96
|
+
const createVisualizerFrameGenerator = (
|
|
97
|
+
config: Config,
|
|
98
|
+
backgroundImage: BmpDecoder,
|
|
99
|
+
spectrumBusMargin: number
|
|
100
|
+
): (params: CommonVisualizerFrameProps) => EncodedBmp => {
|
|
101
|
+
if (!config.outVideo.spectrum) {
|
|
102
|
+
const createPolarVisualizerFrame = createPolarVisualizerFrameGenerator();
|
|
103
|
+
const polarX = getPolarXAbsolute(config, backgroundImage.width);
|
|
104
|
+
const polarY = getPolarYAbsolute(config, backgroundImage.height);
|
|
105
|
+
const polarInnerRadius = getPolarInnerRadius(config);
|
|
106
|
+
const polarMaxBarLength = getPolarMaxBarLength(config);
|
|
107
|
+
const polarBarWidth = getPolarBarWidth(config);
|
|
108
|
+
const polarColor = getPolarColor(config) || invertColor(getImageColor(backgroundImage));
|
|
109
|
+
const polarEffect = getPolarEffect(config);
|
|
110
|
+
const polarOpacity = getPolarOpacityParsed(config);
|
|
111
|
+
|
|
112
|
+
return (params: CommonVisualizerFrameProps) => {
|
|
113
|
+
return createPolarVisualizerFrame({
|
|
114
|
+
...params,
|
|
115
|
+
centerX: polarX,
|
|
116
|
+
centerY: polarY,
|
|
117
|
+
innerRadius: polarInnerRadius,
|
|
118
|
+
maxBarLength: polarMaxBarLength,
|
|
119
|
+
barWidth: polarBarWidth,
|
|
120
|
+
color: polarColor,
|
|
121
|
+
opacity: polarOpacity,
|
|
122
|
+
spectrumEffect: polarEffect,
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
}
|
|
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);
|
|
131
|
+
const spectrumRotation = getSpectrumRotation(config);
|
|
132
|
+
const spectrumColor = getSpectrumColor(config) || invertColor(getImageColor(backgroundImage));
|
|
133
|
+
const spectrumEffect = getSpectrumEffect(config);
|
|
134
|
+
const spectrumOpacity = getSpectrumOpacityParsed(config);
|
|
135
|
+
|
|
136
|
+
return (params: CommonVisualizerFrameProps) => {
|
|
137
|
+
return createPolarVisualizerFrame({
|
|
138
|
+
...params,
|
|
139
|
+
size: { width: spectrumWidth, height: spectrumHeight },
|
|
140
|
+
position: { x: spectrumX, y: spectrumY },
|
|
141
|
+
rotation: spectrumRotation,
|
|
142
|
+
margin: spectrumBusMargin,
|
|
143
|
+
color: spectrumColor,
|
|
144
|
+
opacity: spectrumOpacity,
|
|
145
|
+
spectrumEffect,
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
74
150
|
export const renderAudioVisualizer = (config: Config, onProgress?: (progress: number) => any, shouldStop?: () => boolean) =>
|
|
75
151
|
new Promise<number>(async (resolve) => {
|
|
152
|
+
// Validate that only one visualizer type is specified
|
|
153
|
+
if (config.outVideo.spectrum && config.outVideo.polar) {
|
|
154
|
+
throw new Error('Cannot use both "spectrum" and "polar" options. Please specify only one visualizer type.');
|
|
155
|
+
}
|
|
156
|
+
|
|
76
157
|
const audioFilePath = getAudioFilePath(config);
|
|
77
158
|
const backgroundImagePath = getBackgroundImagePath(config);
|
|
78
159
|
const outVideoPath = getOutVideoPath(config);
|
|
@@ -86,30 +167,18 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
86
167
|
throw new Error('ffmpeg didn\'t show audio sample rate');
|
|
87
168
|
}
|
|
88
169
|
|
|
89
|
-
const spectrumBusesCount = getSpectrumBusesCount();
|
|
90
170
|
const spectrumBusMargin = getSpectrumBusMargin();
|
|
91
171
|
const FPS = getFPS(config);
|
|
92
|
-
const spectrumWidth =
|
|
93
|
-
getSpectrumWidthAbsolute(config, backgroundImage.width);
|
|
94
|
-
const spectrumHeight =
|
|
95
|
-
getSpectrumHeightAbsolute(config, backgroundImage.height);
|
|
96
|
-
const spectrumX =
|
|
97
|
-
getSpectrumXAbsolute(config, spectrumWidth, backgroundImage.width);
|
|
98
|
-
const spectrumY =
|
|
99
|
-
getSpectrumYAbsolute(config, spectrumHeight, backgroundImage.height);
|
|
100
|
-
const spectrumRotation =
|
|
101
|
-
getSpectrumRotation(config);
|
|
102
|
-
const spectrumColor =
|
|
103
|
-
getSpectrumColor(config) ||
|
|
104
|
-
invertColor(getImageColor(backgroundImage));
|
|
105
|
-
const spectrumOpacity = getSpectrumOpacityParsed(config);
|
|
106
172
|
const ffmpeg_cfr = getFfmpeg_cfr(config);
|
|
107
173
|
const ffmpeg_preset = getFfmpeg_preset(config);
|
|
108
174
|
const frame_processing_delay = getFrame_processing_delay(config);
|
|
109
175
|
|
|
176
|
+
const createVisualizerFrame = createVisualizerFrameGenerator(config, backgroundImage, spectrumBusMargin);
|
|
177
|
+
|
|
110
178
|
const audioDuration = audioBuffer.length / sampleRate;
|
|
111
179
|
const framesCount = Math.trunc(audioDuration * FPS);
|
|
112
180
|
const audioDataStep = Math.trunc(audioBuffer.length / framesCount);
|
|
181
|
+
const processingBuffer = new Float32Array(PROCESSING_BUFFER_SIZE).fill(0);
|
|
113
182
|
|
|
114
183
|
const ffmpegVideoWriter = spawnFfmpegVideoWriter({
|
|
115
184
|
audioFilename: audioFilePath,
|
|
@@ -123,21 +192,21 @@ export const renderAudioVisualizer = (config: Config, onProgress?: (progress: nu
|
|
|
123
192
|
|
|
124
193
|
const bpmEncoder = createBpmEncoder({ width: backgroundImage.width, height: backgroundImage.height });
|
|
125
194
|
const backgroundImageBuffer = bpmEncoder(backgroundImage.data);
|
|
126
|
-
const
|
|
195
|
+
const skipFramesCount = FPS < 45 ? 1 : 2;
|
|
196
|
+
const processSpectrum = createSpectrumsProcessor(sampleRate, skipFramesCount);
|
|
197
|
+
|
|
127
198
|
for (let i = 0; i < framesCount; i++) {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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);
|
|
202
|
+
|
|
203
|
+
const audioDataParser = () => Array.from(processingBuffer);
|
|
204
|
+
const spectrum = processSpectrum(audioDataParser);
|
|
205
|
+
const commonVisualizerFrameProps: CommonVisualizerFrameProps = {
|
|
132
206
|
backgroundImageBuffer,
|
|
133
207
|
spectrum,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
rotation: spectrumRotation,
|
|
137
|
-
margin: spectrumBusMargin,
|
|
138
|
-
color: spectrumColor,
|
|
139
|
-
opacity: spectrumOpacity,
|
|
140
|
-
});
|
|
208
|
+
};
|
|
209
|
+
const frameImage = createVisualizerFrame(commonVisualizerFrameProps);
|
|
141
210
|
const isFrameProcessed = ffmpegVideoWriter.stdin.write(frameImage.data);
|
|
142
211
|
if (!isFrameProcessed) {
|
|
143
212
|
await waitDrain(ffmpegVideoWriter.stdin);
|
package/src/tests/audio.test.ts
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
skipEvery,
|
|
9
9
|
getPeaks,
|
|
10
10
|
correctPeaks,
|
|
11
|
-
smoothValues,
|
|
12
11
|
createSpectrumsProcessor,
|
|
13
12
|
} from '../audio';
|
|
14
13
|
import { createSandbox } from 'sinon';
|
|
@@ -85,21 +84,13 @@ describe('audio', function () {
|
|
|
85
84
|
expect(result).deep.equal(expected);
|
|
86
85
|
});
|
|
87
86
|
|
|
88
|
-
it('smoothValues', () => {
|
|
89
|
-
const spectrums = [1, 2, 3, 4];
|
|
90
|
-
const prevSpectrums = [2, 4, 6, 3];
|
|
91
|
-
const result = smoothValues(spectrums, prevSpectrums);
|
|
92
|
-
const expected = [1.5, 3, 4.5, 3.5];
|
|
93
|
-
expect(result).deep.equal(expected);
|
|
94
|
-
});
|
|
95
87
|
|
|
96
88
|
it('createSpectrumsProcessor', () => {
|
|
97
|
-
const
|
|
98
|
-
const spectrumsProcessor = createSpectrumsProcessor(busesCount);
|
|
89
|
+
const spectrumsProcessor = createSpectrumsProcessor(44100);
|
|
99
90
|
|
|
100
|
-
const frame0 = spectrumsProcessor(
|
|
101
|
-
expect(frame0).to.have.length(
|
|
102
|
-
const frame1 = spectrumsProcessor(
|
|
103
|
-
expect(frame1).to.have.length(
|
|
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);
|
|
104
95
|
});
|
|
105
96
|
});
|
package/src/tests/config.test.ts
CHANGED
|
@@ -79,9 +79,9 @@ describe('config', function() {
|
|
|
79
79
|
);
|
|
80
80
|
|
|
81
81
|
const result2 = getSpectrumWidthAbsolute({
|
|
82
|
-
outVideo: { spectrum: { width:
|
|
82
|
+
outVideo: { spectrum: { width: 600 }}
|
|
83
83
|
} as Config, backgroundImageWidth);
|
|
84
|
-
expect(result2).equal(
|
|
84
|
+
expect(result2).equal(600);
|
|
85
85
|
|
|
86
86
|
const result3 = getSpectrumWidthAbsolute({
|
|
87
87
|
outVideo: { spectrum: { width: '69%' }}
|
|
@@ -117,13 +117,11 @@ describe('config', function() {
|
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
it('getSpectrumHeightAbsolute', function() {
|
|
120
|
-
const backgroundImageHeight =
|
|
120
|
+
const backgroundImageHeight = 720;
|
|
121
121
|
const result1 = getSpectrumHeightAbsolute({
|
|
122
122
|
outVideo: {}
|
|
123
123
|
} as Config, backgroundImageHeight);
|
|
124
|
-
expect(result1).equal(
|
|
125
|
-
backgroundImageHeight * (parseInt(defaults.spectrumHeight) / 100)
|
|
126
|
-
);
|
|
124
|
+
expect(result1).equal(160);
|
|
127
125
|
|
|
128
126
|
const result2 = getSpectrumHeightAbsolute({
|
|
129
127
|
outVideo: { spectrum: { height: 420 }}
|
|
@@ -212,7 +210,7 @@ describe('config', function() {
|
|
|
212
210
|
|
|
213
211
|
it('getSpectrumYAbsolute', function() {
|
|
214
212
|
const spectrumHeight = 125;
|
|
215
|
-
const backgroundImageHeight =
|
|
213
|
+
const backgroundImageHeight = 721;
|
|
216
214
|
const result1 = getSpectrumYAbsolute(
|
|
217
215
|
{
|
|
218
216
|
outVideo: {},
|
|
@@ -221,7 +219,7 @@ describe('config', function() {
|
|
|
221
219
|
backgroundImageHeight
|
|
222
220
|
);
|
|
223
221
|
expect(result1).equal(
|
|
224
|
-
|
|
222
|
+
298
|
|
225
223
|
);
|
|
226
224
|
|
|
227
225
|
const result2 = getSpectrumYAbsolute(
|