Playback time compression by scheduling cursor steps#128
Conversation
…`currentTimeStamp`
|
Independently reproduced and fixed the same bug in a Next.js 16 + OSMD app — your diagnosis is spot-on. Repro: Tchaikovsky's Nutcracker (piano arrangement, 353 measures in 3/4 at 146 BPM) finished in ~27 s instead of the expected ~435 s — a ~16× compression. The piece has dense polyphony with mismatched voice lengths and triplet ornaments, which is exactly the failure pattern you describe. Confirmation of root cause: Fix in our app: rather than patch the dependency, we rebuild Also worth pairing with #129 (tickDenominator=60480) — that eliminates the float-tick class entirely, so the integer rounding becomes a no-op rather than a workaround. Hopefully this helps confirm the fix for any reviewer. Happy to share the external-rebuild snippet if useful for anyone who can't fork the package. |
Playback time compression bug (OSMD Cursor–based scheduling)
IMPORTANT DISCLAIMER
This fix was:
PR overview
timeStampRealValueBug overview
When building an audio playback timeline from OpenSheetMusicDisplay (OSMD) cursor iterations, it’s possible to unintentionally compress the musical timeline, causing playback to run far faster than intended (e.g., a multi‑minute piece finishing in a few seconds). This presents as an apparent “tempo increase”, but the root issue is incorrect event timestamping, not an actual BPM change.
This document describes the bug pattern and a fix that makes scheduling stable across dense notation (tuplets, very short notes, etc.) by using OSMD’s absolute cursor timestamp.
Symptoms
Root cause
Problem: “first empty tick” scheduling compresses time
A common approach to building a playback queue from cursor steps is:
This heuristic assumes that:
That assumption fails for real-world scores because:
The result is an internal timeline where later measures are scheduled much earlier than they should be, producing “minutes in seconds”.
Key observation
OSMD already exposes an absolute position timestamp for the cursor iterator:
iterator.currentTimeStamp.realValue(or variant casing depending on build)This timestamp is expressed in whole-note units (fractional), and it represents the correct global position in the score timeline for the current cursor location.
Ignoring this and relying on “first empty tick” is the core bug.
Fix
Use OSMD’s absolute cursor timestamp as the scheduler’s time coordinate
Instead of deriving the next scheduling tick from the queue contents, compute the tick directly:
tick = offset + round(iteratorTimeStampRealValue * tickDenominator)Where:
iteratorTimeStampRealValueis the cursor iterator’s absolute timestamp in whole-note unitstickDenominatoris the scheduler’s ticks-per-whole-note constant (e.g., 1024)offsetis any initial padding/hack (optional) used for better first-note alignmentThis makes each cursor step’s start time deterministic and proportional to the true score timeline.
Avoid generating “empty end steps” when using absolute timestamps
When the scheduler is timestamp-driven:
Implementation notes (library-agnostic)
currentTimeStampis unavailable (orrealValuecannot be read), fall back to the old heuristic to avoid breaking older OSMD builds.CurrentTimeStampvscurrentTimeStamp,RealValuevsrealValue). A robust implementation should try both.Why this fixes the “fast forever” symptom
With absolute timestamps: