Skip to content

Commit b2fc792

Browse files
fix(studio,core,cli): review hardening for beat detection + timeline UX
- playerStore.reset() now clears beat state (analysis, edits, undo/redo, persist) so a project switch can't apply the previous project's beats, undo stack, or file-writer to the new one. - removeUserBeat returns the same reference on a no-op, and delete/move beat actions skip committing when nothing changed — no more phantom undo entries / debounced writes for no-op edits. - regularizeBeats bails to raw onsets when the (octave-misread) tempo would produce a sub-125ms grid, avoiding a tens-of-thousands-of-beats freeze. - parseBeats clamps strength to [0,1] and rejects non-finite time/strength, so a hand-edited file can't feed NaN into the gamma curve (Math.pow on a negative base) and blank out beat markers. - Start-edge beat-snap now also requires duration >= minDuration, matching the end-edge guard, so a rightward snap can't collapse the clip. - Center-anchor zoom effect always consumes its skip flag, so a pinch that produced no pps change can't leave it stranded and skip the next zoom. - Headless beats analyzer projects to {beatTimes,beatStrengths,bpm,confidence} before returning, so page.evaluate no longer serializes the full decoded PCM (channelData) across the CDP boundary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent 31ee735 commit b2fc792

8 files changed

Lines changed: 72 additions & 28 deletions

File tree

packages/cli/src/beats/headlessAnalyzer.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,21 @@ function inPageAnalyze(data: string) {
8989
};
9090
if (typeof win.__hfAnalyze !== "function") throw new Error("beat analyzer not loaded");
9191
const ctx = new (win.AudioContext || win.webkitAudioContext!)();
92-
return ctx
93-
.decodeAudioData(bytes.buffer)
94-
.then((buf) => win.__hfAnalyze!(buf))
95-
.finally(() => ctx.close());
92+
return (
93+
ctx
94+
.decodeAudioData(bytes.buffer)
95+
.then((buf) => win.__hfAnalyze!(buf))
96+
// analyzeMusicFromBuffer also returns the decoded PCM (channelData) + sampleRate;
97+
// project to only the fields we need so page.evaluate doesn't serialize an
98+
// ~8-million-element Float32Array back across the CDP boundary.
99+
.then((r) => ({
100+
beatTimes: r.beatTimes,
101+
beatStrengths: r.beatStrengths,
102+
bpm: r.bpm,
103+
bpmConfidence: r.bpmConfidence,
104+
}))
105+
.finally(() => ctx.close())
106+
);
96107
}
97108

98109
// Load the analyzer bundle into the page, run analysis, and surface in-page

