nodejs-audio-visualizer 5.5.3 → 5.6.4

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 (49) 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 -39
  6. package/dist/beats.js +220 -220
  7. package/dist/bpmEncoder.d.ts +14 -14
  8. package/dist/bpmEncoder.js +97 -97
  9. package/dist/config.d.ts +81 -80
  10. package/dist/config.js +392 -391
  11. package/dist/config.js.map +1 -1
  12. package/dist/dsp.d.ts +16 -16
  13. package/dist/dsp.js +76 -76
  14. package/dist/highlight.d.ts +91 -91
  15. package/dist/highlight.js +606 -606
  16. package/dist/image.d.ts +107 -97
  17. package/dist/image.js +614 -545
  18. package/dist/image.js.map +1 -1
  19. package/dist/index.d.ts +98 -96
  20. package/dist/index.js +673 -667
  21. package/dist/index.js.map +1 -1
  22. package/dist/subtitleConvert.d.ts +3 -3
  23. package/dist/subtitleConvert.js +61 -61
  24. package/dist/video.d.ts +171 -171
  25. package/dist/video.js +743 -743
  26. package/dist/waitForEventLoop.d.ts +1 -1
  27. package/dist/waitForEventLoop.js +5 -5
  28. package/package.json +62 -62
  29. package/src/audio.ts +129 -129
  30. package/src/beats.ts +279 -279
  31. package/src/bpmEncoder.ts +97 -97
  32. package/src/config.ts +504 -501
  33. package/src/dsp.ts +95 -95
  34. package/src/highlight.ts +617 -617
  35. package/src/image.ts +791 -707
  36. package/src/index.ts +896 -885
  37. package/src/subtitleConvert.ts +63 -63
  38. package/src/tests/audio.test.ts +102 -102
  39. package/src/tests/beats.test.ts +134 -134
  40. package/src/tests/config.test.ts +568 -553
  41. package/src/tests/dsp.test.ts +24 -24
  42. package/src/tests/highlight.test.ts +397 -397
  43. package/src/tests/image.test.ts +371 -319
  44. package/src/tests/index.test.ts +180 -180
  45. package/src/tests/subtitleConvert.test.ts +23 -23
  46. package/src/tests/video.test.ts +652 -652
  47. package/src/typings.d.ts +28 -28
  48. package/src/video.ts +947 -947
  49. package/src/waitForEventLoop.ts +2 -2
