nodejs-audio-visualizer 4.3.6 → 4.3.8

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 (46) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +75 -75
  3. package/dist/audio.d.ts +12 -12
  4. package/dist/audio.js +120 -120
  5. package/dist/beats.d.ts +19 -19
  6. package/dist/beats.js +43 -43
  7. package/dist/bpmEncoder.d.ts +14 -14
  8. package/dist/bpmEncoder.js +97 -97
  9. package/dist/config.d.ts +67 -67
  10. package/dist/config.js +356 -356
  11. package/dist/dsp.d.ts +16 -16
  12. package/dist/dsp.js +76 -76
  13. package/dist/highlight.d.ts +32 -32
  14. package/dist/highlight.js +325 -325
  15. package/dist/image.d.ts +79 -79
  16. package/dist/image.js +458 -458
  17. package/dist/index.d.ts +72 -72
  18. package/dist/index.js +536 -533
  19. package/dist/index.js.map +1 -1
  20. package/dist/subtitleConvert.d.ts +3 -3
  21. package/dist/subtitleConvert.js +61 -61
  22. package/dist/video.d.ts +72 -72
  23. package/dist/video.js +338 -338
  24. package/dist/waitForEventLoop.d.ts +1 -1
  25. package/dist/waitForEventLoop.js +5 -5
  26. package/package.json +61 -61
  27. package/src/audio.ts +120 -120
  28. package/src/beats.ts +68 -68
  29. package/src/bpmEncoder.ts +97 -97
  30. package/src/config.ts +462 -462
  31. package/src/dsp.ts +95 -95
  32. package/src/highlight.ts +284 -284
  33. package/src/image.ts +602 -602
  34. package/src/index.ts +678 -674
  35. package/src/subtitleConvert.ts +63 -63
  36. package/src/tests/audio.test.ts +96 -96
  37. package/src/tests/config.test.ts +475 -475
  38. package/src/tests/dsp.test.ts +24 -24
  39. package/src/tests/highlight.test.ts +178 -178
  40. package/src/tests/image.test.ts +222 -222
  41. package/src/tests/index.test.ts +63 -63
  42. package/src/tests/subtitleConvert.test.ts +23 -23
  43. package/src/tests/video.test.ts +138 -138
  44. package/src/typings.d.ts +6 -6
  45. package/src/video.ts +437 -437
  46. package/src/waitForEventLoop.ts +2 -2
