From 3d7640c8c973d7f13d20be9a1e0baff5043efa63 Mon Sep 17 00:00:00 2001 From: gionnibgud Date: Fri, 17 Jul 2026 01:56:20 +0200 Subject: [PATCH] fix(cursor): advance on bass-only beats; make final beat reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _svBuildBeatTimeline walked only the first staff of the first track (breaking out of the loop right after staff 0), so any beat that only existed on the bass staff was absent from the tick index used for cursor sync — the cursor would stall or skip during left-hand-only passages. It now collects beats from all staves of the first track, sorts, and dedupes by tick, keeping the first (treble) entry when both staves share a position. _svSyncCursor's binary search bounded hi at beats.length - 2, which made the very last beat of a song unreachable by the search. Bound is now beats.length - 1. Co-Authored-By: Claude Sonnet Signed-off-by: gionnibgud --- CHANGELOG.md | 16 ++++++++++++++++ screen.js | 27 +++++++++++++++++++-------- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfc211..6eb08fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [Unreleased] + +### Fixed + +- **Cursor now advances on bass-only (left-hand) beats** — `_svBuildBeatTimeline` + previously walked only the first staff of the first track, so beats that + only existed on the bass staff were missing from the tick index and the + playback cursor would stall or skip during left-hand-only passages. It now + collects beats from all staves of the first track and dedupes by tick + (treble preferred at shared positions). +- **Final beat of a song is now reachable** — `_svSyncCursor`'s binary search + capped `hi` at `beats.length - 2`, making the last beat unreachable by the + search; it's now `beats.length - 1`. + +--- + ## [0.3.0] — 2026-07-08 ### Added diff --git a/screen.js b/screen.js index 4151508..a151fe5 100644 --- a/screen.js +++ b/screen.js @@ -3201,15 +3201,16 @@ function createFactory() { } // ── Beat timeline (tick → Beat lookup) ──────────────────────── - // Walks score.tracks[0].staves[0] only — the first staff of the - // first track is sufficient to build the absolute-tick index used - // for cursor sync. + // Collects beats from ALL staves of the first track so bass-only + // positions aren't skipped during cursor lookup, then dedupes by + // tick (treble preferred at shared positions). function _svBuildBeatTimeline(score) { const out = []; try { - trackLoop: for (const track of (score.tracks || [])) { - for (const staff of (track.staves || [])) { + const tracks = score.tracks || []; + if (tracks.length) { + for (const staff of (tracks[0].staves || [])) { for (const bar of (staff.bars || [])) { for (const voice of (bar.voices || [])) { for (const beat of (voice.beats || [])) { @@ -3222,12 +3223,22 @@ function createFactory() { } } } - break trackLoop; // first staff per track is sufficient; also exits trackLoop } } } catch (_) {} out.sort((a, b) => a.start - b.start); - return out; + // Dedupe by tick: keep the first entry at each start tick so + // treble beats win when both staves share the same position + // (staff 0's beats are pushed first and sort is stable). + const deduped = []; + let prevStart = -1; + for (const entry of out) { + if (entry.start !== prevStart) { + deduped.push(entry); + prevStart = entry.start; + } + } + return deduped; } function _svFindBeatAtTick(tick) { @@ -3287,7 +3298,7 @@ function createFactory() { // Binary search: largest i where beats[i].time <= currentTime. if (currentTime < beats[0].time) return; - let lo = 0, hi = beats.length - 2, idx = 0; + let lo = 0, hi = beats.length - 1, idx = 0; while (lo <= hi) { const mid = (lo + hi) >> 1; if (beats[mid].time <= currentTime) { idx = mid; lo = mid + 1; }