package/src/highlight.ts CHANGED
@@ -1,617 +1,617 @@
1
- /** Output length per segment when `audio.autoHighlight` is true (seconds). */
2
-
3
- import { waitForEventLoop } from "./waitForEventLoop";
4
-
5
- export const HIGHLIGHT_DURATION_SEC = 15;
6
-
7
- export interface BeatFrameEvent {
8
- frameIndex: number;
9
- intensity: number;
10
- }
11
-
12
- /**
13
- * How far (seconds) a highlight boundary may move to land on a beat. The final length can
14
- * therefore differ from `HIGHLIGHT_DURATION_SEC` by up to twice this value.
15
- */
16
- export const MAX_BEAT_SNAP_SEC = 1.5;
17
- /** Beats per bar used when preferring highlight lengths made of whole bars. */
18
- const BEATS_PER_BAR = 4;
19
- /** Cost (in beats of boundary shift) for each beat the length is away from a whole bar. */
20
- const OFF_BAR_PENALTY = 3;
21
- /** Grid beats past the analyzed tempo region snap to a detected onset within this share of a beat. */
22
- const GRID_ONSET_SNAP_SHARE = 0.2;
23
- /** Seconds of audio compared before and after a frame to score it as a drop. */
24
- export const DROP_WINDOW_SEC = 4;
25
- /** Share of the track's biggest energy rise a peak needs to count as a drop. */
26
- const MIN_DROP_SCORE_SHARE = 0.5;
27
- /** With a tempo grid, a drop moves onto a beat within this share of a beat, so the hook ends on the beat. */
28
- const DROP_BEAT_SNAP_SHARE = 0.2;
29
-
30
- /** Tempo grid used to snap highlight cuts to beats (subset of `TempoEstimate`). */
31
- export interface HighlightBeatGrid {
32
- periodFrames: number;
33
- phaseFrame: number;
34
- /** Beat times in seconds from the start of the audio. */
35
- beatsSec?: number[];
36
- }
37
-
38
- export interface HighlightAudioSegment {
39
- seekSeconds: number;
40
- durationSeconds: number;
41
- /** Silence played before the audio, for a lead-in reaching back past the track start. */
42
- delaySeconds?: number;
43
- }
44
-
45
- /** One auto-highlight segment before concatenation (for separate output files). */
46
- export interface HighlightRun {
47
- startFrame: number;
48
- highlightFrames: number;
49
- spectrums: number[][];
50
- beatFrameIndices: number[];
51
- beatIntensities: number[];
52
- audioSegment: HighlightAudioSegment;
53
- /** Frames from the run start to the detected drop (or window start); set when a lead-in was requested. */
54
- leadInFrames?: number;
55
- }
56
-
57
- /**
58
- * `seg.startFrame` may be negative (a lead-in reaching back past the track start): those
59
- * frames get silent spectrums and are played as silence before the audio.
60
- */
61
- function buildHighlightRun(
62
- fps: number,
63
- seg: { startFrame: number; highlightFrames: number; leadInFrames?: number },
64
- spectrums: number[][],
65
- beatEvents: BeatFrameEvent[],
66
- ): HighlightRun {
67
- const { startFrame, highlightFrames, leadInFrames } = seg;
68
- const silentFrames = Math.max(0, -startFrame);
69
- const silentSpectrum = new Array<number>(spectrums[0]?.length ?? 0).fill(0);
70
- const spectrumsSlice = [
71
- ...Array.from({ length: silentFrames }, () => silentSpectrum.slice()),
72
- ...spectrums.slice(startFrame + silentFrames, startFrame + highlightFrames),
73
- ];
74
- const beatsInWindow = beatEvents
75
- .filter(b => b.frameIndex >= startFrame && b.frameIndex < startFrame + highlightFrames)
76
- .sort((a, b) => a.frameIndex - b.frameIndex);
77
- const beatFrameIndices = beatsInWindow.map(b => b.frameIndex - startFrame);
78
- const beatIntensities = beatsInWindow.map(b => b.intensity);
79
- return {
80
- startFrame,
81
- highlightFrames,
82
- spectrums: spectrumsSlice,
83
- beatFrameIndices,
84
- beatIntensities,
85
- audioSegment: {
86
- seekSeconds: (startFrame + silentFrames) / fps,
87
- durationSeconds: (highlightFrames - silentFrames) / fps,
88
- ...(silentFrames > 0 && { delaySeconds: silentFrames / fps }),
89
- },
90
- ...(leadInFrames !== undefined && { leadInFrames }),
91
- };
92
- }
93
-
94
- function frameEnergy(spectrum: number[]): number {
95
- let s = 0;
96
- for (let i = 0; i < spectrum.length; i++) {
97
- const v = spectrum[i];
98
- s += v * v;
99
- }
100
- return s;
101
- }
102
-
103
- function rangesOverlap(
104
- aStart: number,
105
- aEndExclusive: number,
106
- ranges: Array<{ start: number; endExclusive: number }>,
107
- ): boolean {
108
- for (const r of ranges) {
109
- if (aStart < r.endExclusive && aEndExclusive > r.start) {
110
- return true;
111
- }
112
- }
113
- return false;
114
- }
115
-
116
- async function buildFrameEnergies(
117
- spectrums: number[][],
118
- totalFrames: number,
119
- ): Promise<Float64Array> {
120
- const energies = new Float64Array(totalFrames);
121
- for (let i = 0; i < totalFrames; i++) {
122
- energies[i] = frameEnergy(spectrums[i] ?? []);
123
- await waitForEventLoop();
124
- }
125
- return energies;
126
- }
127
-
128
- /**
129
- * Chooses the fixed-length window whose frames have the highest summed spectral energy
130
- * (typical chorus / drop vs a single loud intro transient).
131
- * Skips windows that overlap any excluded range.
132
- */
133
- async function findBestHighlightStart(
134
- energies: Float64Array,
135
- totalFrames: number,
136
- windowFrames: number,
137
- excludeRanges: Array<{ start: number; endExclusive: number }>,
138
- ): Promise<number | null> {
139
- if (totalFrames <= windowFrames) {
140
- if (!rangesOverlap(0, totalFrames, excludeRanges)) {
141
- return 0;
142
- }
143
- return null;
144
- }
145
-
146
- let minE = Infinity;
147
- let maxE = -Infinity;
148
- for (let i = 0; i < totalFrames; i++) {
149
- const e = energies[i];
150
- if (e < minE) {
151
- minE = e;
152
- }
153
- if (e > maxE) {
154
- maxE = e;
155
- }
156
- await waitForEventLoop();
157
- }
158
-
159
- const prefix = new Float64Array(totalFrames + 1);
160
- for (let i = 0; i < totalFrames; i++) {
161
- prefix[i + 1] = prefix[i] + energies[i];
162
- await waitForEventLoop();
163
- }
164
-
165
- const maxStart = totalFrames - windowFrames;
166
- let bestStart: number | null = null;
167
- let bestScore = -Infinity;
168
-
169
- for (let start = 0; start <= maxStart; start++) {
170
- if (rangesOverlap(start, start + windowFrames, excludeRanges)) {
171
- await waitForEventLoop();
172
- continue;
173
- }
174
- const score = prefix[start + windowFrames] - prefix[start];
175
- if (score > bestScore) {
176
- bestScore = score;
177
- bestStart = start;
178
- }
179
- await waitForEventLoop();
180
- }
181
-
182
- if (bestStart === null) {
183
- return null;
184
- }
185
-
186
- if (maxE <= minE) {
187
- const center = Math.floor((totalFrames - windowFrames) / 2);
188
- let bestDist = Infinity;
189
- let tieBreak: number | null = null;
190
- for (let start = 0; start <= maxStart; start++) {
191
- if (rangesOverlap(start, start + windowFrames, excludeRanges)) {
192
- await waitForEventLoop();
193
- continue;
194
- }
195
- const dist = Math.abs(start - center);
196
- if (dist < bestDist) {
197
- bestDist = dist;
198
- tieBreak = start;
199
- }
200
- await waitForEventLoop();
201
- }
202
- return tieBreak;
203
- }
204
-
205
- return bestStart;
206
- }
207
-
208
- /**
209
- * Scores each frame by the rise in mean energy from the `dropWindowFrames` before it to the
210
- * `dropWindowFrames` after it, and picks the best drop, i.e. a frame whose rise is the biggest
211
- * within `dropWindowFrames` on either side, such that the lead-in before it plus `mainFrames`
212
- * after it fit in the track and miss the excluded ranges. Frames rather than beat candidates are
213
- * scored so the drop lands on the actual energy onset (a beat grid can be off by a fraction of a
214
- * beat), and only peaks count so a frame just past a drop is never taken for one. Peaks rising
215
- * less than `MIN_DROP_SCORE_SHARE` of the biggest one are not drops. The window
216
- * starts exactly `leadInFrames` before the drop, so the whole lead-in plays. Drops too close to
217
- * the track start for the full lead-in are only used (with the window starting before the track,
218
- * i.e. at a negative frame) when no other drop fits.
219
- */
220
- function findBestDropSegment(
221
- energies: Float64Array,
222
- totalFrames: number,
223
- mainFrames: number,
224
- leadInFrames: number,
225
- dropWindowFrames: number,
226
- excludeRanges: Array<{ start: number; endExclusive: number }>,
227
- ): { startFrame: number; dropFrame: number } | null {
228
- const prefix = new Float64Array(totalFrames + 1);
229
- for (let i = 0; i < totalFrames; i++) {
230
- prefix[i + 1] = prefix[i] + energies[i];
231
- }
232
- const meanEnergy = (from: number, to: number) => {
233
- const a = Math.max(0, from);
234
- const b = Math.min(totalFrames, to);
235
- return b > a ? (prefix[b] - prefix[a]) / (b - a) : 0;
236
- };
237
- const scores = new Float64Array(totalFrames);
238
- for (let f = 0; f < totalFrames; f++) {
239
- scores[f] = meanEnergy(f, f + dropWindowFrames) - meanEnergy(f - dropWindowFrames, f);
240
- }
241
- const dropFrames: number[] = [];
242
- for (let f = 1; f < totalFrames; f++) {
243
- if (!(scores[f] > 0)) {
244
- continue;
245
- }
246
- let isPeak = true;
247
- const from = Math.max(0, f - dropWindowFrames);
248
- const to = Math.min(totalFrames - 1, f + dropWindowFrames);
249
- for (let g = from; g <= to && isPeak; g++) {
250
- // Ties go to the earliest frame, where the rise starts.
251
- isPeak = scores[g] < scores[f] || (scores[g] === scores[f] && g >= f);
252
- }
253
- if (isPeak) {
254
- dropFrames.push(f);
255
- }
256
- }
257
- const maxScore = dropFrames.reduce((max, f) => Math.max(max, scores[f]), 0);
258
- const strongDropFrames = dropFrames.filter(f => scores[f] >= maxScore * MIN_DROP_SCORE_SHARE);
259
-
260
- const findBest = (allowBeforeTrackStart: boolean) => {
261
- let best: { startFrame: number; dropFrame: number } | null = null;
262
- let bestScore = -Infinity;
263
- for (const dropFrame of strongDropFrames) {
264
- const startFrame = dropFrame - leadInFrames;
265
- if (startFrame < 0 && !allowBeforeTrackStart) {
266
- continue;
267
- }
268
- if (
269
- dropFrame + mainFrames > totalFrames ||
270
- rangesOverlap(startFrame, dropFrame + mainFrames, excludeRanges)
271
- ) {
272
- continue;
273
- }
274
- if (scores[dropFrame] > bestScore) {
275
- bestScore = scores[dropFrame];
276
- best = { startFrame, dropFrame };
277
- }
278
- }
279
- return best;
280
- };
281
- return findBest(false) ?? findBest(true);
282
- }
283
-
284
- /**
285
- * Moves a drop (and its window start with it) onto the nearest beat within `DROP_BEAT_SNAP_SHARE`
286
- * of a beat. Only done with a tempo grid: raw onsets alone are too unreliable to override the
287
- * energy onset. A lead-in that fit in the track is never pushed back past its start.
288
- */
289
- export function snapDropToBeat(
290
- drop: { startFrame: number; dropFrame: number },
291
- beatFrames: number[],
292
- periodFrames?: number,
293
- ): void {
294
- if (!periodFrames || !(periodFrames > 0) || !isFinite(periodFrames)) {
295
- return;
296
- }
297
- const beat = nearestOnset(beatFrames, drop.dropFrame);
298
- if (beat === null || Math.abs(beat - drop.dropFrame) > periodFrames * DROP_BEAT_SNAP_SHARE) {
299
- return;
300
- }
301
- const startFrame = drop.startFrame + (beat - drop.dropFrame);
302
- if (startFrame < 0 && drop.startFrame >= 0) {
303
- return;
304
- }
305
- drop.startFrame = startFrame;
306
- drop.dropFrame = beat;
307
- }
308
-
309
- function nearestOnset(sortedOnsets: number[], frame: number): number | null {
310
- let lo = 0;
311
- let hi = sortedOnsets.length;
312
- while (lo < hi) {
313
- const mid = (lo + hi) >> 1;
314
- if (sortedOnsets[mid] < frame) {
315
- lo = mid + 1;
316
- } else {
317
- hi = mid;
318
- }
319
- }
320
- let best: number | null = null;
321
- for (const i of [lo - 1, lo]) {
322
- if (i >= 0 && i < sortedOnsets.length) {
323
- const v = sortedOnsets[i];
324
- if (best === null || Math.abs(v - frame) < Math.abs(best - frame)) {
325
- best = v;
326
- }
327
- }
328
- }
329
- return best;
330
- }
331
-
332
- /**
333
- * Beat frames (sorted, unique, within [0, totalFrames]) where a highlight may start or end.
334
- * With a tempo grid: tracked beats from the analyzed region, then the regular grid beyond it
335
- * (each grid beat nudged onto a nearby detected onset to follow slight tempo drift).
336
- * Without one: the detected onset beats.
337
- */
338
- export function buildBeatCandidates(
339
- fps: number,
340
- totalFrames: number,
341
- beatEvents: BeatFrameEvent[],
342
- beatGrid?: HighlightBeatGrid | null,
343
- ): number[] {
344
- const onsets = Array.from(new Set(beatEvents.map(b => b.frameIndex)))
345
- .filter(f => f >= 0 && f <= totalFrames)
346
- .sort((a, b) => a - b);
347
- const period = beatGrid?.periodFrames ?? 0;
348
- if (!beatGrid || !(period > 0) || !isFinite(period)) {
349
- return onsets;
350
- }
351
-
352
- const frames = new Set<number>();
353
- let lastTracked = -Infinity;
354
- for (const t of beatGrid.beatsSec ?? []) {
355
- const f = Math.round(t * fps);
356
- if (f >= 0 && f <= totalFrames) {
357
- frames.add(f);
358
- lastTracked = Math.max(lastTracked, f);
359
- }
360
- }
361
-
362
- const maxOnsetShift = period * GRID_ONSET_SNAP_SHARE;
363
- const firstN = Math.ceil(-beatGrid.phaseFrame / period);
364
- for (let n = firstN; ; n++) {
365
- const gridFrame = beatGrid.phaseFrame + n * period;
366
- if (gridFrame > totalFrames) {
367
- break;
368
- }
369
- if (gridFrame <= lastTracked + period / 2) {
370
- continue;
371
- }
372
- const onset = nearestOnset(onsets, gridFrame);
373
- const f = onset !== null && Math.abs(onset - gridFrame) <= maxOnsetShift
374
- ? onset
375
- : Math.round(gridFrame);
376
- if (f >= 0 && f <= totalFrames) {
377
- frames.add(f);
378
- }
379
- }
380
- return Array.from(frames).sort((a, b) => a - b);
381
- }
382
-
383
- /**
384
- * Moves a fixed-length window's start and end onto beats (each by at most `maxShiftFrames`),
385
- * preferring lengths that span whole bars when the beat period is known.
386
- * Boundaries without a nearby beat stay where they are.
387
- */
388
- export function snapSegmentToBeats(
389
- seg: { startFrame: number; highlightFrames: number },
390
- totalFrames: number,
391
- beatFrames: number[],
392
- maxShiftFrames: number,
393
- periodFrames?: number,
394
- /** Keep the start where it is and only move the end. */
395
- lockStart = false,
396
- ): { startFrame: number; highlightFrames: number } {
397
- const rawStart = seg.startFrame;
398
- const rawEnd = seg.startFrame + seg.highlightFrames;
399
- const near = (target: number, max: number) =>
400
- beatFrames.filter(f => Math.abs(f - target) <= maxShiftFrames && f >= 0 && f <= max);
401
- const startCands = lockStart ? [rawStart] : near(rawStart, totalFrames - 1);
402
- const endCands = near(rawEnd, totalFrames);
403
- if (startCands.length === 0) {
404
- startCands.push(rawStart);
405
- }
406
- if (endCands.length === 0) {
407
- endCands.push(Math.min(rawEnd, totalFrames));
408
- }
409
-
410
- const period = periodFrames && periodFrames > 0 && isFinite(periodFrames) ? periodFrames : 0;
411
- const shiftUnit = period > 0 ? period : Math.max(1, maxShiftFrames);
412
- let best: { startFrame: number; endFrame: number } | null = null;
413
- let bestCost = Infinity;
414
- for (const s of startCands) {
415
- for (const e of endCands) {
416
- if (e - s < seg.highlightFrames / 2) {
417
- continue;
418
- }
419
- let cost = (Math.abs(s - rawStart) + Math.abs(e - rawEnd)) / shiftUnit;
420
- if (period > 0) {
421
- const beats = Math.round((e - s) / period);
422
- const offBar = Math.abs(beats - BEATS_PER_BAR * Math.round(beats / BEATS_PER_BAR));
423
- cost += offBar * OFF_BAR_PENALTY;
424
- }
425
- if (cost < bestCost) {
426
- bestCost = cost;
427
- best = { startFrame: s, endFrame: e };
428
- }
429
- }
430
- }
431
- if (!best) {
432
- return seg;
433
- }
434
- return { startFrame: best.startFrame, highlightFrames: best.endFrame - best.startFrame };
435
- }
436
-
437
- /**
438
- * Picks up to `segmentCount` non-overlapping ~15s windows with highest summed spectral energy each,
439
- * moves their boundaries onto beats (see `snapSegmentToBeats`), sorts them chronologically,
440
- * concatenates spectrums, and remaps beat indices.
441
- *
442
- * With `leadInFrames` (e.g. a hook video's length), each window is instead built around a detected
443
- * drop: it starts `leadInFrames` before the drop, so the whole lead-in plays and ends on the drop
444
- * (see `HighlightRun.leadInFrames`), and runs ~15s past the drop, so the lead-in is added on top of
445
- * the ~15s rather than taken out of it. With a tempo grid, the drop is moved onto a nearby beat
446
- * (see `snapDropToBeat`), so the hook ends on the beat. When no drop fits, the lead-in is put before the energy
447
- * window instead. The lead-in is never shortened: where it reaches back past the track start,
448
- * the run starts at a negative frame and that part is silent (see `buildHighlightRun`).
449
- */
450
- export async function computeHighlightSlice(
451
- fps: number,
452
- totalFrames: number,
453
- spectrums: number[][],
454
- beatEvents: BeatFrameEvent[],
455
- segmentCount = 1,
456
- beatGrid?: HighlightBeatGrid | null,
457
- leadInFrames?: number,
458
- ): Promise<{
459
- startFrame: number;
460
- highlightFrames: number;
461
- spectrums: number[][];
462
- beatFrameIndices: number[];
463
- beatIntensities: number[];
464
- audioSeekSeconds: number;
465
- audioDurationSeconds: number;
466
- audioSegments: HighlightAudioSegment[];
467
- runs: HighlightRun[];
468
- }> {
469
- const highlightFramesTarget = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
470
-
471
- if (totalFrames <= 0) {
472
- return {
473
- startFrame: 0,
474
- highlightFrames: 0,
475
- spectrums: [],
476
- beatFrameIndices: [],
477
- beatIntensities: [],
478
- audioSeekSeconds: 0,
479
- audioDurationSeconds: 0,
480
- audioSegments: [],
481
- runs: [],
482
- };
483
- }
484
-
485
- if (totalFrames <= highlightFramesTarget) {
486
- // The whole track follows the lead-in, which is silent as it all comes before the track start.
487
- const leadIn = leadInFrames !== undefined && leadInFrames > 0 ? leadInFrames : 0;
488
- const fullSeg = leadIn > 0
489
- ? { startFrame: -leadIn, highlightFrames: leadIn + totalFrames, leadInFrames: leadIn }
490
- : { startFrame: 0, highlightFrames: totalFrames };
491
- const fullRun = buildHighlightRun(fps, fullSeg, spectrums, beatEvents);
492
- return {
493
- startFrame: fullRun.startFrame,
494
- highlightFrames: fullRun.highlightFrames,
495
- spectrums: fullRun.spectrums,
496
- beatFrameIndices: fullRun.beatFrameIndices,
497
- beatIntensities: fullRun.beatIntensities,
498
- audioSeekSeconds: fullRun.audioSegment.seekSeconds,
499
- audioDurationSeconds: fullRun.audioSegment.durationSeconds,
500
- audioSegments: [fullRun.audioSegment],
501
- runs: [fullRun],
502
- };
503
- }
504
-
505
- const highlightFrames = highlightFramesTarget;
506
- const energies = await buildFrameEnergies(spectrums, totalFrames);
507
- const excludeRanges: Array<{ start: number; endExclusive: number }> = [];
508
- const rawSegments: Array<{ startFrame: number; highlightFrames: number; leadInFrames?: number }> = [];
509
- const beatFrames = buildBeatCandidates(fps, totalFrames, beatEvents, beatGrid);
510
- const maxShiftFrames = Math.round(MAX_BEAT_SNAP_SEC * fps);
511
- const useDrops = leadInFrames !== undefined && leadInFrames > 0;
512
- const dropWindowFrames = Math.round(DROP_WINDOW_SEC * fps);
513
-
514
- const n = Math.max(1, Math.floor(segmentCount));
515
- for (let k = 0; k < n; k++) {
516
- const drop = useDrops
517
- ? findBestDropSegment(
518
- energies,
519
- totalFrames,
520
- highlightFrames,
521
- leadInFrames as number,
522
- dropWindowFrames,
523
- excludeRanges,
524
- )
525
- : null;
526
- if (drop) {
527
- snapDropToBeat(drop, beatFrames, beatGrid?.periodFrames);
528
- // Only the part from the drop on is ~15s; its end is snapped to a beat.
529
- const main = snapSegmentToBeats(
530
- { startFrame: drop.dropFrame, highlightFrames },
531
- totalFrames,
532
- beatFrames,
533
- maxShiftFrames,
534
- beatGrid?.periodFrames,
535
- true,
536
- );
537
- const dropLeadIn = drop.dropFrame - drop.startFrame;
538
- rawSegments.push({
539
- startFrame: drop.startFrame,
540
- highlightFrames: dropLeadIn + main.highlightFrames,
541
- leadInFrames: dropLeadIn,
542
- });
543
- excludeRanges.push({
544
- start: drop.startFrame - maxShiftFrames,
545
- endExclusive: main.startFrame + main.highlightFrames + maxShiftFrames,
546
- });
547
- await waitForEventLoop();
548
- continue;
549
- }
550
- const startFrame = await findBestHighlightStart(
551
- energies,
552
- totalFrames,
553
- highlightFrames,
554
- excludeRanges,
555
- );
556
- if (startFrame === null) {
557
- break;
558
- }
559
- const snapped = snapSegmentToBeats(
560
- { startFrame, highlightFrames },
561
- totalFrames,
562
- beatFrames,
563
- maxShiftFrames,
564
- beatGrid?.periodFrames,
565
- );
566
- // The whole lead-in plays before the window, even over an earlier window (each highlight with
567
- // a lead-in is its own output) or before the track start (as silence).
568
- const windowLeadIn = useDrops ? leadInFrames as number : 0;
569
- rawSegments.push(windowLeadIn > 0
570
- ? {
571
- startFrame: snapped.startFrame - windowLeadIn,
572
- highlightFrames: windowLeadIn + snapped.highlightFrames,
573
- leadInFrames: windowLeadIn,
574
- }
575
- : snapped);
576
- // Padded so the next raw window, once snapped, cannot reach back into this one.
577
- excludeRanges.push({
578
- start: snapped.startFrame - maxShiftFrames,
579
- endExclusive: snapped.startFrame + snapped.highlightFrames + maxShiftFrames,
580
- });
581
- }
582
-
583
- rawSegments.sort((a, b) => a.startFrame - b.startFrame);
584
-
585
- const runs = rawSegments.map(seg =>
586
- buildHighlightRun(fps, seg, spectrums, beatEvents),
587
- );
588
-
589
- const slicedSpectrums: number[][] = [];
590
- const beatFrameIndices: number[] = [];
591
- const beatIntensities: number[] = [];
592
- let outOffset = 0;
593
-
594
- for (const run of runs) {
595
- slicedSpectrums.push(...run.spectrums);
596
- beatFrameIndices.push(...run.beatFrameIndices.map(frameIndex => frameIndex + outOffset));
597
- beatIntensities.push(...run.beatIntensities);
598
- outOffset += run.highlightFrames;
599
- }
600
-
601
- const totalHighlightFrames = slicedSpectrums.length;
602
- const audioSegments: HighlightAudioSegment[] = runs.map(run => run.audioSegment);
603
-
604
- const first = audioSegments[0] ?? { seekSeconds: 0, durationSeconds: 0 };
605
-
606
- return {
607
- startFrame: rawSegments[0]?.startFrame ?? 0,
608
- highlightFrames: totalHighlightFrames,
609
- spectrums: slicedSpectrums,
610
- beatFrameIndices,
611
- beatIntensities,
612
- audioSeekSeconds: first.seekSeconds,
613
- audioDurationSeconds: first.durationSeconds,
614
- audioSegments,
615
- runs,
616
- };
617
- }
1
+ /** Output length per segment when `audio.autoHighlight` is true (seconds). */
2
+
3
+ import { waitForEventLoop } from "./waitForEventLoop";
4
+
5
+ export const HIGHLIGHT_DURATION_SEC = 15;
6
+
7
+ export interface BeatFrameEvent {
8
+ frameIndex: number;
9
+ intensity: number;
10
+ }
11
+
12
+ /**
13
+ * How far (seconds) a highlight boundary may move to land on a beat. The final length can
14
+ * therefore differ from `HIGHLIGHT_DURATION_SEC` by up to twice this value.
15
+ */
16
+ export const MAX_BEAT_SNAP_SEC = 1.5;
17
+ /** Beats per bar used when preferring highlight lengths made of whole bars. */
18
+ const BEATS_PER_BAR = 4;
19
+ /** Cost (in beats of boundary shift) for each beat the length is away from a whole bar. */
20
+ const OFF_BAR_PENALTY = 3;
21
+ /** Grid beats past the analyzed tempo region snap to a detected onset within this share of a beat. */
22
+ const GRID_ONSET_SNAP_SHARE = 0.2;
23
+ /** Seconds of audio compared before and after a frame to score it as a drop. */
24
+ export const DROP_WINDOW_SEC = 4;
25
+ /** Share of the track's biggest energy rise a peak needs to count as a drop. */
26
+ const MIN_DROP_SCORE_SHARE = 0.5;
27
+ /** With a tempo grid, a drop moves onto a beat within this share of a beat, so the hook ends on the beat. */
28
+ const DROP_BEAT_SNAP_SHARE = 0.2;
29
+
30
+ /** Tempo grid used to snap highlight cuts to beats (subset of `TempoEstimate`). */
31
+ export interface HighlightBeatGrid {
32
+ periodFrames: number;
33
+ phaseFrame: number;
34
+ /** Beat times in seconds from the start of the audio. */
35
+ beatsSec?: number[];
36
+ }
37
+
38
+ export interface HighlightAudioSegment {
39
+ seekSeconds: number;
40
+ durationSeconds: number;
41
+ /** Silence played before the audio, for a lead-in reaching back past the track start. */
42
+ delaySeconds?: number;
43
+ }
44
+
45
+ /** One auto-highlight segment before concatenation (for separate output files). */
46
+ export interface HighlightRun {
47
+ startFrame: number;
48
+ highlightFrames: number;
49
+ spectrums: number[][];
50
+ beatFrameIndices: number[];
51
+ beatIntensities: number[];
52
+ audioSegment: HighlightAudioSegment;
53
+ /** Frames from the run start to the detected drop (or window start); set when a lead-in was requested. */
54
+ leadInFrames?: number;
55
+ }
56
+
57
+ /**
58
+ * `seg.startFrame` may be negative (a lead-in reaching back past the track start): those
59
+ * frames get silent spectrums and are played as silence before the audio.
60
+ */
61
+ function buildHighlightRun(
62
+ fps: number,
63
+ seg: { startFrame: number; highlightFrames: number; leadInFrames?: number },
64
+ spectrums: number[][],
65
+ beatEvents: BeatFrameEvent[],
66
+ ): HighlightRun {
67
+ const { startFrame, highlightFrames, leadInFrames } = seg;
68
+ const silentFrames = Math.max(0, -startFrame);
69
+ const silentSpectrum = new Array<number>(spectrums[0]?.length ?? 0).fill(0);
70
+ const spectrumsSlice = [
71
+ ...Array.from({ length: silentFrames }, () => silentSpectrum.slice()),
72
+ ...spectrums.slice(startFrame + silentFrames, startFrame + highlightFrames),
73
+ ];
74
+ const beatsInWindow = beatEvents
75
+ .filter(b => b.frameIndex >= startFrame && b.frameIndex < startFrame + highlightFrames)
76
+ .sort((a, b) => a.frameIndex - b.frameIndex);
77
+ const beatFrameIndices = beatsInWindow.map(b => b.frameIndex - startFrame);
78
+ const beatIntensities = beatsInWindow.map(b => b.intensity);
79
+ return {
80
+ startFrame,
81
+ highlightFrames,
82
+ spectrums: spectrumsSlice,
83
+ beatFrameIndices,
84
+ beatIntensities,
85
+ audioSegment: {
86
+ seekSeconds: (startFrame + silentFrames) / fps,
87
+ durationSeconds: (highlightFrames - silentFrames) / fps,
88
+ ...(silentFrames > 0 && { delaySeconds: silentFrames / fps }),
89
+ },
90
+ ...(leadInFrames !== undefined && { leadInFrames }),
91
+ };
92
+ }
93
+
94
+ function frameEnergy(spectrum: number[]): number {
95
+ let s = 0;
96
+ for (let i = 0; i < spectrum.length; i++) {
97
+ const v = spectrum[i];
98
+ s += v * v;
99
+ }
100
+ return s;
101
+ }
102
+
103
+ function rangesOverlap(
104
+ aStart: number,
105
+ aEndExclusive: number,
106
+ ranges: Array<{ start: number; endExclusive: number }>,
107
+ ): boolean {
108
+ for (const r of ranges) {
109
+ if (aStart < r.endExclusive && aEndExclusive > r.start) {
110
+ return true;
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+
116
+ async function buildFrameEnergies(
117
+ spectrums: number[][],
118
+ totalFrames: number,
119
+ ): Promise<Float64Array> {
120
+ const energies = new Float64Array(totalFrames);
121
+ for (let i = 0; i < totalFrames; i++) {
122
+ energies[i] = frameEnergy(spectrums[i] ?? []);
123
+ await waitForEventLoop();
124
+ }
125
+ return energies;
126
+ }
127
+
128
+ /**
129
+ * Chooses the fixed-length window whose frames have the highest summed spectral energy
130
+ * (typical chorus / drop vs a single loud intro transient).
131
+ * Skips windows that overlap any excluded range.
132
+ */
133
+ async function findBestHighlightStart(
134
+ energies: Float64Array,
135
+ totalFrames: number,
136
+ windowFrames: number,
137
+ excludeRanges: Array<{ start: number; endExclusive: number }>,
138
+ ): Promise<number | null> {
139
+ if (totalFrames <= windowFrames) {
140
+ if (!rangesOverlap(0, totalFrames, excludeRanges)) {
141
+ return 0;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ let minE = Infinity;
147
+ let maxE = -Infinity;
148
+ for (let i = 0; i < totalFrames; i++) {
149
+ const e = energies[i];
150
+ if (e < minE) {
151
+ minE = e;
152
+ }
153
+ if (e > maxE) {
154
+ maxE = e;
155
+ }
156
+ await waitForEventLoop();
157
+ }
158
+
159
+ const prefix = new Float64Array(totalFrames + 1);
160
+ for (let i = 0; i < totalFrames; i++) {
161
+ prefix[i + 1] = prefix[i] + energies[i];
162
+ await waitForEventLoop();
163
+ }
164
+
165
+ const maxStart = totalFrames - windowFrames;
166
+ let bestStart: number | null = null;
167
+ let bestScore = -Infinity;
168
+
169
+ for (let start = 0; start <= maxStart; start++) {
170
+ if (rangesOverlap(start, start + windowFrames, excludeRanges)) {
171
+ await waitForEventLoop();
172
+ continue;
173
+ }
174
+ const score = prefix[start + windowFrames] - prefix[start];
175
+ if (score > bestScore) {
176
+ bestScore = score;
177
+ bestStart = start;
178
+ }
179
+ await waitForEventLoop();
180
+ }
181
+
182
+ if (bestStart === null) {
183
+ return null;
184
+ }
185
+
186
+ if (maxE <= minE) {
187
+ const center = Math.floor((totalFrames - windowFrames) / 2);
188
+ let bestDist = Infinity;
189
+ let tieBreak: number | null = null;
190
+ for (let start = 0; start <= maxStart; start++) {
191
+ if (rangesOverlap(start, start + windowFrames, excludeRanges)) {
192
+ await waitForEventLoop();
193
+ continue;
194
+ }
195
+ const dist = Math.abs(start - center);
196
+ if (dist < bestDist) {
197
+ bestDist = dist;
198
+ tieBreak = start;
199
+ }
200
+ await waitForEventLoop();
201
+ }
202
+ return tieBreak;
203
+ }
204
+
205
+ return bestStart;
206
+ }
207
+
208
+ /**
209
+ * Scores each frame by the rise in mean energy from the `dropWindowFrames` before it to the
210
+ * `dropWindowFrames` after it, and picks the best drop, i.e. a frame whose rise is the biggest
211
+ * within `dropWindowFrames` on either side, such that the lead-in before it plus `mainFrames`
212
+ * after it fit in the track and miss the excluded ranges. Frames rather than beat candidates are
213
+ * scored so the drop lands on the actual energy onset (a beat grid can be off by a fraction of a
214
+ * beat), and only peaks count so a frame just past a drop is never taken for one. Peaks rising
215
+ * less than `MIN_DROP_SCORE_SHARE` of the biggest one are not drops. The window
216
+ * starts exactly `leadInFrames` before the drop, so the whole lead-in plays. Drops too close to
217
+ * the track start for the full lead-in are only used (with the window starting before the track,
218
+ * i.e. at a negative frame) when no other drop fits.
219
+ */
220
+ function findBestDropSegment(
221
+ energies: Float64Array,
222
+ totalFrames: number,
223
+ mainFrames: number,
224
+ leadInFrames: number,
225
+ dropWindowFrames: number,
226
+ excludeRanges: Array<{ start: number; endExclusive: number }>,
227
+ ): { startFrame: number; dropFrame: number } | null {
228
+ const prefix = new Float64Array(totalFrames + 1);
229
+ for (let i = 0; i < totalFrames; i++) {
230
+ prefix[i + 1] = prefix[i] + energies[i];
231
+ }
232
+ const meanEnergy = (from: number, to: number) => {
233
+ const a = Math.max(0, from);
234
+ const b = Math.min(totalFrames, to);
235
+ return b > a ? (prefix[b] - prefix[a]) / (b - a) : 0;
236
+ };
237
+ const scores = new Float64Array(totalFrames);
238
+ for (let f = 0; f < totalFrames; f++) {
239
+ scores[f] = meanEnergy(f, f + dropWindowFrames) - meanEnergy(f - dropWindowFrames, f);
240
+ }
241
+ const dropFrames: number[] = [];
242
+ for (let f = 1; f < totalFrames; f++) {
243
+ if (!(scores[f] > 0)) {
244
+ continue;
245
+ }
246
+ let isPeak = true;
247
+ const from = Math.max(0, f - dropWindowFrames);
248
+ const to = Math.min(totalFrames - 1, f + dropWindowFrames);
249
+ for (let g = from; g <= to && isPeak; g++) {
250
+ // Ties go to the earliest frame, where the rise starts.
251
+ isPeak = scores[g] < scores[f] || (scores[g] === scores[f] && g >= f);
252
+ }
253
+ if (isPeak) {
254
+ dropFrames.push(f);
255
+ }
256
+ }
257
+ const maxScore = dropFrames.reduce((max, f) => Math.max(max, scores[f]), 0);
258
+ const strongDropFrames = dropFrames.filter(f => scores[f] >= maxScore * MIN_DROP_SCORE_SHARE);
259
+
260
+ const findBest = (allowBeforeTrackStart: boolean) => {
261
+ let best: { startFrame: number; dropFrame: number } | null = null;
262
+ let bestScore = -Infinity;
263
+ for (const dropFrame of strongDropFrames) {
264
+ const startFrame = dropFrame - leadInFrames;
265
+ if (startFrame < 0 && !allowBeforeTrackStart) {
266
+ continue;
267
+ }
268
+ if (
269
+ dropFrame + mainFrames > totalFrames ||
270
+ rangesOverlap(startFrame, dropFrame + mainFrames, excludeRanges)
271
+ ) {
272
+ continue;
273
+ }
274
+ if (scores[dropFrame] > bestScore) {
275
+ bestScore = scores[dropFrame];
276
+ best = { startFrame, dropFrame };
277
+ }
278
+ }
279
+ return best;
280
+ };
281
+ return findBest(false) ?? findBest(true);
282
+ }
283
+
284
+ /**
285
+ * Moves a drop (and its window start with it) onto the nearest beat within `DROP_BEAT_SNAP_SHARE`
286
+ * of a beat. Only done with a tempo grid: raw onsets alone are too unreliable to override the
287
+ * energy onset. A lead-in that fit in the track is never pushed back past its start.
288
+ */
289
+ export function snapDropToBeat(
290
+ drop: { startFrame: number; dropFrame: number },
291
+ beatFrames: number[],
292
+ periodFrames?: number,
293
+ ): void {
294
+ if (!periodFrames || !(periodFrames > 0) || !isFinite(periodFrames)) {
295
+ return;
296
+ }
297
+ const beat = nearestOnset(beatFrames, drop.dropFrame);
298
+ if (beat === null || Math.abs(beat - drop.dropFrame) > periodFrames * DROP_BEAT_SNAP_SHARE) {
299
+ return;
300
+ }
301
+ const startFrame = drop.startFrame + (beat - drop.dropFrame);
302
+ if (startFrame < 0 && drop.startFrame >= 0) {
303
+ return;
304
+ }
305
+ drop.startFrame = startFrame;
306
+ drop.dropFrame = beat;
307
+ }
308
+
309
+ function nearestOnset(sortedOnsets: number[], frame: number): number | null {
310
+ let lo = 0;
311
+ let hi = sortedOnsets.length;
312
+ while (lo < hi) {
313
+ const mid = (lo + hi) >> 1;
314
+ if (sortedOnsets[mid] < frame) {
315
+ lo = mid + 1;
316
+ } else {
317
+ hi = mid;
318
+ }
319
+ }
320
+ let best: number | null = null;
321
+ for (const i of [lo - 1, lo]) {
322
+ if (i >= 0 && i < sortedOnsets.length) {
323
+ const v = sortedOnsets[i];
324
+ if (best === null || Math.abs(v - frame) < Math.abs(best - frame)) {
325
+ best = v;
326
+ }
327
+ }
328
+ }
329
+ return best;
330
+ }
331
+
332
+ /**
333
+ * Beat frames (sorted, unique, within [0, totalFrames]) where a highlight may start or end.
334
+ * With a tempo grid: tracked beats from the analyzed region, then the regular grid beyond it
335
+ * (each grid beat nudged onto a nearby detected onset to follow slight tempo drift).
336
+ * Without one: the detected onset beats.
337
+ */
338
+ export function buildBeatCandidates(
339
+ fps: number,
340
+ totalFrames: number,
341
+ beatEvents: BeatFrameEvent[],
342
+ beatGrid?: HighlightBeatGrid | null,
343
+ ): number[] {
344
+ const onsets = Array.from(new Set(beatEvents.map(b => b.frameIndex)))
345
+ .filter(f => f >= 0 && f <= totalFrames)
346
+ .sort((a, b) => a - b);
347
+ const period = beatGrid?.periodFrames ?? 0;
348
+ if (!beatGrid || !(period > 0) || !isFinite(period)) {
349
+ return onsets;
350
+ }
351
+
352
+ const frames = new Set<number>();
353
+ let lastTracked = -Infinity;
354
+ for (const t of beatGrid.beatsSec ?? []) {
355
+ const f = Math.round(t * fps);
356
+ if (f >= 0 && f <= totalFrames) {
357
+ frames.add(f);
358
+ lastTracked = Math.max(lastTracked, f);
359
+ }
360
+ }
361
+
362
+ const maxOnsetShift = period * GRID_ONSET_SNAP_SHARE;
363
+ const firstN = Math.ceil(-beatGrid.phaseFrame / period);
364
+ for (let n = firstN; ; n++) {
365
+ const gridFrame = beatGrid.phaseFrame + n * period;
366
+ if (gridFrame > totalFrames) {
367
+ break;
368
+ }
369
+ if (gridFrame <= lastTracked + period / 2) {
370
+ continue;
371
+ }
372
+ const onset = nearestOnset(onsets, gridFrame);
373
+ const f = onset !== null && Math.abs(onset - gridFrame) <= maxOnsetShift
374
+ ? onset
375
+ : Math.round(gridFrame);
376
+ if (f >= 0 && f <= totalFrames) {
377
+ frames.add(f);
378
+ }
379
+ }
380
+ return Array.from(frames).sort((a, b) => a - b);
381
+ }
382
+
383
+ /**
384
+ * Moves a fixed-length window's start and end onto beats (each by at most `maxShiftFrames`),
385
+ * preferring lengths that span whole bars when the beat period is known.
386
+ * Boundaries without a nearby beat stay where they are.
387
+ */
388
+ export function snapSegmentToBeats(
389
+ seg: { startFrame: number; highlightFrames: number },
390
+ totalFrames: number,
391
+ beatFrames: number[],
392
+ maxShiftFrames: number,
393
+ periodFrames?: number,
394
+ /** Keep the start where it is and only move the end. */
395
+ lockStart = false,
396
+ ): { startFrame: number; highlightFrames: number } {
397
+ const rawStart = seg.startFrame;
398
+ const rawEnd = seg.startFrame + seg.highlightFrames;
399
+ const near = (target: number, max: number) =>
400
+ beatFrames.filter(f => Math.abs(f - target) <= maxShiftFrames && f >= 0 && f <= max);
401
+ const startCands = lockStart ? [rawStart] : near(rawStart, totalFrames - 1);
402
+ const endCands = near(rawEnd, totalFrames);
403
+ if (startCands.length === 0) {
404
+ startCands.push(rawStart);
405
+ }
406
+ if (endCands.length === 0) {
407
+ endCands.push(Math.min(rawEnd, totalFrames));
408
+ }
409
+
410
+ const period = periodFrames && periodFrames > 0 && isFinite(periodFrames) ? periodFrames : 0;
411
+ const shiftUnit = period > 0 ? period : Math.max(1, maxShiftFrames);
412
+ let best: { startFrame: number; endFrame: number } | null = null;
413
+ let bestCost = Infinity;
414
+ for (const s of startCands) {
415
+ for (const e of endCands) {
416
+ if (e - s < seg.highlightFrames / 2) {
417
+ continue;
418
+ }
419
+ let cost = (Math.abs(s - rawStart) + Math.abs(e - rawEnd)) / shiftUnit;
420
+ if (period > 0) {
421
+ const beats = Math.round((e - s) / period);
422
+ const offBar = Math.abs(beats - BEATS_PER_BAR * Math.round(beats / BEATS_PER_BAR));
423
+ cost += offBar * OFF_BAR_PENALTY;
424
+ }
425
+ if (cost < bestCost) {
426
+ bestCost = cost;
427
+ best = { startFrame: s, endFrame: e };
428
+ }
429
+ }
430
+ }
431
+ if (!best) {
432
+ return seg;
433
+ }
434
+ return { startFrame: best.startFrame, highlightFrames: best.endFrame - best.startFrame };
435
+ }
436
+
437
+ /**
438
+ * Picks up to `segmentCount` non-overlapping ~15s windows with highest summed spectral energy each,
439
+ * moves their boundaries onto beats (see `snapSegmentToBeats`), sorts them chronologically,
440
+ * concatenates spectrums, and remaps beat indices.
441
+ *
442
+ * With `leadInFrames` (e.g. a hook video's length), each window is instead built around a detected
443
+ * drop: it starts `leadInFrames` before the drop, so the whole lead-in plays and ends on the drop
444
+ * (see `HighlightRun.leadInFrames`), and runs ~15s past the drop, so the lead-in is added on top of
445
+ * the ~15s rather than taken out of it. With a tempo grid, the drop is moved onto a nearby beat
446
+ * (see `snapDropToBeat`), so the hook ends on the beat. When no drop fits, the lead-in is put before the energy
447
+ * window instead. The lead-in is never shortened: where it reaches back past the track start,
448
+ * the run starts at a negative frame and that part is silent (see `buildHighlightRun`).
449
+ */
450
+ export async function computeHighlightSlice(
451
+ fps: number,
452
+ totalFrames: number,
453
+ spectrums: number[][],
454
+ beatEvents: BeatFrameEvent[],
455
+ segmentCount = 1,
456
+ beatGrid?: HighlightBeatGrid | null,
457
+ leadInFrames?: number,
458
+ ): Promise<{
459
+ startFrame: number;
460
+ highlightFrames: number;
461
+ spectrums: number[][];
462
+ beatFrameIndices: number[];
463
+ beatIntensities: number[];
464
+ audioSeekSeconds: number;
465
+ audioDurationSeconds: number;
466
+ audioSegments: HighlightAudioSegment[];
467
+ runs: HighlightRun[];
468
+ }> {
469
+ const highlightFramesTarget = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
470
+
471
+ if (totalFrames <= 0) {
472
+ return {
473
+ startFrame: 0,
474
+ highlightFrames: 0,
475
+ spectrums: [],
476
+ beatFrameIndices: [],
477
+ beatIntensities: [],
478
+ audioSeekSeconds: 0,
479
+ audioDurationSeconds: 0,
480
+ audioSegments: [],
481
+ runs: [],
482
+ };
483
+ }
484
+
485
+ if (totalFrames <= highlightFramesTarget) {
486
+ // The whole track follows the lead-in, which is silent as it all comes before the track start.
487
+ const leadIn = leadInFrames !== undefined && leadInFrames > 0 ? leadInFrames : 0;
488
+ const fullSeg = leadIn > 0
489
+ ? { startFrame: -leadIn, highlightFrames: leadIn + totalFrames, leadInFrames: leadIn }
490
+ : { startFrame: 0, highlightFrames: totalFrames };
491
+ const fullRun = buildHighlightRun(fps, fullSeg, spectrums, beatEvents);
492
+ return {
493
+ startFrame: fullRun.startFrame,
494
+ highlightFrames: fullRun.highlightFrames,
495
+ spectrums: fullRun.spectrums,
496
+ beatFrameIndices: fullRun.beatFrameIndices,
497
+ beatIntensities: fullRun.beatIntensities,
498
+ audioSeekSeconds: fullRun.audioSegment.seekSeconds,
499
+ audioDurationSeconds: fullRun.audioSegment.durationSeconds,
500
+ audioSegments: [fullRun.audioSegment],
501
+ runs: [fullRun],
502
+ };
503
+ }
504
+
505
+ const highlightFrames = highlightFramesTarget;
506
+ const energies = await buildFrameEnergies(spectrums, totalFrames);
507
+ const excludeRanges: Array<{ start: number; endExclusive: number }> = [];
508
+ const rawSegments: Array<{ startFrame: number; highlightFrames: number; leadInFrames?: number }> = [];
509
+ const beatFrames = buildBeatCandidates(fps, totalFrames, beatEvents, beatGrid);
510
+ const maxShiftFrames = Math.round(MAX_BEAT_SNAP_SEC * fps);
511
+ const useDrops = leadInFrames !== undefined && leadInFrames > 0;
512
+ const dropWindowFrames = Math.round(DROP_WINDOW_SEC * fps);
513
+
514
+ const n = Math.max(1, Math.floor(segmentCount));
515
+ for (let k = 0; k < n; k++) {
516
+ const drop = useDrops
517
+ ? findBestDropSegment(
518
+ energies,
519
+ totalFrames,
520
+ highlightFrames,
521
+ leadInFrames as number,
522
+ dropWindowFrames,
523
+ excludeRanges,
524
+ )
525
+ : null;
526
+ if (drop) {
527
+ snapDropToBeat(drop, beatFrames, beatGrid?.periodFrames);
528
+ // Only the part from the drop on is ~15s; its end is snapped to a beat.
529
+ const main = snapSegmentToBeats(
530
+ { startFrame: drop.dropFrame, highlightFrames },
531
+ totalFrames,
532
+ beatFrames,
533
+ maxShiftFrames,
534
+ beatGrid?.periodFrames,
535
+ true,
536
+ );
537
+ const dropLeadIn = drop.dropFrame - drop.startFrame;
538
+ rawSegments.push({
539
+ startFrame: drop.startFrame,
540
+ highlightFrames: dropLeadIn + main.highlightFrames,
541
+ leadInFrames: dropLeadIn,
542
+ });
543
+ excludeRanges.push({
544
+ start: drop.startFrame - maxShiftFrames,
545
+ endExclusive: main.startFrame + main.highlightFrames + maxShiftFrames,
546
+ });
547
+ await waitForEventLoop();
548
+ continue;
549
+ }
550
+ const startFrame = await findBestHighlightStart(
551
+ energies,
552
+ totalFrames,
553
+ highlightFrames,
554
+ excludeRanges,
555
+ );
556
+ if (startFrame === null) {
557
+ break;
558
+ }
559
+ const snapped = snapSegmentToBeats(
560
+ { startFrame, highlightFrames },
561
+ totalFrames,
562
+ beatFrames,
563
+ maxShiftFrames,
564
+ beatGrid?.periodFrames,
565
+ );
566
+ // The whole lead-in plays before the window, even over an earlier window (each highlight with
567
+ // a lead-in is its own output) or before the track start (as silence).
568
+ const windowLeadIn = useDrops ? leadInFrames as number : 0;
569
+ rawSegments.push(windowLeadIn > 0
570
+ ? {
571
+ startFrame: snapped.startFrame - windowLeadIn,
572
+ highlightFrames: windowLeadIn + snapped.highlightFrames,
573
+ leadInFrames: windowLeadIn,
574
+ }
575
+ : snapped);
576
+ // Padded so the next raw window, once snapped, cannot reach back into this one.
577
+ excludeRanges.push({
578
+ start: snapped.startFrame - maxShiftFrames,
579
+ endExclusive: snapped.startFrame + snapped.highlightFrames + maxShiftFrames,
580
+ });
581
+ }
582
+
583
+ rawSegments.sort((a, b) => a.startFrame - b.startFrame);
584
+
585
+ const runs = rawSegments.map(seg =>
586
+ buildHighlightRun(fps, seg, spectrums, beatEvents),
587
+ );
588
+
589
+ const slicedSpectrums: number[][] = [];
590
+ const beatFrameIndices: number[] = [];
591
+ const beatIntensities: number[] = [];
592
+ let outOffset = 0;
593
+
594
+ for (const run of runs) {
595
+ slicedSpectrums.push(...run.spectrums);
596
+ beatFrameIndices.push(...run.beatFrameIndices.map(frameIndex => frameIndex + outOffset));
597
+ beatIntensities.push(...run.beatIntensities);
598
+ outOffset += run.highlightFrames;
599
+ }
600
+
601
+ const totalHighlightFrames = slicedSpectrums.length;
602
+ const audioSegments: HighlightAudioSegment[] = runs.map(run => run.audioSegment);
603
+
604
+ const first = audioSegments[0] ?? { seekSeconds: 0, durationSeconds: 0 };
605
+
606
+ return {
607
+ startFrame: rawSegments[0]?.startFrame ?? 0,
608
+ highlightFrames: totalHighlightFrames,
609
+ spectrums: slicedSpectrums,
610
+ beatFrameIndices,
611
+ beatIntensities,
612
+ audioSeekSeconds: first.seekSeconds,
613
+ audioDurationSeconds: first.durationSeconds,
614
+ audioSegments,
615
+ runs,
616
+ };
617
+ }