package/src/image.ts CHANGED
@@ -1,602 +1,602 @@
1
- import { RotationAliasName } from './index';
2
- import { decode, BmpDecoder } from 'bmp-js';
3
- import { EncodedBmp } from './bpmEncoder';
4
- import Jimp from 'jimp';
5
- import { SpectrumEffect } from './config';
6
-
7
- export interface Color {
8
- red: number;
9
- green: number;
10
- blue: number;
11
- }
12
-
13
- interface Position {
14
- x: number;
15
- y: number;
16
- }
17
-
18
- interface Size {
19
- width: number;
20
- height: number;
21
- }
22
-
23
- interface MixColorProps {
24
- color: Color;
25
- colorOpacity: number;
26
- backgroundColor: Color;
27
- }
28
-
29
- export const mixValues = (value1: number, value1Opacity: number, value2: number) =>
30
- Math.round(value2 * (1 - value1Opacity) + value1 * value1Opacity);
31
-
32
- export const mixColors = ({
33
- color,
34
- colorOpacity,
35
- backgroundColor,
36
- }: MixColorProps): Color => {
37
- return {
38
- red: mixValues(color.red, colorOpacity, backgroundColor.red),
39
- green: mixValues(color.green, colorOpacity, backgroundColor.green),
40
- blue: mixValues(color.blue, colorOpacity, backgroundColor.blue),
41
- };
42
- };
43
-
44
- const getRectPixelColor = (params: {
45
- imageDstBuffer: EncodedBmp;
46
- pixelIndex: number;
47
- color: Color;
48
- opacity: number;
49
- }) => {
50
- const {
51
- imageDstBuffer,
52
- pixelIndex,
53
- color,
54
- opacity,
55
- } = params;
56
- if (opacity === 1) {
57
- return color;
58
- }
59
-
60
- const backgroundPixelColor: Color = {
61
- blue: imageDstBuffer.data[pixelIndex],
62
- green: imageDstBuffer.data[pixelIndex + 1],
63
- red: imageDstBuffer.data[pixelIndex + 2],
64
- };
65
- const resultColor = mixColors({
66
- color,
67
- colorOpacity: opacity,
68
- backgroundColor: backgroundPixelColor,
69
- });
70
- return resultColor;
71
- };
72
-
73
- interface DrawRectPorps {
74
- imageDstBuffer: EncodedBmp;
75
- position: Position;
76
- size: Size;
77
- color: Color;
78
- opacity: number;
79
- }
80
-
81
- export const drawRect = ({
82
- imageDstBuffer,
83
- position,
84
- size,
85
- color,
86
- opacity,
87
- }: DrawRectPorps) => {
88
- for (let currY = position.y; currY < position.y + size.height; currY++) {
89
- for (let currX = position.x; currX < position.x + size.width; currX++) {
90
- const pixelIndex = imageDstBuffer.shiftPos + currY * imageDstBuffer.rowBytes + currX * 3;
91
- const pixelColor = getRectPixelColor({
92
- imageDstBuffer,
93
- pixelIndex,
94
- color,
95
- opacity,
96
- });
97
- imageDstBuffer.data[pixelIndex] = pixelColor.blue;
98
- imageDstBuffer.data[pixelIndex + 1] = pixelColor.green;
99
- imageDstBuffer.data[pixelIndex + 2] = pixelColor.red;
100
- }
101
- }
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
-
224
- interface DrawSpectrumProps {
225
- imageDstBuffer: EncodedBmp;
226
- spectrum: number[];
227
- size: Size;
228
- position: Position;
229
- rotation: RotationAliasName;
230
- margin: number;
231
- color: Color;
232
- opacity: number;
233
- }
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
-
256
- const drawSpectrum = ({
257
- imageDstBuffer,
258
- spectrum,
259
- size,
260
- position,
261
- rotation,
262
- margin,
263
- color,
264
- opacity,
265
- }: DrawSpectrumProps) => {
266
- const busWidth = Math.trunc(size.width / spectrum.length);
267
- const left = Math.trunc(position.x);
268
- const top = Math.trunc(position.y);
269
- const height = Math.trunc(size.height);
270
-
271
- for (let spectrumX = 0; spectrumX < spectrum.length; spectrumX++) {
272
- const spectrumValue = spectrum[spectrumX];
273
- if ((spectrumValue > 1) || (spectrumValue < 0)) {
274
- throw new Error('Spectrum values must be in range from 0 to 1');
275
- }
276
-
277
- const rectX = left + busWidth * spectrumX;
278
- const rectHeight = getRectHeight(height, spectrumValue);
279
- const rectY = getRectY(rotation, top, height, rectHeight);
280
- drawRect({
281
- imageDstBuffer,
282
- position: { x: rectX + margin, y: rectY },
283
- size: { width: busWidth - margin / 2, height: rectHeight },
284
- color,
285
- opacity,
286
- });
287
- }
288
- };
289
-
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 {
358
- backgroundImageBuffer: EncodedBmp;
359
- spectrum: number[];
360
- }
361
-
362
- export interface CreateVisualizerFrameProps {
363
- size: Size;
364
- position: Position;
365
- rotation: RotationAliasName;
366
- margin: number;
367
- color: Color | string;
368
- opacity: number;
369
- spectrumEffect?: SpectrumEffect;
370
- }
371
-
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 = () => {
384
- let prevSpectrum: number[] | null = null;
385
-
386
- return ({
387
- backgroundImageBuffer,
388
- spectrum,
389
- size,
390
- position,
391
- rotation,
392
- margin,
393
- color,
394
- opacity,
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
- 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) =>
509
- new Promise<Buffer>(async (resolve, reject) => {
510
- try {
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
- }
538
- image.getBuffer("image/bmp", (err, value) => {
539
- if (err) {
540
- reject(err);
541
- }
542
- resolve(value);
543
- });
544
- } catch {
545
- throw new Error(`Cannot procces image file from path: ${filePath}`);
546
- }
547
- });
548
-
549
- export const parseImage = (buffer: Buffer) => decode(buffer);
550
-
551
- export const getImageColor = (image: BmpDecoder): Color => {
552
- let blueSum = 0;
553
- let greenSum = 0;
554
- let redSum = 0;
555
-
556
- for (let currY = 0; currY < image.height; currY++) {
557
- for (let currX = 0; currX < image.width; currX++) {
558
- const idx = (image.width * currY + currX) << 2;
559
-
560
- blueSum += image.data[idx + 1];
561
- greenSum += image.data[idx + 2];
562
- redSum += image.data[idx + 3];
563
- }
564
- }
565
-
566
- const pixelsCount = image.width * image.height;
567
- return {
568
- red: ~~(redSum / pixelsCount),
569
- green: ~~(greenSum / pixelsCount),
570
- blue: ~~(blueSum / pixelsCount)
571
- };
572
- };
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
-
593
- export const invertColor = (color: Color) =>
594
- ({ red: 255 - color.red, green: 255 - color.green, blue: 255 - color.blue });
595
-
596
- const hexToRgb = (hex: String) => {
597
- const red = parseInt(hex.substring(1, 3), 16);
598
- const green = parseInt(hex.substring(3, 5), 16);
599
- const blue = parseInt(hex.substring(5, 7), 16);
600
-
601
- return { red, green, blue };
602
- };
1
+ import { RotationAliasName } from './index';
2
+ import { decode, BmpDecoder } from 'bmp-js';
3
+ import { EncodedBmp } from './bpmEncoder';
4
+ import Jimp from 'jimp';
5
+ import { SpectrumEffect } from './config';
6
+
7
+ export interface Color {
8
+ red: number;
9
+ green: number;
10
+ blue: number;
11
+ }
12
+
13
+ interface Position {
14
+ x: number;
15
+ y: number;
16
+ }
17
+
18
+ interface Size {
19
+ width: number;
20
+ height: number;
21
+ }
22
+
23
+ interface MixColorProps {
24
+ color: Color;
25
+ colorOpacity: number;
26
+ backgroundColor: Color;
27
+ }
28
+
29
+ export const mixValues = (value1: number, value1Opacity: number, value2: number) =>
30
+ Math.round(value2 * (1 - value1Opacity) + value1 * value1Opacity);
31
+
32
+ export const mixColors = ({
33
+ color,
34
+ colorOpacity,
35
+ backgroundColor,
36
+ }: MixColorProps): Color => {
37
+ return {
38
+ red: mixValues(color.red, colorOpacity, backgroundColor.red),
39
+ green: mixValues(color.green, colorOpacity, backgroundColor.green),
40
+ blue: mixValues(color.blue, colorOpacity, backgroundColor.blue),
41
+ };
42
+ };
43
+
44
+ const getRectPixelColor = (params: {
45
+ imageDstBuffer: EncodedBmp;
46
+ pixelIndex: number;
47
+ color: Color;
48
+ opacity: number;
49
+ }) => {
50
+ const {
51
+ imageDstBuffer,
52
+ pixelIndex,
53
+ color,
54
+ opacity,
55
+ } = params;
56
+ if (opacity === 1) {
57
+ return color;
58
+ }
59
+
60
+ const backgroundPixelColor: Color = {
61
+ blue: imageDstBuffer.data[pixelIndex],
62
+ green: imageDstBuffer.data[pixelIndex + 1],
63
+ red: imageDstBuffer.data[pixelIndex + 2],
64
+ };
65
+ const resultColor = mixColors({
66
+ color,
67
+ colorOpacity: opacity,
68
+ backgroundColor: backgroundPixelColor,
69
+ });
70
+ return resultColor;
71
+ };
72
+
73
+ interface DrawRectPorps {
74
+ imageDstBuffer: EncodedBmp;
75
+ position: Position;
76
+ size: Size;
77
+ color: Color;
78
+ opacity: number;
79
+ }
80
+
81
+ export const drawRect = ({
82
+ imageDstBuffer,
83
+ position,
84
+ size,
85
+ color,
86
+ opacity,
87
+ }: DrawRectPorps) => {
88
+ for (let currY = position.y; currY < position.y + size.height; currY++) {
89
+ for (let currX = position.x; currX < position.x + size.width; currX++) {
90
+ const pixelIndex = imageDstBuffer.shiftPos + currY * imageDstBuffer.rowBytes + currX * 3;
91
+ const pixelColor = getRectPixelColor({
92
+ imageDstBuffer,
93
+ pixelIndex,
94
+ color,
95
+ opacity,
96
+ });
97
+ imageDstBuffer.data[pixelIndex] = pixelColor.blue;
98
+ imageDstBuffer.data[pixelIndex + 1] = pixelColor.green;
99
+ imageDstBuffer.data[pixelIndex + 2] = pixelColor.red;
100
+ }
101
+ }
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
+
224
+ interface DrawSpectrumProps {
225
+ imageDstBuffer: EncodedBmp;
226
+ spectrum: number[];
227
+ size: Size;
228
+ position: Position;
229
+ rotation: RotationAliasName;
230
+ margin: number;
231
+ color: Color;
232
+ opacity: number;
233
+ }
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
+
256
+ const drawSpectrum = ({
257
+ imageDstBuffer,
258
+ spectrum,
259
+ size,
260
+ position,
261
+ rotation,
262
+ margin,
263
+ color,
264
+ opacity,
265
+ }: DrawSpectrumProps) => {
266
+ const busWidth = Math.trunc(size.width / spectrum.length);
267
+ const left = Math.trunc(position.x);
268
+ const top = Math.trunc(position.y);
269
+ const height = Math.trunc(size.height);
270
+
271
+ for (let spectrumX = 0; spectrumX < spectrum.length; spectrumX++) {
272
+ const spectrumValue = spectrum[spectrumX];
273
+ if ((spectrumValue > 1) || (spectrumValue < 0)) {
274
+ throw new Error('Spectrum values must be in range from 0 to 1');
275
+ }
276
+
277
+ const rectX = left + busWidth * spectrumX;
278
+ const rectHeight = getRectHeight(height, spectrumValue);
279
+ const rectY = getRectY(rotation, top, height, rectHeight);
280
+ drawRect({
281
+ imageDstBuffer,
282
+ position: { x: rectX + margin, y: rectY },
283
+ size: { width: busWidth - margin / 2, height: rectHeight },
284
+ color,
285
+ opacity,
286
+ });
287
+ }
288
+ };
289
+
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 {
358
+ backgroundImageBuffer: EncodedBmp;
359
+ spectrum: number[];
360
+ }
361
+
362
+ export interface CreateVisualizerFrameProps {
363
+ size: Size;
364
+ position: Position;
365
+ rotation: RotationAliasName;
366
+ margin: number;
367
+ color: Color | string;
368
+ opacity: number;
369
+ spectrumEffect?: SpectrumEffect;
370
+ }
371
+
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 = () => {
384
+ let prevSpectrum: number[] | null = null;
385
+
386
+ return ({
387
+ backgroundImageBuffer,
388
+ spectrum,
389
+ size,
390
+ position,
391
+ rotation,
392
+ margin,
393
+ color,
394
+ opacity,
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
+ 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) =>
509
+ new Promise<Buffer>(async (resolve, reject) => {
510
+ try {
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
+ }
538
+ image.getBuffer("image/bmp", (err, value) => {
539
+ if (err) {
540
+ reject(err);
541
+ }
542
+ resolve(value);
543
+ });
544
+ } catch {
545
+ throw new Error(`Cannot procces image file from path: ${filePath}`);
546
+ }
547
+ });
548
+
549
+ export const parseImage = (buffer: Buffer) => decode(buffer);
550
+
551
+ export const getImageColor = (image: BmpDecoder): Color => {
552
+ let blueSum = 0;
553
+ let greenSum = 0;
554
+ let redSum = 0;
555
+
556
+ for (let currY = 0; currY < image.height; currY++) {
557
+ for (let currX = 0; currX < image.width; currX++) {
558
+ const idx = (image.width * currY + currX) << 2;
559
+
560
+ blueSum += image.data[idx + 1];
561
+ greenSum += image.data[idx + 2];
562
+ redSum += image.data[idx + 3];
563
+ }
564
+ }
565
+
566
+ const pixelsCount = image.width * image.height;
567
+ return {
568
+ red: ~~(redSum / pixelsCount),
569
+ green: ~~(greenSum / pixelsCount),
570
+ blue: ~~(blueSum / pixelsCount)
571
+ };
572
+ };
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
+
593
+ export const invertColor = (color: Color) =>
594
+ ({ red: 255 - color.red, green: 255 - color.green, blue: 255 - color.blue });
595
+
596
+ const hexToRgb = (hex: String) => {
597
+ const red = parseInt(hex.substring(1, 3), 16);
598
+ const green = parseInt(hex.substring(3, 5), 16);
599
+ const blue = parseInt(hex.substring(5, 7), 16);
600
+
601
+ return { red, green, blue };
602
+ };