You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Highway jitter: the chart-clock interpolator manufactures the jitter it exists to prevent (backward frames, ~±40ms positional error)
Summary
getTime()'s interpolator estimates playback rate from a single quantised sample gap, which makes the estimate alternate between ~1.38x and ~0.69x during steady 1x playback. It then snaps the output onto each new (stale, quantised) audio.currentTime anchor. Together these drive the chart clock backward on ~10% of frames, by as much as 50 ms.
The highway renders at the correct average rate — it never drifts or desyncs — but every frame is drawn at a position that is wrong by a randomly-varying amount. Visually this is a shimmer/vibration on the note highway during playback.
This is not a rendering-cost problem. On the machine below the highway draws in 0.8 ms with the adaptive scaler fully disengaged (auto 1.00), and the jitter is still plainly visible.
GPU, compositor and frame delivery are all healthy: rAF is vsync-locked to the 200 Hz display, draw cost is sub-millisecond, and _adaptRenderScale never engages.
The clamp (observed > 0.05 && observed < 5) passes both. getTime() then extrapolates at that rate for up to _CHART_MAX_INTERP_MS (100 ms), so the scroll speed swings ±38%, worth up to ±38 ms of positional error.
2. getTime() snaps to the anchor
getTime() returns _chartAnchorAudioT + rate * elapsed. At each re-anchor elapsed ≈ 0, so the output jumps onto the raw audio.currentTime sample — which is quantised and stale by up to a full tick. That is where the backward frames come from.
Fixing (1) alone does not help — simulated at 200 Hz:
CURRENT (instantaneous rate, snap to anchor):
stddev 4.42 | BACKWARD 33/1039
FIX A — smooth the rate only (EMA):
stddev 3.60 | BACKWARD 33/1039 <-- backward frames unchanged
FIX B — EMA rate + phase-locked output (never snap):
stddev 0.66 | BACKWARD 0/1039
Related: the default 2D renderer never used the interpolated clock at all
static/js/highway-draw.js positions notes from hwState.currentTime — the raw value written by setTime(). getTime()'s interpolation was only ever consumed by plugins (player-chrome.js, transport.js) and re-implemented locally by highway_3d as smoothNow(). The 2D renderer sees the raw 60 Hz staircase.
Why this survived
It's invisible to a profiler. Frames land inside budget — they just draw the wrong position. All existing instrumentation (perf HUD, _adaptRenderScaleFix v3 Songs List View favorite heart staying dim until re-search #654, highway-perf-baseline.spec.ts, the plugin "no per-frame DOM queries" rules) targets draw cost, which was never the constraint here.
It affects every frame rate. Simulated backward-frame counts: 37/419 at 60 fps, 51/841 at 120, 45/1399 at 200. Higher refresh means more frames spent extrapolating between clock samples, so the excursions get larger and the shimmer more legible — but 60 Hz users have it too.
Proposed fix
Free-run a continuous clock at a smoothed rate and pull it gently toward the anchored estimate (proportional correction), hard-resyncing only on a genuine seek. Never snap.
Measured on the affected machine, before and after:
before: mean 4.96 | min -52.42 | max 36.73 | stddev 11.99 | BACKWARD 42/399
after: mean 5.00 | min 2.60 | max 6.59 | stddev 0.77 | BACKWARD 0/399
Backward frames eliminated; jitter down ~15x to sub-pixel (≈0.3 px over the 3 s VISIBLE_SECONDS window, vs ~19 px lurches before). Mean is unchanged, so A/V sync is unaffected.
Frame-rate sweep of the fix (stddev in ms / backward frames):
fps
before
after
30
11.43 / 0
1.02 / 0
60
10.26 / 37
0.86 / 0
120
6.20 / 51
1.00 / 0
144
4.35 / 32
0.83 / 0
200
4.43 / 45
0.86 / 0
240
5.68 / 91
0.91 / 0
360
4.51 / 82
0.87 / 0
Residual error is flat across frame rates — the loop advances by rate * dt on real elapsed time, so it is frame-rate independent and tolerates dropped frames.
Patch attached / PR to follow. It:
adds a phase-locked render clock used by draw(), and returned by getTime() while the draw loop is live, so plugins and the renderer share one clock;
leaves the existing anchor/interpolator intact underneath, so the source-extraction sandbox in tests/js/highway_monotonic_clock.test.js still exercises it — 997/997 existing tests pass unchanged;
adds setClockSource(), used in JUCE mode to read jucePlayer.currentTime (already continuous in performance.now()) directly at frame time, bypassing the interpolator entirely.
Open questions for maintainers
_PLL_GAIN = 0.10 is applied per frame, so the loop's settling time in wall-clock terms scales with refresh (~170 ms at 60 fps, ~50 ms at 200 fps). A time-based gain (err * (1 - Math.exp(-dt / tau))) would make behaviour identical across rates. Happy to switch if preferred.
jucePlayer._startPolling() (static/js/transport.js) sets _pollAt = performance.now() on IPC response arrival, not when the backend sampled the position, so round-trip latency is baked in as error and re-anchored hard every 100 ms. Simulated at ~1.5 ms stddev — much smaller than the above, but it's a separate periodic ~10 Hz ripple in JUCE mode. Worth a follow-up; happy to file separately.
Highway jitter: the chart-clock interpolator manufactures the jitter it exists to prevent (backward frames, ~±40ms positional error)
Summary
getTime()'s interpolator estimates playback rate from a single quantised sample gap, which makes the estimate alternate between ~1.38x and ~0.69x during steady 1x playback. It then snaps the output onto each new (stale, quantised)audio.currentTimeanchor. Together these drive the chart clock backward on ~10% of frames, by as much as 50 ms.The highway renders at the correct average rate — it never drifts or desyncs — but every frame is drawn at a position that is wrong by a randomly-varying amount. Visually this is a shimmer/vibration on the note highway during playback.
This is not a rendering-cost problem. On the machine below the highway draws in 0.8 ms with the adaptive scaler fully disengaged (
auto 1.00), and the jitter is still plainly visible.Environment
resources/slopsmith/), Linux/Waylandwindow._juceMode === false(HTML5-routed song —AudioEngine.hnotesgetBackingPosition()is "frozen for HTML5-routed (sloppak) songs")plugins/highway_3d)Perf HUD during playback (
localStorage.setItem('highwayPerfHud','1')):GPU, compositor and frame delivery are all healthy: rAF is vsync-locked to the 200 Hz display, draw cost is sub-millisecond, and
_adaptRenderScalenever engages.Reproduction
Play a song, then run in the console:
At 200 Hz every frame should advance the chart clock a constant 5.00 ms. Observed:
A second probe confirms the input side — hooking
setTime()and measuring how stale the pushed sample is at draw time:Mean 8.46 ≈ 16.7/2 — the signature of a 60 Hz push being consumed by an unsynchronised render loop.
Root cause
Two defects compound, both in the chart-clock path.
1. The rate estimate is derived from one quantised sample gap
setTime()(static/highway.js):audio.currentTimeadvances in ~23 ms steps.setTime()is polled at 60 Hz (16.7 ms) fromapp.js, and re-anchors only whentchanges.observed=23/16.7 ≈ 1.38or23/33.3 ≈ 0.69, alternating.The clamp
(observed > 0.05 && observed < 5)passes both.getTime()then extrapolates at that rate for up to_CHART_MAX_INTERP_MS(100 ms), so the scroll speed swings ±38%, worth up to ±38 ms of positional error.2.
getTime()snaps to the anchorgetTime()returns_chartAnchorAudioT + rate * elapsed. At each re-anchorelapsed ≈ 0, so the output jumps onto the rawaudio.currentTimesample — which is quantised and stale by up to a full tick. That is where the backward frames come from.Fixing (1) alone does not help — simulated at 200 Hz:
Related: the default 2D renderer never used the interpolated clock at all
static/js/highway-draw.jspositions notes fromhwState.currentTime— the raw value written bysetTime().getTime()'s interpolation was only ever consumed by plugins (player-chrome.js,transport.js) and re-implemented locally byhighway_3dassmoothNow(). The 2D renderer sees the raw 60 Hz staircase.Why this survived
_adaptRenderScaleFix v3 Songs List View favorite heart staying dim until re-search #654,highway-perf-baseline.spec.ts, the plugin "no per-frame DOM queries" rules) targets draw cost, which was never the constraint here.Proposed fix
Free-run a continuous clock at a smoothed rate and pull it gently toward the anchored estimate (proportional correction), hard-resyncing only on a genuine seek. Never snap.
Measured on the affected machine, before and after:
Backward frames eliminated; jitter down ~15x to sub-pixel (≈0.3 px over the 3 s
VISIBLE_SECONDSwindow, vs ~19 px lurches before). Mean is unchanged, so A/V sync is unaffected.Frame-rate sweep of the fix (stddev in ms / backward frames):
Residual error is flat across frame rates — the loop advances by
rate * dton real elapsed time, so it is frame-rate independent and tolerates dropped frames.Patch attached / PR to follow. It:
draw(), and returned bygetTime()while the draw loop is live, so plugins and the renderer share one clock;tests/js/highway_monotonic_clock.test.jsstill exercises it — 997/997 existing tests pass unchanged;setClockSource(), used in JUCE mode to readjucePlayer.currentTime(already continuous inperformance.now()) directly at frame time, bypassing the interpolator entirely.Open questions for maintainers
_PLL_GAIN = 0.10is applied per frame, so the loop's settling time in wall-clock terms scales with refresh (~170 ms at 60 fps, ~50 ms at 200 fps). A time-based gain (err * (1 - Math.exp(-dt / tau))) would make behaviour identical across rates. Happy to switch if preferred.jucePlayer._startPolling()(static/js/transport.js) sets_pollAt = performance.now()on IPC response arrival, not when the backend sampled the position, so round-trip latency is baked in as error and re-anchored hard every 100 ms. Simulated at ~1.5 ms stddev — much smaller than the above, but it's a separate periodic ~10 Hz ripple in JUCE mode. Worth a follow-up; happy to file separately.