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/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
- interface CreateVisualizerFrameProps {
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 const createVisualizerFrameGenerator = () => {
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
- imageDstBuffer,
204
- spectrum,
205
- size,
206
- position: { x: position.x + 2, y: position.y + 2 },
207
- rotation,
208
- margin,
209
- color: rgbSpectrumColor,
210
- opacity: opacity * 0.5,
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
- imageDstBuffer,
216
- spectrum: prevSpectrum,
217
- size,
218
- position,
219
- rotation,
220
- margin,
221
- color: rgbSpectrumColor,
222
- opacity: opacity * 0.3,
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 convertToBmp = async (filePath: string) =>
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