-
-
Notifications
You must be signed in to change notification settings - Fork 48
Expand file tree
/
Copy pathbeatResolver.ts
More file actions
397 lines (346 loc) · 12.9 KB
/
Copy pathbeatResolver.ts
File metadata and controls
397 lines (346 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
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
126
127
128
129
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
231
232
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
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
/**
* Pure beat-resolution cascade extracted from useScrollAndAnimation.
*
* This module takes a playback time plus analysis data and returns which
* beat/downbeat should be highlighted, together with the next hysteresis
* state. It is a pure function with no React, rAF, Zustand, or DOM
* dependencies so the logic can be unit-tested in isolation.
*
* Behavior is intentionally identical to the previous in-hook cascade — no
* user-visible changes.
*/
import type { AnalysisResult } from '@/types/audioAnalysis';
import type { ChordGridData } from '@/hooks/scroll/useScrollAndAnimation';
// --- Tunables (moved out of the hook) ---------------------------------------
export const STABILITY_THRESHOLD = 2; // Frames a new beat must hold
export const HYSTERESIS_BUFFER = 0.05; // 50ms buffer around boundaries
export const PHASE_SWITCH_BUFFER = 0.03; // 30ms buffer between pre-beat and model phase
export const OFF_DWELL_SECONDS = 0.08; // 80ms dwell before turning highlight off
export const TIMING_TOLERANCE = 0.02; // 20ms tolerance for binary search
// --- Types ------------------------------------------------------------------
export interface HysteresisState {
lastStableBeat: number;
beatStabilityCounter: number;
lastEmittedBeat: number;
lastEmitTime: number;
prevTime: number;
}
export const INITIAL_HYSTERESIS_STATE: HysteresisState = {
lastStableBeat: -1,
beatStabilityCounter: 0,
lastEmittedBeat: -1,
lastEmitTime: 0,
prevTime: 0,
};
export interface ResolveBeatInput {
time: number;
chordGridData: ChordGridData;
analysisResults: AnalysisResult;
hysteresisState: HysteresisState;
globalSpeedAdjustment: number | null;
}
export interface ResolveBeatResult {
beatIndex: number; // -1 when nothing should be highlighted
downbeatIndex: number; // -1 when unknown
nextHysteresisState: HysteresisState;
nextGlobalSpeedAdjustment: number | null;
shouldSkipEmit: boolean; // Cascade decided this frame produces no update
}
// --- Internal helpers -------------------------------------------------------
/**
* Binary search for the current beat with hysteresis smoothing. Returns the
* updated hysteresis bookkeeping alongside the chosen beat index.
*/
function findCurrentBeatIndexWithHysteresis(
currentTime: number,
beats: (number | null)[],
state: HysteresisState,
): { beatIndex: number; lastStableBeat: number; beatStabilityCounter: number } {
let lastStableBeat = state.lastStableBeat;
let beatStabilityCounter = state.beatStabilityCounter;
if (!beats || beats.length === 0) {
return { beatIndex: -1, lastStableBeat, beatStabilityCounter };
}
const validBeats: { time: number; index: number }[] = [];
beats.forEach((beat, index) => {
if (typeof beat === 'number' && beat >= 0) {
validBeats.push({ time: beat, index });
}
});
if (validBeats.length === 0) {
return { beatIndex: -1, lastStableBeat, beatStabilityCounter };
}
let left = 0;
let right = validBeats.length - 1;
let candidateBeatIndex = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const beatTime = validBeats[mid].time;
const nextBeatTime = mid < validBeats.length - 1
? validBeats[mid + 1].time
: beatTime + 2.0;
const switchingPoint = beatTime + (nextBeatTime - beatTime) / 2;
if (currentTime >= beatTime && currentTime < nextBeatTime) {
const currentStableBeat = lastStableBeat;
const currentBeatIndex = validBeats[mid].index;
if (currentStableBeat === currentBeatIndex) {
candidateBeatIndex = currentBeatIndex;
} else if (currentTime < switchingPoint - HYSTERESIS_BUFFER) {
candidateBeatIndex = mid > 0 ? validBeats[mid - 1].index : currentBeatIndex;
} else if (currentTime > switchingPoint + HYSTERESIS_BUFFER) {
candidateBeatIndex = currentBeatIndex;
} else {
candidateBeatIndex = currentStableBeat !== -1 ? currentStableBeat : currentBeatIndex;
}
break;
} else if (currentTime < beatTime) {
right = mid - 1;
} else {
left = mid + 1;
}
}
// Stability gating
if (candidateBeatIndex === lastStableBeat) {
beatStabilityCounter = Math.min(beatStabilityCounter + 1, STABILITY_THRESHOLD);
return { beatIndex: candidateBeatIndex, lastStableBeat, beatStabilityCounter };
}
if (beatStabilityCounter >= STABILITY_THRESHOLD) {
lastStableBeat = candidateBeatIndex;
beatStabilityCounter = 1;
return { beatIndex: candidateBeatIndex, lastStableBeat, beatStabilityCounter };
}
beatStabilityCounter += 1;
return { beatIndex: lastStableBeat, lastStableBeat, beatStabilityCounter };
}
/**
* Binary search for the latest `originalAudioMapping` entry whose timestamp
* has already been reached. Returns -1 when nothing matches.
*/
function findCurrentAudioMappingIndex(
currentTime: number,
audioMapping: Array<{ timestamp: number; visualIndex: number }>,
): number {
if (!audioMapping || audioMapping.length === 0) return -1;
const adjustedTime = currentTime + TIMING_TOLERANCE;
let left = 0;
let right = audioMapping.length - 1;
let result = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const item = audioMapping[mid];
if (adjustedTime >= item.timestamp) {
result = item.visualIndex;
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
/**
* Binary search for the latest downbeat whose timestamp has already been
* reached. Returns -1 when nothing matches.
*/
export function findDownbeatIndexAtTime(
time: number,
downbeats: number[] | undefined,
): number {
if (!downbeats || downbeats.length === 0) return -1;
let left = 0;
let right = downbeats.length - 1;
let result = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const dbTime = downbeats[mid];
if (time >= dbTime) {
result = mid;
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
// --- Main cascade -----------------------------------------------------------
type ChordGridDataWithMapping = ChordGridData & {
originalAudioMapping: Array<{
timestamp: number;
chord: string;
visualIndex: number;
}>;
};
function hasOriginalAudioMapping(data: ChordGridData): data is ChordGridDataWithMapping {
return 'originalAudioMapping' in data
&& Array.isArray((data as ChordGridDataWithMapping).originalAudioMapping)
&& (data as ChordGridDataWithMapping).originalAudioMapping.length > 0;
}
export function resolveBeatAtTime(input: ResolveBeatInput): ResolveBeatResult {
const { time, chordGridData, analysisResults, globalSpeedAdjustment } = input;
const hysteresis = input.hysteresisState;
// Guard: empty grid => nothing to emit. Preserve prevTime for the rewind
// guard that lives in the hook.
if (!chordGridData || chordGridData.chords.length === 0) {
return {
beatIndex: -1,
downbeatIndex: -1,
nextHysteresisState: hysteresis,
nextGlobalSpeedAdjustment: globalSpeedAdjustment,
shouldSkipEmit: true,
};
}
// Compute animationRangeStart (first non-null beat time > 0)
let firstDetectedBeat = 0.0;
if (analysisResults.beats && analysisResults.beats.length > 0) {
for (const beat of analysisResults.beats) {
if (beat && beat.time !== undefined && beat.time > 0) {
firstDetectedBeat = beat.time;
break;
}
}
}
let animationRangeStart = firstDetectedBeat;
if (hasOriginalAudioMapping(chordGridData) && chordGridData.originalAudioMapping.length > 0) {
const firstMappedTime = chordGridData.originalAudioMapping[0].timestamp;
if (typeof firstMappedTime === 'number' && firstMappedTime >= 0) {
animationRangeStart = firstDetectedBeat > 0
? Math.min(firstDetectedBeat, firstMappedTime)
: firstMappedTime;
}
}
let currentBeat = -1;
let nextGlobalSpeedAdjustment: number | null = globalSpeedAdjustment;
let nextHysteresis: HysteresisState = hysteresis;
// --- PHASE 1: Pre-model context ------------------------------------------
if (time < animationRangeStart - PHASE_SWITCH_BUFFER) {
return {
beatIndex: -1,
downbeatIndex: -1,
nextHysteresisState: {
...nextHysteresis,
lastEmittedBeat: -1,
lastEmitTime: time,
},
nextGlobalSpeedAdjustment,
shouldSkipEmit: false,
};
}
// --- PHASE 2: Model beats ------------------------------------------------
if (time > animationRangeStart + PHASE_SWITCH_BUFFER) {
if (hasOriginalAudioMapping(chordGridData)) {
const animationBpm = analysisResults.beatDetectionResult?.bpm || 120;
const originalBeatDuration = Math.round((60 / animationBpm) * 1000) / 1000;
// Identify chord change segments in chord-model timeline
const chordChanges: Array<{
index: number;
chord: string;
timestamp: number;
chordModelTimestamp: number;
}> = [];
let lastChord = '';
chordGridData.originalAudioMapping.forEach((item, index) => {
if (item.chord !== lastChord) {
const chordModelTimestamp = item.timestamp - firstDetectedBeat;
if (chordModelTimestamp >= 0) {
chordChanges.push({
index,
chord: item.chord,
timestamp: item.timestamp,
chordModelTimestamp,
});
lastChord = item.chord;
}
}
});
// Compute globalSpeedAdjustment one time from the first segment pair
if (nextGlobalSpeedAdjustment === null && chordChanges.length >= 2) {
const firstSegment = chordChanges[0];
const secondSegment = chordChanges[1];
const actualDuration = secondSegment.chordModelTimestamp - firstSegment.chordModelTimestamp;
const expectedDuration = originalBeatDuration;
if (actualDuration > 0 && expectedDuration > 0) {
nextGlobalSpeedAdjustment = actualDuration / expectedDuration;
}
}
// Binary-search the audio mapping for the active visualIndex.
const audioMappingIndex = findCurrentAudioMappingIndex(time, chordGridData.originalAudioMapping);
if (audioMappingIndex !== -1) {
currentBeat = audioMappingIndex;
}
if (currentBeat === -1) {
// Fallback to hysteresis-based search over the chord grid beats
const search = findCurrentBeatIndexWithHysteresis(
time,
chordGridData.beats,
nextHysteresis,
);
currentBeat = search.beatIndex;
nextHysteresis = {
...nextHysteresis,
lastStableBeat: search.lastStableBeat,
beatStabilityCounter: search.beatStabilityCounter,
};
}
}
// When originalAudioMapping is missing we fall through with currentBeat === -1.
// Only mapped audio beats may drive the visual highlighter.
let finalBeatIndex = currentBeat;
let currentDownbeat = -1;
if (currentBeat !== -1) {
const chord = chordGridData.chords[currentBeat] || '';
const isEmptyCell = chord === '' || chord === 'undefined';
const isMappedAudioBeat = hasOriginalAudioMapping(chordGridData) &&
chordGridData.originalAudioMapping.some(item => item.visualIndex === currentBeat);
// The audio mapping is authoritative. In particular, a mapped N.C. cell
// is a real beat even when it occupies a nominal padding position. Empty
// cells remain layout-only even if an incomplete mapping references them.
if (!isMappedAudioBeat || isEmptyCell) {
finalBeatIndex = -1;
}
} else {
finalBeatIndex = -1;
}
// Downbeat lookup (linear scan preserved from original for parity)
if (analysisResults.downbeats && analysisResults.downbeats.length > 0) {
for (let i = 0; i < analysisResults.downbeats.length; i++) {
const downbeat = analysisResults.downbeats[i];
if (downbeat && downbeat <= time) {
currentDownbeat = i;
} else {
break;
}
}
}
// Stabilize emit
let stableFinalBeat = finalBeatIndex;
const lastEmitted = nextHysteresis.lastEmittedBeat;
const prevTime = nextHysteresis.prevTime;
const isRewinding = time + 1e-6 < prevTime;
if (!isRewinding && lastEmitted !== -1 && stableFinalBeat !== -1 && stableFinalBeat < lastEmitted) {
stableFinalBeat = lastEmitted;
}
if (stableFinalBeat === -1 && lastEmitted !== -1) {
if (time - nextHysteresis.lastEmitTime < OFF_DWELL_SECONDS) {
stableFinalBeat = lastEmitted;
}
}
return {
beatIndex: stableFinalBeat,
downbeatIndex: currentDownbeat,
nextHysteresisState: {
...nextHysteresis,
lastEmittedBeat: stableFinalBeat,
lastEmitTime: time,
},
nextGlobalSpeedAdjustment,
shouldSkipEmit: false,
};
}
// In the narrow PHASE_SWITCH_BUFFER window we intentionally emit nothing.
return {
beatIndex: -1,
downbeatIndex: -1,
nextHysteresisState: nextHysteresis,
nextGlobalSpeedAdjustment,
shouldSkipEmit: true,
};
}