packages/core/src/beats/beatDetection.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ function octaveAlignBpm(bpm: number, reference: number): number {
144144
function regularizeBeats(rawBeats: number[], bpm: number, duration: number): number[] {
145145
if (rawBeats.length === 0 || bpm <= 0 || duration <= 0) return rawBeats;
146146
const beatInterval = 60 / bpm;
147+
// Guard against a pathological (octave-misread) tempo producing a millisecond
148+
// interval → tens of thousands of grid beats that freeze the timeline. 480 BPM
149+
// (0.125s) is well above any real music tempo; bail to the raw onsets instead.
150+
if (beatInterval < 0.125) return rawBeats;
147151
const threshold = beatInterval * 0.25;
148152

149153
// Find phase offset that maximally aligns with raw onsets

packages/core/src/beats/beatFile.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,13 @@ export function parseBeats(content: string): { times: number[]; strengths: numbe
6363
const times: number[] = [];
6464
const strengths: number[] = [];
6565
for (const b of data.beats) {
66-
if (b && typeof b.time === "number") {
66+
if (b && typeof b.time === "number" && Number.isFinite(b.time)) {
6767
times.push(b.time);
68-
strengths.push(typeof b.strength === "number" ? b.strength : 0.5);
68+
// Clamp to [0,1] — a hand-edited file could carry an out-of-range or
69+
// non-finite strength, and the renderers feed it into Math.pow(s, 2.2)
70+
// (NaN for a negative base).
71+
const s = typeof b.strength === "number" && Number.isFinite(b.strength) ? b.strength : 0.5;
72+
strengths.push(Math.max(0, Math.min(1, s)));
6973
}
7074
}
7175
return { times, strengths };

packages/studio/src/player/components/useTimelineClipDrag.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,11 +365,19 @@ export function useTimelineClipDrag({
365365
nextResize.playbackStart != null
366366
? nextResize.playbackStart / playbackRate
367367
: Number.POSITIVE_INFINITY;
368-
if (snapped !== nextResize.start && snapped >= 0 && delta <= maxLeftDelta + 1e-6) {
368+
// Also require the resulting duration to stay >= minDuration so a
369+
// rightward snap (delta < 0) can't collapse the clip to zero/negative.
370+
const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
371+
if (
372+
snapped !== nextResize.start &&
373+
snapped >= 0 &&
374+
delta <= maxLeftDelta + 1e-6 &&
375+
snappedDuration >= 0.05
376+
) {
369377
nextResize = {
370378
...nextResize,
371379
start: snapped,
372-
duration: Math.round((nextResize.duration + delta) * 1000) / 1000,
380+
duration: snappedDuration,
373381
playbackStart:
374382
nextResize.playbackStart != null
375383
? Math.round(

packages/studio/src/player/components/useTimelinePlayhead.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,12 @@ export function useTimelinePlayhead({
6464
const scroll = scrollRef.current;
6565
const prevPps = previousAnchorPpsRef.current;
6666
previousAnchorPpsRef.current = pps;
67-
if (!scroll || pps === prevPps) return;
68-
if (skipCenterAnchorRef.current) {
69-
skipCenterAnchorRef.current = false;
70-
return;
71-
}
67+
// Always consume the skip flag, even when pps didn't change — otherwise a
68+
// pinch that produced no pps change (already at the zoom clamp) would strand
69+
// it true and the next toolbar zoom would wrongly skip center-anchoring.
70+
const skip = skipCenterAnchorRef.current;
71+
skipCenterAnchorRef.current = false;
72+
if (!scroll || pps === prevPps || skip) return;
7273
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
7374
pointerX: scroll.clientWidth / 2,
7475
currentScrollLeft: scroll.scrollLeft,

packages/studio/src/player/store/playerStore.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,5 +338,12 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
338338
selectedKeyframes: new Set(),
339339
selectedElementIds: new Set(),
340340
keyframeCache: new Map(),
341+
// Beat state is project-specific — clear it so a project switch can't
342+
// apply the previous project's beats/undo/persist to the new one.
343+
beatAnalysis: null,
344+
beatEdits: null,
345+
beatUndo: [],
346+
beatRedo: [],
347+
beatPersist: null,
341348
}),
342349
}));

packages/studio/src/utils/beatEditActions.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,9 @@ export function deleteBeatAtCompositionTime(compT: number): void {
8787
const c = ctx();
8888
if (!c) return;
8989
const audioT = compToAudio(c.music.start, c.music.playbackStart ?? 0, compT);
90-
c.s.commitBeatEdits(
91-
removeUserBeat(c.s.beatEdits, c.src, c.analysis.beatTimes, audioT),
92-
"delete beat",
93-
);
90+
const next = removeUserBeat(c.s.beatEdits, c.src, c.analysis.beatTimes, audioT);
91+
// No-op when there was no beat to remove — skip the undo entry + write.
92+
if (next !== c.s.beatEdits) c.s.commitBeatEdits(next, "delete beat");
9493
}
9594

9695
export function moveBeatCompositionTime(fromCompT: number, toCompT: number): void {
@@ -101,11 +100,10 @@ export function moveBeatCompositionTime(fromCompT: number, toCompT: number): voi
101100
const toAudio = compToAudio(c.music.start, playbackStart, toCompT);
102101
const clamped = Math.max(playbackStart, Math.min(playbackStart + clipDuration(c.music), toAudio));
103102
const strength = strengthAtTime(c.analysis, clamped);
104-
c.s.commitBeatEdits(
105-
moveUserBeat(c.s.beatEdits, c.src, c.analysis.beatTimes, fromAudio, {
106-
time: clamped,
107-
strength,
108-
}),
109-
"move beat",
110-
);
103+
const next = moveUserBeat(c.s.beatEdits, c.src, c.analysis.beatTimes, fromAudio, {
104+
time: clamped,
105+
strength,
106+
});
107+
// No-op when the move resolves to no change — skip the undo entry + write.
108+
if (next !== c.s.beatEdits) c.s.commitBeatEdits(next, "move beat");
111109
}

packages/studio/src/utils/beatEditing.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,20 +92,31 @@ export function addUserBeat(
9292
return next;
9393
}
9494

95-
/** Remove the beat nearest `time` — drops a user-added beat or hides a detected one. */
95+
/**
96+
* Remove the beat nearest `time` — drops a user-added beat or hides a detected
97+
* one. Returns the SAME reference when nothing changed (no added beat near
98+
* `time`, and no live detected beat to hide) so callers can skip persisting a
99+
* phantom edit/undo/write.
100+
*/
96101
export function removeUserBeat(
97102
edits: BeatEditState | null,
98103
src: string,
99104
detectedTimes: number[],
100105
time: number,
101-
): BeatEditState {
106+
): BeatEditState | null {
107+
const active = activeEdits(edits, src);
108+
const hasAdded = (active?.added ?? []).some((b) => near(b.time, time));
109+
const detected = detectedTimes.find((t) => near(t, time));
110+
const alreadyHidden =
111+
detected !== undefined && (active?.removed ?? []).some((r) => near(r, detected));
112+
if (!hasAdded && (detected === undefined || alreadyHidden)) return edits;
113+
102114
const next = base(edits, src);
103115
const ai = next.added.findIndex((b) => near(b.time, time));
104116
if (ai >= 0) {
105117
next.added.splice(ai, 1);
106118
return next;
107119
}
108-
const detected = detectedTimes.find((t) => near(t, time));
109120
if (detected !== undefined && !next.removed.some((r) => near(r, detected))) {
110121
next.removed.push(detected);
111122
}
@@ -119,7 +130,7 @@ export function moveUserBeat(
119130
detectedTimes: number[],
120131
fromTime: number,
121132
toBeat: UserBeat,
122-
): BeatEditState {
133+
): BeatEditState | null {
123134
const removed = removeUserBeat(edits, src, detectedTimes, fromTime);
124135
return addUserBeat(removed, src, toBeat, detectedTimes) ?? removed;
125136
}

0 commit comments

Comments
 (0)