nodejs-audio-visualizer 5.1.0 → 5.3.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.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +81 -80
  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 +78 -77
  10. package/dist/config.js +377 -369
  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 +66 -34
  15. package/dist/highlight.js +471 -334
  16. package/dist/highlight.js.map +1 -1
  17. package/dist/image.d.ts +97 -92
  18. package/dist/image.js +545 -538
  19. package/dist/image.js.map +1 -1
  20. package/dist/index.d.ts +87 -79
  21. package/dist/index.js +598 -566
  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 +150 -120
  26. package/dist/video.js +677 -582
  27. package/dist/video.js.map +1 -1
  28. package/dist/waitForEventLoop.d.ts +1 -1
  29. package/dist/waitForEventLoop.js +5 -5
  30. package/package.json +62 -62
  31. package/src/audio.ts +129 -129
  32. package/src/beats.ts +279 -279
  33. package/src/bpmEncoder.ts +97 -97
  34. package/src/config.ts +487 -476
  35. package/src/dsp.ts +95 -95
  36. package/src/highlight.ts +451 -291
  37. package/src/image.ts +707 -700
  38. package/src/index.ts +813 -769
  39. package/src/subtitleConvert.ts +63 -63
  40. package/src/tests/audio.test.ts +102 -102
  41. package/src/tests/beats.test.ts +134 -134
  42. package/src/tests/config.test.ts +534 -496
  43. package/src/tests/dsp.test.ts +24 -24
  44. package/src/tests/highlight.test.ts +261 -178
  45. package/src/tests/image.test.ts +319 -308
  46. package/src/tests/index.test.ts +180 -180
  47. package/src/tests/subtitleConvert.test.ts +23 -23
  48. package/src/tests/video.test.ts +535 -447
  49. package/src/typings.d.ts +28 -28
  50. package/src/video.ts +856 -722
  51. package/src/waitForEventLoop.ts +2 -2
package/src/highlight.ts CHANGED
@@ -1,291 +1,451 @@
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
- export interface HighlightAudioSegment {
13
- seekSeconds: number;
14
- durationSeconds: number;
15
- }
16
-
17
- /** One auto-highlight segment before concatenation (for separate output files). */
18
- export interface HighlightRun {
19
- startFrame: number;
20
- highlightFrames: number;
21
- spectrums: number[][];
22
- beatFrameIndices: number[];
23
- beatIntensities: number[];
24
- audioSegment: HighlightAudioSegment;
25
- }
26
-
27
- function buildHighlightRun(
28
- fps: number,
29
- seg: { startFrame: number; highlightFrames: number },
30
- spectrums: number[][],
31
- beatEvents: BeatFrameEvent[],
32
- ): HighlightRun {
33
- const { startFrame, highlightFrames } = seg;
34
- const spectrumsSlice = spectrums.slice(startFrame, startFrame + highlightFrames);
35
- const beatsInWindow = beatEvents
36
- .filter(b => b.frameIndex >= startFrame && b.frameIndex < startFrame + highlightFrames)
37
- .sort((a, b) => a.frameIndex - b.frameIndex);
38
- const beatFrameIndices = beatsInWindow.map(b => b.frameIndex - startFrame);
39
- const beatIntensities = beatsInWindow.map(b => b.intensity);
40
- return {
41
- startFrame,
42
- highlightFrames,
43
- spectrums: spectrumsSlice,
44
- beatFrameIndices,
45
- beatIntensities,
46
- audioSegment: {
47
- seekSeconds: startFrame / fps,
48
- durationSeconds: highlightFrames / fps,
49
- },
50
- };
51
- }
52
-
53
- function frameEnergy(spectrum: number[]): number {
54
- let s = 0;
55
- for (let i = 0; i < spectrum.length; i++) {
56
- const v = spectrum[i];
57
- s += v * v;
58
- }
59
- return s;
60
- }
61
-
62
- function rangesOverlap(
63
- aStart: number,
64
- aEndExclusive: number,
65
- ranges: Array<{ start: number; endExclusive: number }>,
66
- ): boolean {
67
- for (const r of ranges) {
68
- if (aStart < r.endExclusive && aEndExclusive > r.start) {
69
- return true;
70
- }
71
- }
72
- return false;
73
- }
74
-
75
- async function buildFrameEnergies(
76
- spectrums: number[][],
77
- totalFrames: number,
78
- ): Promise<Float64Array> {
79
- const energies = new Float64Array(totalFrames);
80
- for (let i = 0; i < totalFrames; i++) {
81
- energies[i] = frameEnergy(spectrums[i] ?? []);
82
- await waitForEventLoop();
83
- }
84
- return energies;
85
- }
86
-
87
- /**
88
- * Chooses the fixed-length window whose frames have the highest summed spectral energy
89
- * (typical chorus / drop vs a single loud intro transient).
90
- * Skips windows that overlap any excluded range.
91
- */
92
- async function findBestHighlightStart(
93
- energies: Float64Array,
94
- totalFrames: number,
95
- windowFrames: number,
96
- excludeRanges: Array<{ start: number; endExclusive: number }>,
97
- ): Promise<number | null> {
98
- if (totalFrames <= windowFrames) {
99
- if (!rangesOverlap(0, totalFrames, excludeRanges)) {
100
- return 0;
101
- }
102
- return null;
103
- }
104
-
105
- let minE = Infinity;
106
- let maxE = -Infinity;
107
- for (let i = 0; i < totalFrames; i++) {
108
- const e = energies[i];
109
- if (e < minE) {
110
- minE = e;
111
- }
112
- if (e > maxE) {
113
- maxE = e;
114
- }
115
- await waitForEventLoop();
116
- }
117
-
118
- const prefix = new Float64Array(totalFrames + 1);
119
- for (let i = 0; i < totalFrames; i++) {
120
- prefix[i + 1] = prefix[i] + energies[i];
121
- await waitForEventLoop();
122
- }
123
-
124
- const maxStart = totalFrames - windowFrames;
125
- let bestStart: number | null = null;
126
- let bestScore = -Infinity;
127
-
128
- for (let start = 0; start <= maxStart; start++) {
129
- if (rangesOverlap(start, start + windowFrames, excludeRanges)) {
130
- await waitForEventLoop();
131
- continue;
132
- }
133
- const score = prefix[start + windowFrames] - prefix[start];
134
- if (score > bestScore) {
135
- bestScore = score;
136
- bestStart = start;
137
- }
138
- await waitForEventLoop();
139
- }
140
-
141
- if (bestStart === null) {
142
- return null;
143
- }
144
-
145
- if (maxE <= minE) {
146
- const center = Math.floor((totalFrames - windowFrames) / 2);
147
- let bestDist = Infinity;
148
- let tieBreak: number | null = null;
149
- for (let start = 0; start <= maxStart; start++) {
150
- if (rangesOverlap(start, start + windowFrames, excludeRanges)) {
151
- await waitForEventLoop();
152
- continue;
153
- }
154
- const dist = Math.abs(start - center);
155
- if (dist < bestDist) {
156
- bestDist = dist;
157
- tieBreak = start;
158
- }
159
- await waitForEventLoop();
160
- }
161
- return tieBreak;
162
- }
163
-
164
- return bestStart;
165
- }
166
-
167
-
168
- /**
169
- * Picks up to `segmentCount` non-overlapping 15s windows with highest summed spectral energy each,
170
- * sorts them chronologically, concatenates spectrums, and remaps beat indices.
171
- */
172
- export async function computeHighlightSlice(
173
- fps: number,
174
- totalFrames: number,
175
- spectrums: number[][],
176
- beatEvents: BeatFrameEvent[],
177
- segmentCount = 1,
178
- ): Promise<{
179
- startFrame: number;
180
- highlightFrames: number;
181
- spectrums: number[][];
182
- beatFrameIndices: number[];
183
- beatIntensities: number[];
184
- audioSeekSeconds: number;
185
- audioDurationSeconds: number;
186
- audioSegments: HighlightAudioSegment[];
187
- runs: HighlightRun[];
188
- }> {
189
- const highlightFramesTarget = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
190
-
191
- if (totalFrames <= 0) {
192
- return {
193
- startFrame: 0,
194
- highlightFrames: 0,
195
- spectrums: [],
196
- beatFrameIndices: [],
197
- beatIntensities: [],
198
- audioSeekSeconds: 0,
199
- audioDurationSeconds: 0,
200
- audioSegments: [],
201
- runs: [],
202
- };
203
- }
204
-
205
- if (totalFrames <= highlightFramesTarget) {
206
- const audioSegments: HighlightAudioSegment[] = [
207
- {
208
- seekSeconds: 0,
209
- durationSeconds: totalFrames / fps,
210
- },
211
- ];
212
- const fullSeg = { startFrame: 0, highlightFrames: totalFrames };
213
- const fullRun = buildHighlightRun(fps, fullSeg, spectrums, beatEvents);
214
- return {
215
- startFrame: 0,
216
- highlightFrames: totalFrames,
217
- spectrums: spectrums.slice(),
218
- beatFrameIndices: fullRun.beatFrameIndices,
219
- beatIntensities: fullRun.beatIntensities,
220
- audioSeekSeconds: 0,
221
- audioDurationSeconds: totalFrames / fps,
222
- audioSegments,
223
- runs: [fullRun],
224
- };
225
- }
226
-
227
- const highlightFrames = highlightFramesTarget;
228
- const energies = await buildFrameEnergies(spectrums, totalFrames);
229
- const excludeRanges: Array<{ start: number; endExclusive: number }> = [];
230
- const rawSegments: Array<{ startFrame: number; highlightFrames: number }> = [];
231
-
232
- const n = Math.max(1, Math.floor(segmentCount));
233
- for (let k = 0; k < n; k++) {
234
- const startFrame = await findBestHighlightStart(
235
- energies,
236
- totalFrames,
237
- highlightFrames,
238
- excludeRanges,
239
- );
240
- if (startFrame === null) {
241
- break;
242
- }
243
- rawSegments.push({ startFrame, highlightFrames });
244
- excludeRanges.push({ start: startFrame, endExclusive: startFrame + highlightFrames });
245
- }
246
-
247
- rawSegments.sort((a, b) => a.startFrame - b.startFrame);
248
-
249
- const runs = rawSegments.map(seg =>
250
- buildHighlightRun(fps, seg, spectrums, beatEvents),
251
- );
252
-
253
- const slicedSpectrums: number[][] = [];
254
- const beatFrameIndices: number[] = [];
255
- const beatIntensities: number[] = [];
256
- let outOffset = 0;
257
-
258
- for (const seg of rawSegments) {
259
- const chunk = spectrums.slice(seg.startFrame, seg.startFrame + seg.highlightFrames);
260
- slicedSpectrums.push(...chunk);
261
-
262
- const beatsInSeg = beatEvents
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;
270
- }
271
-
272
- const totalHighlightFrames = slicedSpectrums.length;
273
- const audioSegments: HighlightAudioSegment[] = rawSegments.map(seg => ({
274
- seekSeconds: seg.startFrame / fps,
275
- durationSeconds: seg.highlightFrames / fps,
276
- }));
277
-
278
- const first = audioSegments[0] ?? { seekSeconds: 0, durationSeconds: 0 };
279
-
280
- return {
281
- startFrame: rawSegments[0]?.startFrame ?? 0,
282
- highlightFrames: totalHighlightFrames,
283
- spectrums: slicedSpectrums,
284
- beatFrameIndices,
285
- beatIntensities,
286
- audioSeekSeconds: first.seekSeconds,
287
- audioDurationSeconds: first.durationSeconds,
288
- audioSegments,
289
- runs,
290
- };
291
- }
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
+
24
+ /** Tempo grid used to snap highlight cuts to beats (subset of `TempoEstimate`). */
25
+ export interface HighlightBeatGrid {
26
+ periodFrames: number;
27
+ phaseFrame: number;
28
+ /** Beat times in seconds from the start of the audio. */
29
+ beatsSec?: number[];
30
+ }
31
+
32
+ export interface HighlightAudioSegment {
33
+ seekSeconds: number;
34
+ durationSeconds: number;
35
+ }
36
+
37
+ /** One auto-highlight segment before concatenation (for separate output files). */
38
+ export interface HighlightRun {
39
+ startFrame: number;
40
+ highlightFrames: number;
41
+ spectrums: number[][];
42
+ beatFrameIndices: number[];
43
+ beatIntensities: number[];
44
+ audioSegment: HighlightAudioSegment;
45
+ }
46
+
47
+ function buildHighlightRun(
48
+ fps: number,
49
+ seg: { startFrame: number; highlightFrames: number },
50
+ spectrums: number[][],
51
+ beatEvents: BeatFrameEvent[],
52
+ ): HighlightRun {
53
+ const { startFrame, highlightFrames } = seg;
54
+ const spectrumsSlice = spectrums.slice(startFrame, startFrame + highlightFrames);
55
+ const beatsInWindow = beatEvents
56
+ .filter(b => b.frameIndex >= startFrame && b.frameIndex < startFrame + highlightFrames)
57
+ .sort((a, b) => a.frameIndex - b.frameIndex);
58
+ const beatFrameIndices = beatsInWindow.map(b => b.frameIndex - startFrame);
59
+ const beatIntensities = beatsInWindow.map(b => b.intensity);
60
+ return {
61
+ startFrame,
62
+ highlightFrames,
63
+ spectrums: spectrumsSlice,
64
+ beatFrameIndices,
65
+ beatIntensities,
66
+ audioSegment: {
67
+ seekSeconds: startFrame / fps,
68
+ durationSeconds: highlightFrames / fps,
69
+ },
70
+ };
71
+ }
72
+
73
+ function frameEnergy(spectrum: number[]): number {
74
+ let s = 0;
75
+ for (let i = 0; i < spectrum.length; i++) {
76
+ const v = spectrum[i];
77
+ s += v * v;
78
+ }
79
+ return s;
80
+ }
81
+
82
+ function rangesOverlap(
83
+ aStart: number,
84
+ aEndExclusive: number,
85
+ ranges: Array<{ start: number; endExclusive: number }>,
86
+ ): boolean {
87
+ for (const r of ranges) {
88
+ if (aStart < r.endExclusive && aEndExclusive > r.start) {
89
+ return true;
90
+ }
91
+ }
92
+ return false;
93
+ }
94
+
95
+ async function buildFrameEnergies(
96
+ spectrums: number[][],
97
+ totalFrames: number,
98
+ ): Promise<Float64Array> {
99
+ const energies = new Float64Array(totalFrames);
100
+ for (let i = 0; i < totalFrames; i++) {
101
+ energies[i] = frameEnergy(spectrums[i] ?? []);
102
+ await waitForEventLoop();
103
+ }
104
+ return energies;
105
+ }
106
+
107
+ /**
108
+ * Chooses the fixed-length window whose frames have the highest summed spectral energy
109
+ * (typical chorus / drop vs a single loud intro transient).
110
+ * Skips windows that overlap any excluded range.
111
+ */
112
+ async function findBestHighlightStart(
113
+ energies: Float64Array,
114
+ totalFrames: number,
115
+ windowFrames: number,
116
+ excludeRanges: Array<{ start: number; endExclusive: number }>,
117
+ ): Promise<number | null> {
118
+ if (totalFrames <= windowFrames) {
119
+ if (!rangesOverlap(0, totalFrames, excludeRanges)) {
120
+ return 0;
121
+ }
122
+ return null;
123
+ }
124
+
125
+ let minE = Infinity;
126
+ let maxE = -Infinity;
127
+ for (let i = 0; i < totalFrames; i++) {
128
+ const e = energies[i];
129
+ if (e < minE) {
130
+ minE = e;
131
+ }
132
+ if (e > maxE) {
133
+ maxE = e;
134
+ }
135
+ await waitForEventLoop();
136
+ }
137
+
138
+ const prefix = new Float64Array(totalFrames + 1);
139
+ for (let i = 0; i < totalFrames; i++) {
140
+ prefix[i + 1] = prefix[i] + energies[i];
141
+ await waitForEventLoop();
142
+ }
143
+
144
+ const maxStart = totalFrames - windowFrames;
145
+ let bestStart: number | null = null;
146
+ let bestScore = -Infinity;
147
+
148
+ for (let start = 0; start <= maxStart; start++) {
149
+ if (rangesOverlap(start, start + windowFrames, excludeRanges)) {
150
+ await waitForEventLoop();
151
+ continue;
152
+ }
153
+ const score = prefix[start + windowFrames] - prefix[start];
154
+ if (score > bestScore) {
155
+ bestScore = score;
156
+ bestStart = start;
157
+ }
158
+ await waitForEventLoop();
159
+ }
160
+
161
+ if (bestStart === null) {
162
+ return null;
163
+ }
164
+
165
+ if (maxE <= minE) {
166
+ const center = Math.floor((totalFrames - windowFrames) / 2);
167
+ let bestDist = Infinity;
168
+ let tieBreak: number | null = null;
169
+ for (let start = 0; start <= maxStart; start++) {
170
+ if (rangesOverlap(start, start + windowFrames, excludeRanges)) {
171
+ await waitForEventLoop();
172
+ continue;
173
+ }
174
+ const dist = Math.abs(start - center);
175
+ if (dist < bestDist) {
176
+ bestDist = dist;
177
+ tieBreak = start;
178
+ }
179
+ await waitForEventLoop();
180
+ }
181
+ return tieBreak;
182
+ }
183
+
184
+ return bestStart;
185
+ }
186
+
187
+ function nearestOnset(sortedOnsets: number[], frame: number): number | null {
188
+ let lo = 0;
189
+ let hi = sortedOnsets.length;
190
+ while (lo < hi) {
191
+ const mid = (lo + hi) >> 1;
192
+ if (sortedOnsets[mid] < frame) {
193
+ lo = mid + 1;
194
+ } else {
195
+ hi = mid;
196
+ }
197
+ }
198
+ let best: number | null = null;
199
+ for (const i of [lo - 1, lo]) {
200
+ if (i >= 0 && i < sortedOnsets.length) {
201
+ const v = sortedOnsets[i];
202
+ if (best === null || Math.abs(v - frame) < Math.abs(best - frame)) {
203
+ best = v;
204
+ }
205
+ }
206
+ }
207
+ return best;
208
+ }
209
+
210
+ /**
211
+ * Beat frames (sorted, unique, within [0, totalFrames]) where a highlight may start or end.
212
+ * With a tempo grid: tracked beats from the analyzed region, then the regular grid beyond it
213
+ * (each grid beat nudged onto a nearby detected onset to follow slight tempo drift).
214
+ * Without one: the detected onset beats.
215
+ */
216
+ export function buildBeatCandidates(
217
+ fps: number,
218
+ totalFrames: number,
219
+ beatEvents: BeatFrameEvent[],
220
+ beatGrid?: HighlightBeatGrid | null,
221
+ ): number[] {
222
+ const onsets = Array.from(new Set(beatEvents.map(b => b.frameIndex)))
223
+ .filter(f => f >= 0 && f <= totalFrames)
224
+ .sort((a, b) => a - b);
225
+ const period = beatGrid?.periodFrames ?? 0;
226
+ if (!beatGrid || !(period > 0) || !isFinite(period)) {
227
+ return onsets;
228
+ }
229
+
230
+ const frames = new Set<number>();
231
+ let lastTracked = -Infinity;
232
+ for (const t of beatGrid.beatsSec ?? []) {
233
+ const f = Math.round(t * fps);
234
+ if (f >= 0 && f <= totalFrames) {
235
+ frames.add(f);
236
+ lastTracked = Math.max(lastTracked, f);
237
+ }
238
+ }
239
+
240
+ const maxOnsetShift = period * GRID_ONSET_SNAP_SHARE;
241
+ const firstN = Math.ceil(-beatGrid.phaseFrame / period);
242
+ for (let n = firstN; ; n++) {
243
+ const gridFrame = beatGrid.phaseFrame + n * period;
244
+ if (gridFrame > totalFrames) {
245
+ break;
246
+ }
247
+ if (gridFrame <= lastTracked + period / 2) {
248
+ continue;
249
+ }
250
+ const onset = nearestOnset(onsets, gridFrame);
251
+ const f = onset !== null && Math.abs(onset - gridFrame) <= maxOnsetShift
252
+ ? onset
253
+ : Math.round(gridFrame);
254
+ if (f >= 0 && f <= totalFrames) {
255
+ frames.add(f);
256
+ }
257
+ }
258
+ return Array.from(frames).sort((a, b) => a - b);
259
+ }
260
+
261
+ /**
262
+ * Moves a fixed-length window's start and end onto beats (each by at most `maxShiftFrames`),
263
+ * preferring lengths that span whole bars when the beat period is known.
264
+ * Boundaries without a nearby beat stay where they are.
265
+ */
266
+ export function snapSegmentToBeats(
267
+ seg: { startFrame: number; highlightFrames: number },
268
+ totalFrames: number,
269
+ beatFrames: number[],
270
+ maxShiftFrames: number,
271
+ periodFrames?: number,
272
+ ): { startFrame: number; highlightFrames: number } {
273
+ const rawStart = seg.startFrame;
274
+ const rawEnd = seg.startFrame + seg.highlightFrames;
275
+ const near = (target: number, max: number) =>
276
+ beatFrames.filter(f => Math.abs(f - target) <= maxShiftFrames && f >= 0 && f <= max);
277
+ const startCands = near(rawStart, totalFrames - 1);
278
+ const endCands = near(rawEnd, totalFrames);
279
+ if (startCands.length === 0) {
280
+ startCands.push(rawStart);
281
+ }
282
+ if (endCands.length === 0) {
283
+ endCands.push(Math.min(rawEnd, totalFrames));
284
+ }
285
+
286
+ const period = periodFrames && periodFrames > 0 && isFinite(periodFrames) ? periodFrames : 0;
287
+ const shiftUnit = period > 0 ? period : Math.max(1, maxShiftFrames);
288
+ let best: { startFrame: number; endFrame: number } | null = null;
289
+ let bestCost = Infinity;
290
+ for (const s of startCands) {
291
+ for (const e of endCands) {
292
+ if (e - s < seg.highlightFrames / 2) {
293
+ continue;
294
+ }
295
+ let cost = (Math.abs(s - rawStart) + Math.abs(e - rawEnd)) / shiftUnit;
296
+ if (period > 0) {
297
+ const beats = Math.round((e - s) / period);
298
+ const offBar = Math.abs(beats - BEATS_PER_BAR * Math.round(beats / BEATS_PER_BAR));
299
+ cost += offBar * OFF_BAR_PENALTY;
300
+ }
301
+ if (cost < bestCost) {
302
+ bestCost = cost;
303
+ best = { startFrame: s, endFrame: e };
304
+ }
305
+ }
306
+ }
307
+ if (!best) {
308
+ return seg;
309
+ }
310
+ return { startFrame: best.startFrame, highlightFrames: best.endFrame - best.startFrame };
311
+ }
312
+
313
+ /**
314
+ * Picks up to `segmentCount` non-overlapping ~15s windows with highest summed spectral energy each,
315
+ * moves their boundaries onto beats (see `snapSegmentToBeats`), sorts them chronologically,
316
+ * concatenates spectrums, and remaps beat indices.
317
+ */
318
+ export async function computeHighlightSlice(
319
+ fps: number,
320
+ totalFrames: number,
321
+ spectrums: number[][],
322
+ beatEvents: BeatFrameEvent[],
323
+ segmentCount = 1,
324
+ beatGrid?: HighlightBeatGrid | null,
325
+ ): Promise<{
326
+ startFrame: number;
327
+ highlightFrames: number;
328
+ spectrums: number[][];
329
+ beatFrameIndices: number[];
330
+ beatIntensities: number[];
331
+ audioSeekSeconds: number;
332
+ audioDurationSeconds: number;
333
+ audioSegments: HighlightAudioSegment[];
334
+ runs: HighlightRun[];
335
+ }> {
336
+ const highlightFramesTarget = Math.ceil(HIGHLIGHT_DURATION_SEC * fps);
337
+
338
+ if (totalFrames <= 0) {
339
+ return {
340
+ startFrame: 0,
341
+ highlightFrames: 0,
342
+ spectrums: [],
343
+ beatFrameIndices: [],
344
+ beatIntensities: [],
345
+ audioSeekSeconds: 0,
346
+ audioDurationSeconds: 0,
347
+ audioSegments: [],
348
+ runs: [],
349
+ };
350
+ }
351
+
352
+ if (totalFrames <= highlightFramesTarget) {
353
+ const audioSegments: HighlightAudioSegment[] = [
354
+ {
355
+ seekSeconds: 0,
356
+ durationSeconds: totalFrames / fps,
357
+ },
358
+ ];
359
+ const fullSeg = { startFrame: 0, highlightFrames: totalFrames };
360
+ const fullRun = buildHighlightRun(fps, fullSeg, spectrums, beatEvents);
361
+ return {
362
+ startFrame: 0,
363
+ highlightFrames: totalFrames,
364
+ spectrums: spectrums.slice(),
365
+ beatFrameIndices: fullRun.beatFrameIndices,
366
+ beatIntensities: fullRun.beatIntensities,
367
+ audioSeekSeconds: 0,
368
+ audioDurationSeconds: totalFrames / fps,
369
+ audioSegments,
370
+ runs: [fullRun],
371
+ };
372
+ }
373
+
374
+ const highlightFrames = highlightFramesTarget;
375
+ const energies = await buildFrameEnergies(spectrums, totalFrames);
376
+ const excludeRanges: Array<{ start: number; endExclusive: number }> = [];
377
+ const rawSegments: Array<{ startFrame: number; highlightFrames: number }> = [];
378
+ const beatFrames = buildBeatCandidates(fps, totalFrames, beatEvents, beatGrid);
379
+ const maxShiftFrames = Math.round(MAX_BEAT_SNAP_SEC * fps);
380
+
381
+ const n = Math.max(1, Math.floor(segmentCount));
382
+ for (let k = 0; k < n; k++) {
383
+ const startFrame = await findBestHighlightStart(
384
+ energies,
385
+ totalFrames,
386
+ highlightFrames,
387
+ excludeRanges,
388
+ );
389
+ if (startFrame === null) {
390
+ break;
391
+ }
392
+ const snapped = snapSegmentToBeats(
393
+ { startFrame, highlightFrames },
394
+ totalFrames,
395
+ beatFrames,
396
+ maxShiftFrames,
397
+ beatGrid?.periodFrames,
398
+ );
399
+ rawSegments.push(snapped);
400
+ // Padded so the next raw window, once snapped, cannot reach back into this one.
401
+ excludeRanges.push({
402
+ start: snapped.startFrame - maxShiftFrames,
403
+ endExclusive: snapped.startFrame + snapped.highlightFrames + maxShiftFrames,
404
+ });
405
+ }
406
+
407
+ rawSegments.sort((a, b) => a.startFrame - b.startFrame);
408
+
409
+ const runs = rawSegments.map(seg =>
410
+ buildHighlightRun(fps, seg, spectrums, beatEvents),
411
+ );
412
+
413
+ const slicedSpectrums: number[][] = [];
414
+ const beatFrameIndices: number[] = [];
415
+ const beatIntensities: number[] = [];
416
+ let outOffset = 0;
417
+
418
+ for (const seg of rawSegments) {
419
+ const chunk = spectrums.slice(seg.startFrame, seg.startFrame + seg.highlightFrames);
420
+ slicedSpectrums.push(...chunk);
421
+
422
+ const beatsInSeg = beatEvents
423
+ .filter(b => b.frameIndex >= seg.startFrame && b.frameIndex < seg.startFrame + seg.highlightFrames)
424
+ .sort((a, b) => a.frameIndex - b.frameIndex);
425
+ for (const b of beatsInSeg) {
426
+ beatFrameIndices.push(b.frameIndex - seg.startFrame + outOffset);
427
+ beatIntensities.push(b.intensity);
428
+ }
429
+ outOffset += seg.highlightFrames;
430
+ }
431
+
432
+ const totalHighlightFrames = slicedSpectrums.length;
433
+ const audioSegments: HighlightAudioSegment[] = rawSegments.map(seg => ({
434
+ seekSeconds: seg.startFrame / fps,
435
+ durationSeconds: seg.highlightFrames / fps,
436
+ }));
437
+
438
+ const first = audioSegments[0] ?? { seekSeconds: 0, durationSeconds: 0 };
439
+
440
+ return {
441
+ startFrame: rawSegments[0]?.startFrame ?? 0,
442
+ highlightFrames: totalHighlightFrames,
443
+ spectrums: slicedSpectrums,
444
+ beatFrameIndices,
445
+ beatIntensities,
446
+ audioSeekSeconds: first.seekSeconds,
447
+ audioDurationSeconds: first.durationSeconds,
448
+ audioSegments,
449
+ runs,
450
+ };
451
+ }