Skip to content

Highway jitter: the chart-clock interpolator manufactures the jitter it exists to prevent (backward frames, ~±40ms positional error) #957

Description

@Kilgannon2113

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.

Environment

  • feedBack desktop (AppImage, resources/slopsmith/), Linux/Wayland
  • RTX 4070 Ti, 200 Hz display
  • window._juceMode === false (HTML5-routed song — AudioEngine.h notes getBackingPosition() is "frozen for HTML5-routed (sloppak) songs")
  • 3D Highway (plugins/highway_3d)

Perf HUD during playback (localStorage.setItem('highwayPerfHud','1')):

fps 201  draw 0.8ms  scale 0.75 (user 0.75 / auto 1.00)

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.

Reproduction

Play a song, then run in the console:

(() => {
  const d = []; let prev = null, n = 0;
  (function tick() {
    const t = window.highway.getTime();
    if (prev !== null) d.push((t - prev) * 1000);
    prev = t;
    if (++n < 400) requestAnimationFrame(tick);
    else {
      const m = d.reduce((a,b)=>a+b,0)/d.length;
      const sd = Math.sqrt(d.reduce((a,b)=>a+(b-m)**2,0)/d.length);
      console.log('per-frame advance — mean', m.toFixed(2),
        '| min', Math.min(...d).toFixed(2), '| max', Math.max(...d).toFixed(2),
        '| stddev', sd.toFixed(2),
        '| BACKWARD frames:', d.filter(x=>x<0).length, '/', d.length);
    }
  })();
})();

At 200 Hz every frame should advance the chart clock a constant 5.00 ms. Observed:

mean 4.96 | min -52.42 | max 36.73 | stddev 11.99 | BACKWARD frames: 42 / 399

A second probe confirms the input side — hooking setTime() and measuring how stale the pushed sample is at draw time:

clock sample age at draw time — mean 8.46 | min 0.70 | max 18.10 ms

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):

const observed = (t - hwState._chartAnchorAudioT) / dPerf;
if (observed > 0.05 && observed < 5) {
    hwState._chartObservedRate = observed;
}
  • audio.currentTime advances in ~23 ms steps.
  • setTime() is polled at 60 Hz (16.7 ms) from app.js, and re-anchors only when t changes.
  • So the gap between anchors is either one tick (16.7 ms) or two (33.3 ms), while the audio delta is always ~23 ms.
  • Therefore observed = 23/16.7 ≈ 1.38 or 23/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 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, _adaptRenderScale Fix 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions