nodejs-audio-visualizer 5.2.0 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -81
- package/dist/config.d.ts +1 -0
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/highlight.d.ts +60 -3
- package/dist/highlight.js +338 -66
- package/dist/highlight.js.map +1 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.js +150 -80
- package/dist/index.js.map +1 -1
- package/dist/video.d.ts +19 -0
- package/dist/video.js +62 -3
- package/dist/video.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +11 -0
- package/src/highlight.ts +365 -39
- package/src/index.ts +882 -811
- package/src/tests/config.test.ts +19 -0
- package/src/tests/highlight.test.ts +222 -3
- package/src/tests/video.test.ts +80 -1
- package/src/video.ts +81 -0
package/src/highlight.ts
CHANGED
|
@@ -9,9 +9,37 @@ export interface BeatFrameEvent {
|
|
|
9
9
|
intensity: number;
|
|
10
10
|
}
|
|
11
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
|
+
|
|
12
38
|
export interface HighlightAudioSegment {
|
|
13
39
|
seekSeconds: number;
|
|
14
40
|
durationSeconds: number;
|
|
41
|
+
/** Silence played before the audio, for a lead-in reaching back past the track start. */
|
|
42
|
+
delaySeconds?: number;
|
|
15
43
|
}
|
|
16
44
|
|
|
17
45
|
/** One auto-highlight segment before concatenation (for separate output files). */
|
|
@@ -22,16 +50,27 @@ export interface HighlightRun {
|
|
|
22
50
|
beatFrameIndices: number[];
|
|
23
51
|
beatIntensities: number[];
|
|
24
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;
|
|
25
55
|
}
|
|
26
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
|
+
*/
|
|
27
61
|
function buildHighlightRun(
|
|
28
62
|
fps: number,
|
|
29
|
-
seg: { startFrame: number; highlightFrames: number },
|
|
63
|
+
seg: { startFrame: number; highlightFrames: number; leadInFrames?: number },
|
|
30
64
|
spectrums: number[][],
|
|
31
65
|
beatEvents: BeatFrameEvent[],
|
|
32
66
|
): HighlightRun {
|
|
33
|
-
const { startFrame, highlightFrames } = seg;
|
|
34
|
-
const
|
|
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
|
+
];
|
|
35
74
|
const beatsInWindow = beatEvents
|
|
36
75
|
.filter(b => b.frameIndex >= startFrame && b.frameIndex < startFrame + highlightFrames)
|
|
37
76
|
.sort((a, b) => a.frameIndex - b.frameIndex);
|
|
@@ -44,9 +83,11 @@ function buildHighlightRun(
|
|
|
44
83
|
beatFrameIndices,
|
|
45
84
|
beatIntensities,
|
|
46
85
|
audioSegment: {
|
|
47
|
-
seekSeconds: startFrame / fps,
|
|
48
|
-
durationSeconds: highlightFrames / fps,
|
|
86
|
+
seekSeconds: (startFrame + silentFrames) / fps,
|
|
87
|
+
durationSeconds: (highlightFrames - silentFrames) / fps,
|
|
88
|
+
...(silentFrames > 0 && { delaySeconds: silentFrames / fps }),
|
|
49
89
|
},
|
|
90
|
+
...(leadInFrames !== undefined && { leadInFrames }),
|
|
50
91
|
};
|
|
51
92
|
}
|
|
52
93
|
|
|
@@ -164,10 +205,247 @@ async function findBestHighlightStart(
|
|
|
164
205
|
return bestStart;
|
|
165
206
|
}
|
|
166
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
|
+
}
|
|
167
382
|
|
|
168
383
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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`).
|
|
171
449
|
*/
|
|
172
450
|
export async function computeHighlightSlice(
|
|
173
451
|
fps: number,
|
|
@@ -175,6 +453,8 @@ export async function computeHighlightSlice(
|
|
|
175
453
|
spectrums: number[][],
|
|
176
454
|
beatEvents: BeatFrameEvent[],
|
|
177
455
|
segmentCount = 1,
|
|
456
|
+
beatGrid?: HighlightBeatGrid | null,
|
|
457
|
+
leadInFrames?: number,
|
|
178
458
|
): Promise<{
|
|
179
459
|
startFrame: number;
|
|
180
460
|
highlightFrames: number;
|
|
@@ -203,23 +483,21 @@ export async function computeHighlightSlice(
|
|
|
203
483
|
}
|
|
204
484
|
|
|
205
485
|
if (totalFrames <= highlightFramesTarget) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
];
|
|
212
|
-
const fullSeg = { startFrame: 0, highlightFrames: totalFrames };
|
|
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 };
|
|
213
491
|
const fullRun = buildHighlightRun(fps, fullSeg, spectrums, beatEvents);
|
|
214
492
|
return {
|
|
215
|
-
startFrame:
|
|
216
|
-
highlightFrames:
|
|
217
|
-
spectrums: spectrums
|
|
493
|
+
startFrame: fullRun.startFrame,
|
|
494
|
+
highlightFrames: fullRun.highlightFrames,
|
|
495
|
+
spectrums: fullRun.spectrums,
|
|
218
496
|
beatFrameIndices: fullRun.beatFrameIndices,
|
|
219
497
|
beatIntensities: fullRun.beatIntensities,
|
|
220
|
-
audioSeekSeconds:
|
|
221
|
-
audioDurationSeconds:
|
|
222
|
-
audioSegments,
|
|
498
|
+
audioSeekSeconds: fullRun.audioSegment.seekSeconds,
|
|
499
|
+
audioDurationSeconds: fullRun.audioSegment.durationSeconds,
|
|
500
|
+
audioSegments: [fullRun.audioSegment],
|
|
223
501
|
runs: [fullRun],
|
|
224
502
|
};
|
|
225
503
|
}
|
|
@@ -227,10 +505,48 @@ export async function computeHighlightSlice(
|
|
|
227
505
|
const highlightFrames = highlightFramesTarget;
|
|
228
506
|
const energies = await buildFrameEnergies(spectrums, totalFrames);
|
|
229
507
|
const excludeRanges: Array<{ start: number; endExclusive: number }> = [];
|
|
230
|
-
const rawSegments: Array<{ startFrame: number; highlightFrames: 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);
|
|
231
513
|
|
|
232
514
|
const n = Math.max(1, Math.floor(segmentCount));
|
|
233
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
|
+
}
|
|
234
550
|
const startFrame = await findBestHighlightStart(
|
|
235
551
|
energies,
|
|
236
552
|
totalFrames,
|
|
@@ -240,8 +556,28 @@ export async function computeHighlightSlice(
|
|
|
240
556
|
if (startFrame === null) {
|
|
241
557
|
break;
|
|
242
558
|
}
|
|
243
|
-
|
|
244
|
-
|
|
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
|
+
});
|
|
245
581
|
}
|
|
246
582
|
|
|
247
583
|
rawSegments.sort((a, b) => a.startFrame - b.startFrame);
|
|
@@ -255,25 +591,15 @@ export async function computeHighlightSlice(
|
|
|
255
591
|
const beatIntensities: number[] = [];
|
|
256
592
|
let outOffset = 0;
|
|
257
593
|
|
|
258
|
-
for (const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
.filter(b => b.frameIndex >= seg.startFrame && b.frameIndex < seg.startFrame + seg.highlightFrames)
|
|
264
|
-
.sort((a, b) => a.frameIndex - b.frameIndex);
|
|
265
|
-
for (const b of beatsInSeg) {
|
|
266
|
-
beatFrameIndices.push(b.frameIndex - seg.startFrame + outOffset);
|
|
267
|
-
beatIntensities.push(b.intensity);
|
|
268
|
-
}
|
|
269
|
-
outOffset += seg.highlightFrames;
|
|
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;
|
|
270
599
|
}
|
|
271
600
|
|
|
272
601
|
const totalHighlightFrames = slicedSpectrums.length;
|
|
273
|
-
const audioSegments: HighlightAudioSegment[] =
|
|
274
|
-
seekSeconds: seg.startFrame / fps,
|
|
275
|
-
durationSeconds: seg.highlightFrames / fps,
|
|
276
|
-
}));
|
|
602
|
+
const audioSegments: HighlightAudioSegment[] = runs.map(run => run.audioSegment);
|
|
277
603
|
|
|
278
604
|
const first = audioSegments[0] ?? { seekSeconds: 0, durationSeconds: 0 };
|
|
279
605
|
|