nodejs-audio-visualizer 5.5.2 → 5.6.3

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 (48) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +84 -83
  3. package/dist/audio.d.ts +13 -13
  4. package/dist/audio.js +128 -128
  5. package/dist/beats.d.ts +39 -44
  6. package/dist/beats.js +220 -313
  7. package/dist/beats.js.map +1 -1
  8. package/dist/bpmEncoder.d.ts +14 -14
  9. package/dist/bpmEncoder.js +97 -97
  10. package/dist/config.d.ts +81 -80
  11. package/dist/config.js +392 -391
  12. package/dist/config.js.map +1 -1
  13. package/dist/dsp.d.ts +16 -16
  14. package/dist/dsp.js +76 -76
  15. package/dist/highlight.d.ts +91 -91
  16. package/dist/highlight.js +606 -606
  17. package/dist/image.d.ts +107 -97
  18. package/dist/image.js +614 -545
  19. package/dist/image.js.map +1 -1
  20. package/dist/index.d.ts +98 -96
  21. package/dist/index.js +673 -667
  22. package/dist/index.js.map +1 -1
  23. package/dist/subtitleConvert.d.ts +3 -3
  24. package/dist/subtitleConvert.js +61 -61
  25. package/dist/video.d.ts +171 -171
  26. package/dist/video.js +743 -743
  27. package/dist/waitForEventLoop.d.ts +1 -1
  28. package/dist/waitForEventLoop.js +5 -5
  29. package/package.json +62 -61
  30. package/src/audio.ts +129 -129
  31. package/src/beats.ts +279 -396
  32. package/src/bpmEncoder.ts +97 -97
  33. package/src/config.ts +504 -501
  34. package/src/dsp.ts +95 -95
  35. package/src/highlight.ts +617 -617
  36. package/src/image.ts +791 -707
  37. package/src/index.ts +896 -885
  38. package/src/subtitleConvert.ts +63 -63
  39. package/src/tests/audio.test.ts +102 -102
  40. package/src/tests/beats.test.ts +134 -134
  41. package/src/tests/config.test.ts +568 -553
  42. package/src/tests/dsp.test.ts +24 -24
  43. package/src/tests/highlight.test.ts +397 -397
  44. package/src/tests/image.test.ts +371 -319
  45. package/src/tests/index.test.ts +180 -180
  46. package/src/tests/subtitleConvert.test.ts +23 -23
  47. package/src/typings.d.ts +28 -6
  48. package/src/waitForEventLoop.ts +2 -2
