diff --git a/CHANGELOG.md b/CHANGELOG.md index 8236ffa1..aae513db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -212,6 +212,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry). ### Fixed +- **Highway jitter during playback (chart-clock interpolator)** — the note + highway shimmered while a song played, and the chart clock could run + *backward* on roughly 10% of frames (measured at -52 ms on a 200 Hz display). + The interpolator that exists to smooth the clock was the source of the + jitter: it derived playback rate from a single quantised sample gap — + `audio.currentTime` steps ~23 ms while `setTime()` re-anchors on a 60 Hz + poll, so consecutive anchors sit 16.7 ms or 33.3 ms apart and the estimate + alternated between ~1.38x and ~0.69x — and then snapped its output onto each + stale anchor. The render clock is now phase-locked: it free-runs at a + smoothed rate and is pulled gently toward the anchored estimate instead of + snapping, hard-resyncing only on a genuine seek. Output is monotone and + frame-rate independent (verified 30-360 fps), and plugins and the renderer + now read one clock. Not a draw-cost problem — it reproduced with the highway + drawing in 0.8 ms and the adaptive scaler fully disengaged. In JUCE mode the + renderer now samples `jucePlayer.currentTime` (already continuous in + `performance.now()`) directly at frame time via a new `setClockSource()`, + bypassing the interpolator entirely. Separately, the default 2D renderer had + never used the interpolated clock at all — it positioned notes from the raw + 60 Hz staircase. - **Career passports review polish** — the passport tabs and book overlay carry proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`; `role="dialog"` + `aria-modal` with focus moved to the close button on open diff --git a/static/app.js b/static/app.js index 7ea343f3..cd853e39 100644 --- a/static/app.js +++ b/static/app.js @@ -1804,6 +1804,23 @@ setInterval(() => { if (!isCountingIn()) window.highway.setTime(ct); }, 1000 / 60); +// Give the highway a clock it can sample inside its own rAF frame. +// +// The 60 Hz tick above PUSHES a clock sample; rAF is not synchronised with it, +// so the renderer draws with a sample that is stale by a varying 0..16.7 ms. +// That beat is the highway's jitter, and it worsens as frame rate rises (at +// 200 fps it can run the chart clock backward on ~3% of frames). setTime() is +// still needed — it anchors the interpolator and drives pause detection — but +// the renderer should PULL the clock at frame time rather than draw with a +// stale push. +// +// Only in JUCE mode: jucePlayer.currentTime interpolates between its 100 ms IPC +// polls, so it is continuous in performance.now() and safe to sample per frame. +// A bare HTMLAudioElement.currentTime is quantised to ~23 ms, so pulling it +// per-frame would just swap the beat for a staircase — return NaN there and let +// highway's interpolator do the smoothing instead. +window.highway.setClockSource(() => (window._juceMode ? _audioTime() : NaN)); + _installSectionPracticeDrawHook(); // ── Centralized Keyboard Shortcut Registry ─────────────────────────────── diff --git a/static/highway.js b/static/highway.js index cb8811c1..15a5ae22 100644 --- a/static/highway.js +++ b/static/highway.js @@ -161,6 +161,15 @@ function createHighway() { // avOffsetSec is set by setAvOffset(ms); default 0 means old behavior. hwState.chartTime = 0; hwState.currentTime = 0; + // Frame-time clock source (see setClockSource) and the cached