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.
- package/LICENSE +21 -21
- package/README.md +81 -80
- package/dist/audio.d.ts +13 -13
- package/dist/audio.js +128 -128
- package/dist/beats.d.ts +39 -39
- package/dist/beats.js +220 -220
- package/dist/bpmEncoder.d.ts +14 -14
- package/dist/bpmEncoder.js +97 -97
- package/dist/config.d.ts +78 -77
- package/dist/config.js +377 -369
- package/dist/config.js.map +1 -1
- package/dist/dsp.d.ts +16 -16
- package/dist/dsp.js +76 -76
- package/dist/highlight.d.ts +66 -34
- package/dist/highlight.js +471 -334
- package/dist/highlight.js.map +1 -1
- package/dist/image.d.ts +97 -92
- package/dist/image.js +545 -538
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +87 -79
- package/dist/index.js +598 -566
- package/dist/index.js.map +1 -1
- package/dist/subtitleConvert.d.ts +3 -3
- package/dist/subtitleConvert.js +61 -61
- package/dist/video.d.ts +150 -120
- package/dist/video.js +677 -582
- package/dist/video.js.map +1 -1
- package/dist/waitForEventLoop.d.ts +1 -1
- package/dist/waitForEventLoop.js +5 -5
- package/package.json +62 -62
- package/src/audio.ts +129 -129
- package/src/beats.ts +279 -279
- package/src/bpmEncoder.ts +97 -97
- package/src/config.ts +487 -476
- package/src/dsp.ts +95 -95
- package/src/highlight.ts +451 -291
- package/src/image.ts +707 -700
- package/src/index.ts +813 -769
- package/src/subtitleConvert.ts +63 -63
- package/src/tests/audio.test.ts +102 -102
- package/src/tests/beats.test.ts +134 -134
- package/src/tests/config.test.ts +534 -496
- package/src/tests/dsp.test.ts +24 -24
- package/src/tests/highlight.test.ts +261 -178
- package/src/tests/image.test.ts +319 -308
- package/src/tests/index.test.ts +180 -180
- package/src/tests/subtitleConvert.test.ts +23 -23
- package/src/tests/video.test.ts +535 -447
- package/src/typings.d.ts +28 -28
- package/src/video.ts +856 -722
- 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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
let
|
|
126
|
-
let
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
+
}
|