@@ -1,397 +1,397 @@
1
- import { expect } from 'chai';
2
- import {
3
- computeHighlightSlice,
4
- HIGHLIGHT_DURATION_SEC,
5
- MAX_BEAT_SNAP_SEC,
6
- buildBeatCandidates,
7
- snapSegmentToBeats,
8
- snapDropToBeat,
9
- } from '../highlight';
10
-
11
- const dummySpectrums = (n: number) => Array.from({ length: n }, () => [0]);
12
-
13
- /** Non-zero spectrum only on [rangeStart, rangeEnd) so frame energy is concentrated there. */
14
- function spectrumsWithEnergyInRange(
15
- n: number,
16
- rangeStart: number,
17
- rangeEndExclusive: number,
18
- binValue = 1,
19
- ): number[][] {
20
- return Array.from({ length: n }, (_, i) =>
21
- i >= rangeStart && i < rangeEndExclusive ? [binValue] : [0],
22
- );
23
- }
24
-
25
- describe('computeHighlightSlice', function() {
26
- it('centers window when energy is flat (e.g. silence)', async function() {
27
- const fps = 30;
28
- const totalFrames = 1000;
29
- const spectrums = dummySpectrums(totalFrames);
30
- const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
31
- const expectedStart = Math.floor((totalFrames - highlightFrames) / 2);
32
-
33
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, []);
34
-
35
- expect(result.highlightFrames).equal(highlightFrames);
36
- expect(result.startFrame).equal(expectedStart);
37
- expect(result.spectrums.length).equal(highlightFrames);
38
- expect(result.audioSeekSeconds).equal(expectedStart / fps);
39
- expect(result.audioDurationSeconds).equal(highlightFrames / fps);
40
- expect(result.audioSegments).deep.equal([
41
- { seekSeconds: expectedStart / fps, durationSeconds: highlightFrames / fps },
42
- ]);
43
- expect(result.runs).have.length(1);
44
- expect(result.runs[0].highlightFrames).equal(highlightFrames);
45
- });
46
-
47
- it('clamps to track end when the loudest stretch is only in the final window', async function() {
48
- const fps = 30;
49
- const totalFrames = 1000;
50
- const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
51
- const maxStart = totalFrames - highlightFrames;
52
- const spectrums = spectrumsWithEnergyInRange(totalFrames, maxStart, totalFrames);
53
-
54
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
55
- { frameIndex: 950, intensity: 1 },
56
- ]);
57
-
58
- expect(result.startFrame).equal(maxStart);
59
- expect(result.highlightFrames).equal(highlightFrames);
60
- expect(result.spectrums.length).equal(highlightFrames);
61
- expect(result.audioSegments).deep.equal([
62
- { seekSeconds: maxStart / fps, durationSeconds: highlightFrames / fps },
63
- ]);
64
- expect(result.runs).have.length(1);
65
- });
66
-
67
- it('uses full track when shorter than highlight duration', async function() {
68
- const fps = 30;
69
- const totalFrames = 400;
70
- const spectrums = dummySpectrums(totalFrames);
71
-
72
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
73
- { frameIndex: 100, intensity: 1 },
74
- ]);
75
-
76
- expect(result.startFrame).equal(0);
77
- expect(result.highlightFrames).equal(totalFrames);
78
- expect(result.spectrums.length).equal(totalFrames);
79
- expect(result.beatFrameIndices).deep.equal([100]);
80
- expect(result.audioSeekSeconds).equal(0);
81
- expect(result.audioDurationSeconds).equal(totalFrames / fps);
82
- expect(result.audioSegments).deep.equal([
83
- { seekSeconds: 0, durationSeconds: totalFrames / fps },
84
- ]);
85
- expect(result.runs).have.length(1);
86
- expect(result.runs[0].spectrums.length).equal(totalFrames);
87
- });
88
-
89
- it('remaps beat indices into the highlight window', async function() {
90
- const fps = 30;
91
- const totalFrames = 1000;
92
- const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
93
- const spectrums = spectrumsWithEnergyInRange(totalFrames, 300, 300 + highlightFrames);
94
-
95
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
96
- { frameIndex: 300, intensity: 1 },
97
- { frameIndex: 400, intensity: 0.5 },
98
- { frameIndex: 800, intensity: 0.2 },
99
- ]);
100
-
101
- expect(result.startFrame).equal(300);
102
- expect(result.beatFrameIndices).deep.equal([0, 100]);
103
- expect(result.audioSegments).deep.equal([
104
- { seekSeconds: 300 / fps, durationSeconds: highlightFrames / fps },
105
- ]);
106
- expect(result.runs).have.length(1);
107
- });
108
-
109
- it('picks the window with the highest summed spectral energy', async function() {
110
- const fps = 30;
111
- const totalFrames = 1000;
112
- const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
113
- const loud = 10;
114
- const spectrums = spectrumsWithEnergyInRange(totalFrames, 10, 10 + highlightFrames, loud);
115
-
116
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
117
- { frameIndex: 10, intensity: 99 },
118
- { frameIndex: 200, intensity: 0.4 },
119
- { frameIndex: 500, intensity: 0.9 },
120
- ]);
121
-
122
- expect(result.startFrame).equal(10);
123
- // The end moves from frame 460 onto the nearby beat at 500.
124
- expect(result.highlightFrames).equal(490);
125
- expect(result.audioSegments).deep.equal([
126
- { seekSeconds: 10 / fps, durationSeconds: 490 / fps },
127
- ]);
128
- expect(result.runs).have.length(1);
129
- });
130
-
131
- it('returns empty slice for zero frames', async function() {
132
- const result = await computeHighlightSlice(30, 0, [], []);
133
- expect(result.highlightFrames).equal(0);
134
- expect(result.spectrums).deep.equal([]);
135
- expect(result.beatFrameIndices).deep.equal([]);
136
- expect(result.audioSegments).deep.equal([]);
137
- expect(result.runs).deep.equal([]);
138
- });
139
-
140
- it('concatenates two non-overlapping highlights chronologically with segmentCount 2', async function() {
141
- const fps = 30;
142
- const totalFrames = 1000;
143
- const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
144
- const loudA = 10;
145
- const loudB = 5;
146
- const spectrumsA = spectrumsWithEnergyInRange(totalFrames, 0, 450, loudA);
147
- const spectrumsB = spectrumsWithEnergyInRange(totalFrames, 500, 950, loudB);
148
- const spectrums = spectrumsA.map((row, i) => {
149
- const b = spectrumsB[i];
150
- const v = (row[0] || 0) + (b[0] || 0);
151
- return v > 0 ? [v] : [0];
152
- });
153
-
154
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
155
- { frameIndex: 50, intensity: 1 },
156
- { frameIndex: 550, intensity: 1 },
157
- ], 2);
158
-
159
- expect(result.highlightFrames).equal(highlightFrames * 2);
160
- expect(result.spectrums.length).equal(highlightFrames * 2);
161
- expect(result.startFrame).equal(0);
162
- expect(result.audioSegments).deep.equal([
163
- { seekSeconds: 0, durationSeconds: highlightFrames / fps },
164
- { seekSeconds: 500 / fps, durationSeconds: highlightFrames / fps },
165
- ]);
166
- expect(result.beatFrameIndices).deep.equal([50, 500]);
167
- expect(result.runs).have.length(2);
168
- expect(result.runs[0].startFrame).equal(0);
169
- expect(result.runs[1].startFrame).equal(500);
170
- expect(result.runs[0].spectrums.length).equal(highlightFrames);
171
- expect(result.runs[1].spectrums.length).equal(highlightFrames);
172
- });
173
-
174
- it('ignores extra segmentCount when only one full highlight window fits', async function() {
175
- const fps = 30;
176
- const totalFrames = 600;
177
- const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
178
- const spectrums = spectrumsWithEnergyInRange(totalFrames, 0, highlightFrames, 1);
179
-
180
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 3);
181
-
182
- expect(result.highlightFrames).equal(highlightFrames);
183
- expect(result.audioSegments).have.length(1);
184
- expect(result.runs).have.length(1);
185
- });
186
-
187
- it('starts the whole lead-in before the drop when leadInFrames is given', async function() {
188
- const fps = 10;
189
- const totalFrames = 1000;
190
- // Quiet until frame 600, loud after: the drop is at 600.
191
- const spectrums = spectrumsWithEnergyInRange(totalFrames, 600, totalFrames);
192
- const beats = Array.from({ length: 200 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
193
-
194
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 43);
195
-
196
- expect(result.runs).have.length(1);
197
- expect(result.runs[0].startFrame).equal(557);
198
- expect(result.runs[0].leadInFrames).equal(43);
199
- // The lead-in comes on top of the ~15s that follow the drop.
200
- expect(result.runs[0].highlightFrames).equal(43 + Math.ceil(HIGHLIGHT_DURATION_SEC * fps));
201
- });
202
-
203
- it('pads the lead-in with silence before the track start when only an early drop fits', async function() {
204
- const fps = 10;
205
- // A full 130-frame lead-in needs a drop at 130+, but ~15s must follow it: only 100 fits.
206
- const totalFrames = 250;
207
- const spectrums = spectrumsWithEnergyInRange(totalFrames, 100, totalFrames);
208
- const beats = Array.from({ length: 50 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
209
-
210
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 130);
211
- const run = result.runs[0];
212
-
213
- expect(run.startFrame).equal(-30);
214
- expect(run.leadInFrames).equal(130);
215
- expect(run.spectrums).have.length(run.highlightFrames);
216
- expect(run.spectrums.slice(0, 30).every(s => s.every(v => v === 0))).equal(true);
217
- expect(run.beatFrameIndices[0]).equal(30);
218
- expect(run.audioSegment).deep.equal({
219
- seekSeconds: 0,
220
- durationSeconds: (run.highlightFrames - 30) / fps,
221
- delaySeconds: 3,
222
- });
223
- });
224
-
225
- it('picks a separate drop for each highlight', async function() {
226
- const fps = 10;
227
- const totalFrames = 2000;
228
- const spectrums = Array.from({ length: totalFrames }, (_, i) =>
229
- (i >= 500 && i < 800) || (i >= 1400 && i < 1700) ? [1] : [0],
230
- );
231
- const beats = Array.from({ length: 400 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
232
-
233
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 2, null, 30);
234
-
235
- expect(result.runs.map(run => run.startFrame + (run.leadInFrames as number))).deep.equal([500, 1400]);
236
- expect(result.runs.map(run => run.leadInFrames)).deep.equal([30, 30]);
237
- });
238
-
239
- it('ends the lead-in on the energy onset even when beats are off it', async function() {
240
- const fps = 10;
241
- const totalFrames = 1000;
242
- const spectrums = spectrumsWithEnergyInRange(totalFrames, 602, totalFrames);
243
- // Beats every 5 frames, none on the onset at 602.
244
- const beats = Array.from({ length: 200 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
245
-
246
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 43);
247
-
248
- expect(result.runs[0].startFrame + (result.runs[0].leadInFrames as number)).equal(602);
249
- expect(result.runs[0].leadInFrames).equal(43);
250
- });
251
-
252
- it('ends the lead-in on a nearby grid beat when a tempo grid is given', async function() {
253
- const fps = 10;
254
- const totalFrames = 1000;
255
- const spectrums = spectrumsWithEnergyInRange(totalFrames, 602, totalFrames);
256
- const beats = Array.from({ length: 200 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
257
- // Grid beats every 10 frames on multiples of 10: 600 is 2 frames (0.2 beat) from the onset.
258
- const grid = { periodFrames: 10, phaseFrame: 0 };
259
-
260
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, grid, 43);
261
-
262
- expect(result.runs[0].startFrame).equal(557);
263
- expect(result.runs[0].leadInFrames).equal(43);
264
- });
265
-
266
- it('keeps the full lead-in rather than ending it past the drop or on a weak rise', async function() {
267
- const fps = 10;
268
- const totalFrames = 600;
269
- // Big drop at 140, then a small bump at 300; a 200-frame lead-in doesn't fit before 140.
270
- const spectrums = Array.from({ length: totalFrames }, (_, i) =>
271
- i < 140 ? [0.1] : i < 300 ? [1] : [1.1],
272
- );
273
- const beats = Array.from({ length: 120 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
274
-
275
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 200);
276
-
277
- expect(result.runs[0].startFrame).equal(-60);
278
- expect(result.runs[0].leadInFrames).equal(200);
279
- });
280
-
281
- it('puts the lead-in before the energy window when no drop is found', async function() {
282
- const fps = 10;
283
- const totalFrames = 1000;
284
- const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
285
- const spectrums = spectrumsWithEnergyInRange(totalFrames, 600, totalFrames);
286
-
287
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 1, null, 900);
288
-
289
- // The window starts at 600; the rest of the lead-in is silence before the track start.
290
- expect(result.runs[0].startFrame).equal(-300);
291
- expect(result.runs[0].leadInFrames).equal(900);
292
- expect(result.runs[0].highlightFrames).equal(900 + highlightFrames);
293
- expect(result.runs[0].audioSegment.delaySeconds).equal(30);
294
- });
295
-
296
- it('snaps highlight start and end onto detected beats', async function() {
297
- const fps = 30;
298
- const totalFrames = 1000;
299
- const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
300
- const spectrums = spectrumsWithEnergyInRange(totalFrames, 300, 300 + highlightFrames);
301
-
302
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
303
- { frameIndex: 290, intensity: 1 },
304
- { frameIndex: 600, intensity: 1 },
305
- { frameIndex: 760, intensity: 1 },
306
- ]);
307
-
308
- expect(result.startFrame).equal(290);
309
- expect(result.highlightFrames).equal(760 - 290);
310
- expect(result.audioSegments).deep.equal([
311
- { seekSeconds: 290 / fps, durationSeconds: (760 - 290) / fps },
312
- ]);
313
- expect(result.beatFrameIndices).deep.equal([0, 310]);
314
- });
315
-
316
- it('snaps to a tempo grid and prefers a whole number of bars', async function() {
317
- const fps = 30;
318
- const totalFrames = 2000;
319
- const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
320
- const spectrums = spectrumsWithEnergyInRange(totalFrames, 505, 505 + highlightFrames);
321
- // 120 BPM at 30 fps: a beat every 15 frames, a bar every 60.
322
- const beatGrid = { periodFrames: 15, phaseFrame: 5 };
323
-
324
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 1, beatGrid);
325
-
326
- expect((result.startFrame - 5) % 15).equal(0);
327
- expect(result.highlightFrames % 60).equal(0);
328
- expect(Math.abs(result.startFrame - 505)).lte(MAX_BEAT_SNAP_SEC * fps);
329
- expect(Math.abs(result.highlightFrames - highlightFrames)).lte(2 * MAX_BEAT_SNAP_SEC * fps);
330
- });
331
-
332
- it('keeps several snapped highlights from overlapping', async function() {
333
- const fps = 30;
334
- const totalFrames = 3000;
335
- const spectrums = Array.from({ length: totalFrames }, (_, i) => [1 + Math.sin(i / 50)]);
336
- const beatGrid = { periodFrames: 14, phaseFrame: 3 };
337
-
338
- const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 4, beatGrid);
339
-
340
- for (let i = 1; i < result.runs.length; i++) {
341
- const prev = result.runs[i - 1];
342
- expect(result.runs[i].startFrame).gte(prev.startFrame + prev.highlightFrames);
343
- }
344
- });
345
- });
346
-
347
- describe('buildBeatCandidates', function() {
348
- it('uses onsets when no tempo grid is given', function() {
349
- expect(buildBeatCandidates(30, 100, [
350
- { frameIndex: 40, intensity: 1 },
351
- { frameIndex: 10, intensity: 1 },
352
- { frameIndex: 10, intensity: 1 },
353
- ])).deep.equal([10, 40]);
354
- });
355
-
356
- it('uses tracked beats, then the grid nudged onto nearby onsets', function() {
357
- const frames = buildBeatCandidates(10, 60, [{ frameIndex: 41, intensity: 1 }], {
358
- periodFrames: 10,
359
- phaseFrame: 0,
360
- beatsSec: [0.1, 1.1, 2.1],
361
- });
362
- expect(frames).deep.equal([1, 11, 21, 30, 41, 50, 60]);
363
- });
364
- });
365
-
366
- describe('snapSegmentToBeats', function() {
367
- it('leaves a segment unchanged when no beat is close enough', function() {
368
- const seg = { startFrame: 100, highlightFrames: 200 };
369
- expect(snapSegmentToBeats(seg, 1000, [10, 900], 20)).deep.equal(seg);
370
- });
371
- });
372
-
373
- describe('snapDropToBeat', function() {
374
- it('moves the drop and its start together onto a beat within a fifth of a beat', function() {
375
- const drop = { startFrame: 60, dropFrame: 103 };
376
- snapDropToBeat(drop, [80, 100, 120], 20);
377
- expect(drop).deep.equal({ startFrame: 57, dropFrame: 100 });
378
- });
379
-
380
- it('keeps the drop when the nearest beat is too far', function() {
381
- const drop = { startFrame: 60, dropFrame: 105 };
382
- snapDropToBeat(drop, [80, 100, 120], 20);
383
- expect(drop).deep.equal({ startFrame: 60, dropFrame: 105 });
384
- });
385
-
386
- it('keeps the drop without a tempo period', function() {
387
- const drop = { startFrame: 60, dropFrame: 101 };
388
- snapDropToBeat(drop, [80, 100, 120]);
389
- expect(drop).deep.equal({ startFrame: 60, dropFrame: 101 });
390
- });
391
-
392
- it('does not push a lead-in that fit in the track back past its start', function() {
393
- const drop = { startFrame: 1, dropFrame: 43 };
394
- snapDropToBeat(drop, [40], 20);
395
- expect(drop).deep.equal({ startFrame: 1, dropFrame: 43 });
396
- });
397
- });
1
+ import { expect } from 'chai';
2
+ import {
3
+ computeHighlightSlice,
4
+ HIGHLIGHT_DURATION_SEC,
5
+ MAX_BEAT_SNAP_SEC,
6
+ buildBeatCandidates,
7
+ snapSegmentToBeats,
8
+ snapDropToBeat,
9
+ } from '../highlight';
10
+
11
+ const dummySpectrums = (n: number) => Array.from({ length: n }, () => [0]);
12
+
13
+ /** Non-zero spectrum only on [rangeStart, rangeEnd) so frame energy is concentrated there. */
14
+ function spectrumsWithEnergyInRange(
15
+ n: number,
16
+ rangeStart: number,
17
+ rangeEndExclusive: number,
18
+ binValue = 1,
19
+ ): number[][] {
20
+ return Array.from({ length: n }, (_, i) =>
21
+ i >= rangeStart && i < rangeEndExclusive ? [binValue] : [0],
22
+ );
23
+ }
24
+
25
+ describe('computeHighlightSlice', function() {
26
+ it('centers window when energy is flat (e.g. silence)', async function() {
27
+ const fps = 30;
28
+ const totalFrames = 1000;
29
+ const spectrums = dummySpectrums(totalFrames);
30
+ const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
31
+ const expectedStart = Math.floor((totalFrames - highlightFrames) / 2);
32
+
33
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, []);
34
+
35
+ expect(result.highlightFrames).equal(highlightFrames);
36
+ expect(result.startFrame).equal(expectedStart);
37
+ expect(result.spectrums.length).equal(highlightFrames);
38
+ expect(result.audioSeekSeconds).equal(expectedStart / fps);
39
+ expect(result.audioDurationSeconds).equal(highlightFrames / fps);
40
+ expect(result.audioSegments).deep.equal([
41
+ { seekSeconds: expectedStart / fps, durationSeconds: highlightFrames / fps },
42
+ ]);
43
+ expect(result.runs).have.length(1);
44
+ expect(result.runs[0].highlightFrames).equal(highlightFrames);
45
+ });
46
+
47
+ it('clamps to track end when the loudest stretch is only in the final window', async function() {
48
+ const fps = 30;
49
+ const totalFrames = 1000;
50
+ const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
51
+ const maxStart = totalFrames - highlightFrames;
52
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, maxStart, totalFrames);
53
+
54
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
55
+ { frameIndex: 950, intensity: 1 },
56
+ ]);
57
+
58
+ expect(result.startFrame).equal(maxStart);
59
+ expect(result.highlightFrames).equal(highlightFrames);
60
+ expect(result.spectrums.length).equal(highlightFrames);
61
+ expect(result.audioSegments).deep.equal([
62
+ { seekSeconds: maxStart / fps, durationSeconds: highlightFrames / fps },
63
+ ]);
64
+ expect(result.runs).have.length(1);
65
+ });
66
+
67
+ it('uses full track when shorter than highlight duration', async function() {
68
+ const fps = 30;
69
+ const totalFrames = 400;
70
+ const spectrums = dummySpectrums(totalFrames);
71
+
72
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
73
+ { frameIndex: 100, intensity: 1 },
74
+ ]);
75
+
76
+ expect(result.startFrame).equal(0);
77
+ expect(result.highlightFrames).equal(totalFrames);
78
+ expect(result.spectrums.length).equal(totalFrames);
79
+ expect(result.beatFrameIndices).deep.equal([100]);
80
+ expect(result.audioSeekSeconds).equal(0);
81
+ expect(result.audioDurationSeconds).equal(totalFrames / fps);
82
+ expect(result.audioSegments).deep.equal([
83
+ { seekSeconds: 0, durationSeconds: totalFrames / fps },
84
+ ]);
85
+ expect(result.runs).have.length(1);
86
+ expect(result.runs[0].spectrums.length).equal(totalFrames);
87
+ });
88
+
89
+ it('remaps beat indices into the highlight window', async function() {
90
+ const fps = 30;
91
+ const totalFrames = 1000;
92
+ const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
93
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 300, 300 + highlightFrames);
94
+
95
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
96
+ { frameIndex: 300, intensity: 1 },
97
+ { frameIndex: 400, intensity: 0.5 },
98
+ { frameIndex: 800, intensity: 0.2 },
99
+ ]);
100
+
101
+ expect(result.startFrame).equal(300);
102
+ expect(result.beatFrameIndices).deep.equal([0, 100]);
103
+ expect(result.audioSegments).deep.equal([
104
+ { seekSeconds: 300 / fps, durationSeconds: highlightFrames / fps },
105
+ ]);
106
+ expect(result.runs).have.length(1);
107
+ });
108
+
109
+ it('picks the window with the highest summed spectral energy', async function() {
110
+ const fps = 30;
111
+ const totalFrames = 1000;
112
+ const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
113
+ const loud = 10;
114
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 10, 10 + highlightFrames, loud);
115
+
116
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
117
+ { frameIndex: 10, intensity: 99 },
118
+ { frameIndex: 200, intensity: 0.4 },
119
+ { frameIndex: 500, intensity: 0.9 },
120
+ ]);
121
+
122
+ expect(result.startFrame).equal(10);
123
+ // The end moves from frame 460 onto the nearby beat at 500.
124
+ expect(result.highlightFrames).equal(490);
125
+ expect(result.audioSegments).deep.equal([
126
+ { seekSeconds: 10 / fps, durationSeconds: 490 / fps },
127
+ ]);
128
+ expect(result.runs).have.length(1);
129
+ });
130
+
131
+ it('returns empty slice for zero frames', async function() {
132
+ const result = await computeHighlightSlice(30, 0, [], []);
133
+ expect(result.highlightFrames).equal(0);
134
+ expect(result.spectrums).deep.equal([]);
135
+ expect(result.beatFrameIndices).deep.equal([]);
136
+ expect(result.audioSegments).deep.equal([]);
137
+ expect(result.runs).deep.equal([]);
138
+ });
139
+
140
+ it('concatenates two non-overlapping highlights chronologically with segmentCount 2', async function() {
141
+ const fps = 30;
142
+ const totalFrames = 1000;
143
+ const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
144
+ const loudA = 10;
145
+ const loudB = 5;
146
+ const spectrumsA = spectrumsWithEnergyInRange(totalFrames, 0, 450, loudA);
147
+ const spectrumsB = spectrumsWithEnergyInRange(totalFrames, 500, 950, loudB);
148
+ const spectrums = spectrumsA.map((row, i) => {
149
+ const b = spectrumsB[i];
150
+ const v = (row[0] || 0) + (b[0] || 0);
151
+ return v > 0 ? [v] : [0];
152
+ });
153
+
154
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
155
+ { frameIndex: 50, intensity: 1 },
156
+ { frameIndex: 550, intensity: 1 },
157
+ ], 2);
158
+
159
+ expect(result.highlightFrames).equal(highlightFrames * 2);
160
+ expect(result.spectrums.length).equal(highlightFrames * 2);
161
+ expect(result.startFrame).equal(0);
162
+ expect(result.audioSegments).deep.equal([
163
+ { seekSeconds: 0, durationSeconds: highlightFrames / fps },
164
+ { seekSeconds: 500 / fps, durationSeconds: highlightFrames / fps },
165
+ ]);
166
+ expect(result.beatFrameIndices).deep.equal([50, 500]);
167
+ expect(result.runs).have.length(2);
168
+ expect(result.runs[0].startFrame).equal(0);
169
+ expect(result.runs[1].startFrame).equal(500);
170
+ expect(result.runs[0].spectrums.length).equal(highlightFrames);
171
+ expect(result.runs[1].spectrums.length).equal(highlightFrames);
172
+ });
173
+
174
+ it('ignores extra segmentCount when only one full highlight window fits', async function() {
175
+ const fps = 30;
176
+ const totalFrames = 600;
177
+ const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
178
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 0, highlightFrames, 1);
179
+
180
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 3);
181
+
182
+ expect(result.highlightFrames).equal(highlightFrames);
183
+ expect(result.audioSegments).have.length(1);
184
+ expect(result.runs).have.length(1);
185
+ });
186
+
187
+ it('starts the whole lead-in before the drop when leadInFrames is given', async function() {
188
+ const fps = 10;
189
+ const totalFrames = 1000;
190
+ // Quiet until frame 600, loud after: the drop is at 600.
191
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 600, totalFrames);
192
+ const beats = Array.from({ length: 200 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
193
+
194
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 43);
195
+
196
+ expect(result.runs).have.length(1);
197
+ expect(result.runs[0].startFrame).equal(557);
198
+ expect(result.runs[0].leadInFrames).equal(43);
199
+ // The lead-in comes on top of the ~15s that follow the drop.
200
+ expect(result.runs[0].highlightFrames).equal(43 + Math.ceil(HIGHLIGHT_DURATION_SEC * fps));
201
+ });
202
+
203
+ it('pads the lead-in with silence before the track start when only an early drop fits', async function() {
204
+ const fps = 10;
205
+ // A full 130-frame lead-in needs a drop at 130+, but ~15s must follow it: only 100 fits.
206
+ const totalFrames = 250;
207
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 100, totalFrames);
208
+ const beats = Array.from({ length: 50 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
209
+
210
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 130);
211
+ const run = result.runs[0];
212
+
213
+ expect(run.startFrame).equal(-30);
214
+ expect(run.leadInFrames).equal(130);
215
+ expect(run.spectrums).have.length(run.highlightFrames);
216
+ expect(run.spectrums.slice(0, 30).every(s => s.every(v => v === 0))).equal(true);
217
+ expect(run.beatFrameIndices[0]).equal(30);
218
+ expect(run.audioSegment).deep.equal({
219
+ seekSeconds: 0,
220
+ durationSeconds: (run.highlightFrames - 30) / fps,
221
+ delaySeconds: 3,
222
+ });
223
+ });
224
+
225
+ it('picks a separate drop for each highlight', async function() {
226
+ const fps = 10;
227
+ const totalFrames = 2000;
228
+ const spectrums = Array.from({ length: totalFrames }, (_, i) =>
229
+ (i >= 500 && i < 800) || (i >= 1400 && i < 1700) ? [1] : [0],
230
+ );
231
+ const beats = Array.from({ length: 400 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
232
+
233
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 2, null, 30);
234
+
235
+ expect(result.runs.map(run => run.startFrame + (run.leadInFrames as number))).deep.equal([500, 1400]);
236
+ expect(result.runs.map(run => run.leadInFrames)).deep.equal([30, 30]);
237
+ });
238
+
239
+ it('ends the lead-in on the energy onset even when beats are off it', async function() {
240
+ const fps = 10;
241
+ const totalFrames = 1000;
242
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 602, totalFrames);
243
+ // Beats every 5 frames, none on the onset at 602.
244
+ const beats = Array.from({ length: 200 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
245
+
246
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 43);
247
+
248
+ expect(result.runs[0].startFrame + (result.runs[0].leadInFrames as number)).equal(602);
249
+ expect(result.runs[0].leadInFrames).equal(43);
250
+ });
251
+
252
+ it('ends the lead-in on a nearby grid beat when a tempo grid is given', async function() {
253
+ const fps = 10;
254
+ const totalFrames = 1000;
255
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 602, totalFrames);
256
+ const beats = Array.from({ length: 200 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
257
+ // Grid beats every 10 frames on multiples of 10: 600 is 2 frames (0.2 beat) from the onset.
258
+ const grid = { periodFrames: 10, phaseFrame: 0 };
259
+
260
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, grid, 43);
261
+
262
+ expect(result.runs[0].startFrame).equal(557);
263
+ expect(result.runs[0].leadInFrames).equal(43);
264
+ });
265
+
266
+ it('keeps the full lead-in rather than ending it past the drop or on a weak rise', async function() {
267
+ const fps = 10;
268
+ const totalFrames = 600;
269
+ // Big drop at 140, then a small bump at 300; a 200-frame lead-in doesn't fit before 140.
270
+ const spectrums = Array.from({ length: totalFrames }, (_, i) =>
271
+ i < 140 ? [0.1] : i < 300 ? [1] : [1.1],
272
+ );
273
+ const beats = Array.from({ length: 120 }, (_, i) => ({ frameIndex: i * 5, intensity: 1 }));
274
+
275
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, beats, 1, null, 200);
276
+
277
+ expect(result.runs[0].startFrame).equal(-60);
278
+ expect(result.runs[0].leadInFrames).equal(200);
279
+ });
280
+
281
+ it('puts the lead-in before the energy window when no drop is found', async function() {
282
+ const fps = 10;
283
+ const totalFrames = 1000;
284
+ const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
285
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 600, totalFrames);
286
+
287
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 1, null, 900);
288
+
289
+ // The window starts at 600; the rest of the lead-in is silence before the track start.
290
+ expect(result.runs[0].startFrame).equal(-300);
291
+ expect(result.runs[0].leadInFrames).equal(900);
292
+ expect(result.runs[0].highlightFrames).equal(900 + highlightFrames);
293
+ expect(result.runs[0].audioSegment.delaySeconds).equal(30);
294
+ });
295
+
296
+ it('snaps highlight start and end onto detected beats', async function() {
297
+ const fps = 30;
298
+ const totalFrames = 1000;
299
+ const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
300
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 300, 300 + highlightFrames);
301
+
302
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [
303
+ { frameIndex: 290, intensity: 1 },
304
+ { frameIndex: 600, intensity: 1 },
305
+ { frameIndex: 760, intensity: 1 },
306
+ ]);
307
+
308
+ expect(result.startFrame).equal(290);
309
+ expect(result.highlightFrames).equal(760 - 290);
310
+ expect(result.audioSegments).deep.equal([
311
+ { seekSeconds: 290 / fps, durationSeconds: (760 - 290) / fps },
312
+ ]);
313
+ expect(result.beatFrameIndices).deep.equal([0, 310]);
314
+ });
315
+
316
+ it('snaps to a tempo grid and prefers a whole number of bars', async function() {
317
+ const fps = 30;
318
+ const totalFrames = 2000;
319
+ const highlightFrames = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
320
+ const spectrums = spectrumsWithEnergyInRange(totalFrames, 505, 505 + highlightFrames);
321
+ // 120 BPM at 30 fps: a beat every 15 frames, a bar every 60.
322
+ const beatGrid = { periodFrames: 15, phaseFrame: 5 };
323
+
324
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 1, beatGrid);
325
+
326
+ expect((result.startFrame - 5) % 15).equal(0);
327
+ expect(result.highlightFrames % 60).equal(0);
328
+ expect(Math.abs(result.startFrame - 505)).lte(MAX_BEAT_SNAP_SEC * fps);
329
+ expect(Math.abs(result.highlightFrames - highlightFrames)).lte(2 * MAX_BEAT_SNAP_SEC * fps);
330
+ });
331
+
332
+ it('keeps several snapped highlights from overlapping', async function() {
333
+ const fps = 30;
334
+ const totalFrames = 3000;
335
+ const spectrums = Array.from({ length: totalFrames }, (_, i) => [1 + Math.sin(i / 50)]);
336
+ const beatGrid = { periodFrames: 14, phaseFrame: 3 };
337
+
338
+ const result = await computeHighlightSlice(fps, totalFrames, spectrums, [], 4, beatGrid);
339
+
340
+ for (let i = 1; i < result.runs.length; i++) {
341
+ const prev = result.runs[i - 1];
342
+ expect(result.runs[i].startFrame).gte(prev.startFrame + prev.highlightFrames);
343
+ }
344
+ });
345
+ });
346
+
347
+ describe('buildBeatCandidates', function() {
348
+ it('uses onsets when no tempo grid is given', function() {
349
+ expect(buildBeatCandidates(30, 100, [
350
+ { frameIndex: 40, intensity: 1 },
351
+ { frameIndex: 10, intensity: 1 },
352
+ { frameIndex: 10, intensity: 1 },
353
+ ])).deep.equal([10, 40]);
354
+ });
355
+
356
+ it('uses tracked beats, then the grid nudged onto nearby onsets', function() {
357
+ const frames = buildBeatCandidates(10, 60, [{ frameIndex: 41, intensity: 1 }], {
358
+ periodFrames: 10,
359
+ phaseFrame: 0,
360
+ beatsSec: [0.1, 1.1, 2.1],
361
+ });
362
+ expect(frames).deep.equal([1, 11, 21, 30, 41, 50, 60]);
363
+ });
364
+ });
365
+
366
+ describe('snapSegmentToBeats', function() {
367
+ it('leaves a segment unchanged when no beat is close enough', function() {
368
+ const seg = { startFrame: 100, highlightFrames: 200 };
369
+ expect(snapSegmentToBeats(seg, 1000, [10, 900], 20)).deep.equal(seg);
370
+ });
371
+ });
372
+
373
+ describe('snapDropToBeat', function() {
374
+ it('moves the drop and its start together onto a beat within a fifth of a beat', function() {
375
+ const drop = { startFrame: 60, dropFrame: 103 };
376
+ snapDropToBeat(drop, [80, 100, 120], 20);
377
+ expect(drop).deep.equal({ startFrame: 57, dropFrame: 100 });
378
+ });
379
+
380
+ it('keeps the drop when the nearest beat is too far', function() {
381
+ const drop = { startFrame: 60, dropFrame: 105 };
382
+ snapDropToBeat(drop, [80, 100, 120], 20);
383
+ expect(drop).deep.equal({ startFrame: 60, dropFrame: 105 });
384
+ });
385
+
386
+ it('keeps the drop without a tempo period', function() {
387
+ const drop = { startFrame: 60, dropFrame: 101 };
388
+ snapDropToBeat(drop, [80, 100, 120]);
389
+ expect(drop).deep.equal({ startFrame: 60, dropFrame: 101 });
390
+ });
391
+
392
+ it('does not push a lead-in that fit in the track back past its start', function() {
393
+ const drop = { startFrame: 1, dropFrame: 43 };
394
+ snapDropToBeat(drop, [40], 20);
395
+ expect(drop).deep.equal({ startFrame: 1, dropFrame: 43 });
396
+ });
397
+ });