nodejs-audio-visualizer 3.0.0 → 4.1.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 +19 -3
- package/dist/beats.d.ts +19 -0
- package/dist/beats.js +44 -0
- package/dist/beats.js.map +1 -0
- package/dist/bpmEncoder.d.ts +4 -0
- package/dist/bpmEncoder.js +65 -43
- package/dist/bpmEncoder.js.map +1 -1
- package/dist/config.d.ts +21 -1
- package/dist/config.js +100 -1
- package/dist/config.js.map +1 -1
- package/dist/image.d.ts +28 -3
- package/dist/image.js +231 -3
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +214 -52
- package/dist/index.js.map +1 -1
- package/dist/video.d.ts +41 -1
- package/dist/video.js +244 -0
- package/dist/video.js.map +1 -1
- package/package.json +2 -2
- package/src/beats.ts +68 -0
- package/src/bpmEncoder.ts +57 -31
- package/src/config.ts +117 -1
- package/src/image.ts +338 -27
- package/src/index.ts +321 -43
- package/src/tests/image.test.ts +86 -3
- package/src/video.ts +302 -1
package/src/image.ts
CHANGED
|
@@ -26,7 +26,7 @@ interface MixColorProps {
|
|
|
26
26
|
backgroundColor: Color;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export const mixValues =(value1: number, value1Opacity: number, value2: number) =>
|
|
29
|
+
export const mixValues = (value1: number, value1Opacity: number, value2: number) =>
|
|
30
30
|
Math.round(value2 * (1 - value1Opacity) + value1 * value1Opacity);
|
|
31
31
|
|
|
32
32
|
export const mixColors = ({
|
|
@@ -101,6 +101,126 @@ export const drawRect = ({
|
|
|
101
101
|
}
|
|
102
102
|
};
|
|
103
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
|
+
|
|
104
224
|
interface DrawSpectrumProps {
|
|
105
225
|
imageDstBuffer: EncodedBmp;
|
|
106
226
|
spectrum: number[];
|
|
@@ -167,9 +287,79 @@ const drawSpectrum = ({
|
|
|
167
287
|
}
|
|
168
288
|
};
|
|
169
289
|
|
|
170
|
-
|
|
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 {
|
|
171
358
|
backgroundImageBuffer: EncodedBmp;
|
|
172
359
|
spectrum: number[];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export interface CreateVisualizerFrameProps {
|
|
173
363
|
size: Size;
|
|
174
364
|
position: Position;
|
|
175
365
|
rotation: RotationAliasName;
|
|
@@ -179,7 +369,18 @@ interface CreateVisualizerFrameProps {
|
|
|
179
369
|
spectrumEffect?: SpectrumEffect;
|
|
180
370
|
}
|
|
181
371
|
|
|
182
|
-
export
|
|
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
|
+
}
|
|
382
|
+
|
|
383
|
+
export const createSpectrumVisualizerFrameGenerator = () => {
|
|
183
384
|
let prevSpectrum: number[] | null = null;
|
|
184
385
|
|
|
185
386
|
return ({
|
|
@@ -192,35 +393,34 @@ export const createVisualizerFrameGenerator = () => {
|
|
|
192
393
|
color,
|
|
193
394
|
opacity,
|
|
194
395
|
spectrumEffect,
|
|
195
|
-
}: CreateVisualizerFrameProps) => {
|
|
396
|
+
}: CommonVisualizerFrameProps & CreateVisualizerFrameProps) => {
|
|
196
397
|
const imageDstBuffer = Object.assign({}, backgroundImageBuffer);
|
|
197
398
|
imageDstBuffer.data = Buffer.from(backgroundImageBuffer.data);
|
|
198
|
-
|
|
199
|
-
|
|
399
|
+
|
|
200
400
|
const rgbSpectrumColor = (typeof color === 'string') ? hexToRgb(color) : color;
|
|
201
401
|
if (spectrumEffect === 'volume') {
|
|
202
402
|
drawSpectrum({
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
});
|
|
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
|
+
});
|
|
212
412
|
}
|
|
213
413
|
if (spectrumEffect === 'smooth' && prevSpectrum) {
|
|
214
414
|
drawSpectrum({
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
});
|
|
415
|
+
imageDstBuffer,
|
|
416
|
+
spectrum: prevSpectrum,
|
|
417
|
+
size,
|
|
418
|
+
position,
|
|
419
|
+
rotation,
|
|
420
|
+
margin,
|
|
421
|
+
color: rgbSpectrumColor,
|
|
422
|
+
opacity: opacity * 0.3,
|
|
423
|
+
});
|
|
224
424
|
}
|
|
225
425
|
drawSpectrum({
|
|
226
426
|
imageDstBuffer,
|
|
@@ -232,17 +432,109 @@ export const createVisualizerFrameGenerator = () => {
|
|
|
232
432
|
color: rgbSpectrumColor,
|
|
233
433
|
opacity,
|
|
234
434
|
});
|
|
235
|
-
|
|
435
|
+
|
|
236
436
|
prevSpectrum = spectrum;
|
|
237
|
-
|
|
437
|
+
|
|
238
438
|
return imageDstBuffer;
|
|
239
439
|
};
|
|
240
440
|
};
|
|
241
441
|
|
|
242
|
-
export const
|
|
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
|
+
if (spectrumEffect === 'volume') {
|
|
463
|
+
drawPolarSpectrum({
|
|
464
|
+
imageDstBuffer,
|
|
465
|
+
spectrum,
|
|
466
|
+
centerX: centerX + 2,
|
|
467
|
+
centerY: centerY + 2,
|
|
468
|
+
innerRadius,
|
|
469
|
+
maxBarLength,
|
|
470
|
+
barWidth,
|
|
471
|
+
color: rgbSpectrumColor,
|
|
472
|
+
opacity: opacity * 0.5,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (spectrumEffect === 'smooth' && prevSpectrum) {
|
|
477
|
+
drawPolarSpectrum({
|
|
478
|
+
imageDstBuffer,
|
|
479
|
+
spectrum: prevSpectrum,
|
|
480
|
+
centerX,
|
|
481
|
+
centerY,
|
|
482
|
+
innerRadius,
|
|
483
|
+
maxBarLength,
|
|
484
|
+
barWidth,
|
|
485
|
+
color: rgbSpectrumColor,
|
|
486
|
+
opacity: opacity * 0.3,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
drawPolarSpectrum({
|
|
491
|
+
imageDstBuffer,
|
|
492
|
+
spectrum,
|
|
493
|
+
centerX,
|
|
494
|
+
centerY,
|
|
495
|
+
innerRadius,
|
|
496
|
+
maxBarLength,
|
|
497
|
+
barWidth,
|
|
498
|
+
color: rgbSpectrumColor,
|
|
499
|
+
opacity,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
prevSpectrum = spectrum;
|
|
503
|
+
|
|
504
|
+
return imageDstBuffer;
|
|
505
|
+
};
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
export const convertToBmp = async (filePath: string, targetWidth?: number, targetHeight?: number) =>
|
|
243
509
|
new Promise<Buffer>(async (resolve, reject) => {
|
|
244
510
|
try {
|
|
245
511
|
const image = await Jimp.read(filePath);
|
|
512
|
+
if (targetWidth && targetHeight) {
|
|
513
|
+
const sw = image.getWidth();
|
|
514
|
+
const sh = image.getHeight();
|
|
515
|
+
const isOrientationMismatch =
|
|
516
|
+
(sw > sh && targetWidth < targetHeight) ||
|
|
517
|
+
(sw < sh && targetWidth > targetHeight);
|
|
518
|
+
|
|
519
|
+
if (isOrientationMismatch) {
|
|
520
|
+
const targetAspect = targetWidth / targetHeight;
|
|
521
|
+
let cropW: number, cropH: number, cropX: number, cropY: number;
|
|
522
|
+
if (sw / sh > targetAspect) {
|
|
523
|
+
cropW = Math.round(sh * targetAspect);
|
|
524
|
+
cropH = sh;
|
|
525
|
+
cropX = Math.round((sw - cropW) / 2);
|
|
526
|
+
cropY = 0;
|
|
527
|
+
} else {
|
|
528
|
+
cropW = sw;
|
|
529
|
+
cropH = Math.round(sw / targetAspect);
|
|
530
|
+
cropX = 0;
|
|
531
|
+
cropY = Math.round((sh - cropH) / 2);
|
|
532
|
+
}
|
|
533
|
+
image.crop(cropX, cropY, cropW, cropH);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
image.resize(targetWidth, targetHeight);
|
|
537
|
+
}
|
|
246
538
|
image.getBuffer("image/bmp", (err, value) => {
|
|
247
539
|
if (err) {
|
|
248
540
|
reject(err);
|
|
@@ -279,6 +571,25 @@ export const getImageColor = (image: BmpDecoder): Color => {
|
|
|
279
571
|
};
|
|
280
572
|
};
|
|
281
573
|
|
|
574
|
+
export const getVideoFrameColor = (bgrBuffer: Buffer, width: number, height: number): Color => {
|
|
575
|
+
let blueSum = 0;
|
|
576
|
+
let greenSum = 0;
|
|
577
|
+
let redSum = 0;
|
|
578
|
+
const pixelsCount = width * height;
|
|
579
|
+
|
|
580
|
+
for (let i = 0; i < bgrBuffer.length; i += 3) {
|
|
581
|
+
blueSum += bgrBuffer[i];
|
|
582
|
+
greenSum += bgrBuffer[i + 1];
|
|
583
|
+
redSum += bgrBuffer[i + 2];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
red: ~~(redSum / pixelsCount),
|
|
588
|
+
green: ~~(greenSum / pixelsCount),
|
|
589
|
+
blue: ~~(blueSum / pixelsCount),
|
|
590
|
+
};
|
|
591
|
+
};
|
|
592
|
+
|
|
282
593
|
export const invertColor = (color: Color) =>
|
|
283
594
|
({ red: 255 - color.red, green: 255 - color.green, blue: 255 - color.blue });
|
|
284
595
|
|