Skip to content

Show a track change in about a second instead of eleven - #153

Open
edward-rosado wants to merge 5 commits into
ItsRiprod:mainfrom
edward-rosado:fix/track-change-latency
Open

Show a track change in about a second instead of eleven#153
edward-rosado wants to merge 5 commits into
ItsRiprod:mainfrom
edward-rosado:fix/track-change-latency

Conversation

@edward-rosado

Copy link
Copy Markdown
Contributor

A track change took a median of ~11s to reach the screen, worst case 17.8s, measured across 25 natural transitions. After this it is ~0.5s at a track boundary and ~0.8s on a skip.

Nothing here is Car Thing or Bluetooth specific — it is the music path in the server, so any client benefits.

The measurements

track_progress at the moment the server first reports a new track is the lateness, since a track starts at zero. Across 25 natural transitions on the old code:

min 294ms | median ~11.2s | p90 ~16s | max 17.8s

That distribution — near-uniform across 0–15s — is the signature of a free-running poll, and music_refreshInterval is 15000.

After, on the same setup:

track boundary   0.51s
skip (8 runs)    0.74 0.75 0.76 0.76 0.77 0.78 0.81 0.83   (seconds)

Why the poll was doing all the work

There is already a track-boundary detector: songCache arms a timer for the end of each track and MusicService refreshes on it. In the shipped code it is structurally unable to succeed.

  • It fires at the boundary, a beat before the provider reports the advance, so the answer is the track that just finished, the change gate sees nothing new, and nothing is emitted.
  • It then called clear(), dropping the cached song and cancelling every timer — so nothing retried, and a client connecting in that gap had nothing to show at all.
  • song.track_progress && treats progress 0 as absent, so a track first seen at its start — the one that most needs a timer — never got one.
  • A parallel interval fired the same event a second time.

So the timer either lost the race or was never armed, and the 15s poll picked up the pieces a cycle later.

What changed

The boundary event is now correct. It fires once, shortly after the track runs out, and leaves the cache intact.

The refresh is chased, not fired once. A short bounded ladder (600/900/1200/1800/2500ms) stops the moment the track is genuinely different. A progress collapse counts as a change too, so repeat-one does not run it to exhaustion, and it bails when playback has stopped. A skip takes the same path, because it loses the same race.

Two bugs found in the chase itself while measuring on hardware, both worth calling out because they were invisible in isolation:

  1. It checked the cache immediately after sending the refresh. But the request is only handed to the source app; the answer arrives later on a separate message. So every attempt read the track being left, the chase concluded it had failed, and the update fell back to the poll. This is why a skip was 1.4s when the timing happened to work out and 14.7s when it did not — the variance was the tell.
  2. Guarding with an "already chasing" flag discarded a second skip and wedged permanently if a chase ever failed to finish, silently disabling every chase for the life of the process. A generation counter replaces it: a newer chase supersedes an older one, and there is no state to get stuck in.

A track that is merely playing is no longer treated as a track that changed. Progress advancing rebuilt the end-of-track timer on every poll and fired a redundant SONG_CHANGED broadcast each time — which also sent the raw payload as a second, worse copy of an update handleMusicPayload had already broadcast with its thumbnail rewritten to a URL the client can fetch. Only a different track, a play/pause flip, or a real seek counts now.

The poll interval is deliberately unchanged

Shortening it is the obvious move and I think it is the wrong one. Once the boundary is scheduled properly the poll only has to catch changes that have no predictable boundary — a pause, a seek, or a skip from another device. Dropping it to 2s would be a ~7× traffic multiplier on /me/player to improve only that residual case, and it would not fix the tail: a swallowed poll still costs a full cycle.

Tests

18, covering the change detection and the boundary scheduling, including every regression above. The chase tests drive a source app that answers asynchronously, which is the arrangement the original code got wrong — a synchronous mock passes against the broken version.

One test deserves a note: seek detection compares against the cached progress rather than the last polled value. That looks wrong, because an ordinary poll advances progress by far more than the tolerance. It is correct because the progress interval advances the cached value in real time, so it already means "where the track should be by now". A test with a 15s gap between polls pins this down — it fails under the plausible-looking alternative.

Also included

tools/watch-music.js — connects as an ordinary read-only websocket client and reports poll cadence, duplicate sends, and how far into a track the update announcing it arrives. Every number above came from it. It needs no dependencies beyond the ws the server already ships.

Known, not addressed here

While measuring I saw intermittent 403s from /me/player and persistent 403s from /me/tracks/contains. makeRequest re-authenticates and retries on 401 but not on 403. I have deliberately not changed that — a blind retry on 403 is a good way to earn a rate limit, and the right fix depends on why the token is being refused. Flagging it as a separate issue.

🤖 Generated with Claude Code

edward-rosado and others added 4 commits August 5, 2026 21:53
A track change took a median of ~11s to reach the screen, with a worst
case of 17.8s across 25 measured transitions. The distribution is the
signature of a free-running 15s poll, and the poll was indeed doing
nearly all the detecting — but not because nothing else tried.

songCache already armed a timer for the end of each track and
MusicService already refreshed on it. That path was structurally unable
to succeed:

  - it fired at the exact boundary, a beat before the provider reports
    the new track, so the answer was the track that just finished;
  - it then called clear(), dropping the cached song and cancelling
    every timer, so nothing retried and a client connecting in the gap
    had nothing to show;
  - `song.track_progress &&` treated progress 0 as absent, so a track
    first seen at its start — the one that most needed the timer — never
    got one;
  - and an interval fired the same event a second time.

So the timer either lost the race or never existed, and the poll picked
up the pieces one cycle later.

The end-of-track event now fires once, shortly after the track actually
runs out, and leaves the cache intact. MusicService chases the change
with a short bounded ladder (immediate, +1.2s, +2.5s) that stops as soon
as the track is genuinely different, and treats a progress collapse as a
change too so repeat-one does not run it to exhaustion. A skip takes the
same path, since it loses the same race.

A track that is merely playing is no longer treated as a track that
changed. Progress advancing rebuilt the end-of-track timer on every poll
and fired a redundant SONG_CHANGED broadcast each time — which also sent
the raw payload as a second, worse copy of an update handleMusicPayload
had already broadcast with its thumbnail rewritten. Only a different
track, a play/pause flip, or a seek counts now.

The poll interval is deliberately unchanged. Once the boundary is
scheduled properly the poll only has to catch changes with no
predictable boundary — a pause, a seek, or a skip from another device —
and shortening it would multiply traffic against an account this branch
already exists to keep under a rate limit.

tools/watch-music.js measures this from the server's own websocket:
poll cadence, duplicate sends, and how far into a track the update
announcing it arrives.

12 tests cover the change detection and the boundary scheduling,
including each regression above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comparison looks wrong at a glance: an ordinary poll advances
progress by a whole poll period, which is far more than the seek
tolerance, so it reads as though every poll would be treated as a seek.

It isn't, because the progress interval advances the cached value in
real time — the cached number is already "where the track should be by
now", not "where it was at the last poll". Comparing against the last
polled value instead would double-count the elapsed time and rearm the
end-of-track timer on every single poll.

Test added for a 15s gap between polls, which fails under that mistake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
refreshMusicData only hands a request to the source app. The song comes
back later, on a separate message, and updates the cache then. The chase
checked the cache immediately after sending, so it read the track it was
trying to leave every time, concluded nothing had changed, and gave up —
leaving the update to the next scheduled poll.

Measured against the running build: a skip took 1.38s when the timing
happened to work out and 14.73s when it didn't, which matches the ~13s
reported from the device. The variance was the tell.

Each attempt now waits for its answer before looking, and the delays are
the wait after each request rather than the pause before it: 600/900/
1200/1800/2500ms, about a seven second window. The chase still stops the
moment the track is different, so the usual cost is one request.

Concurrent chases are collapsed. A double-press used to start a second
ladder on top of the first and double the requests for one answer.

4 tests, driven through a source app that answers asynchronously like the
real one — the arrangement the old code got wrong. They assert the new
track actually reaches the broadcast, that the ladder stays bounded when
the provider never advances, and that two skips do not stack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Guarding the chase with an "already chasing" flag was wrong in two ways.
A second skip arriving mid-chase was discarded, even though it is
chasing a different change from a different starting track — so it fell
through to the scheduled poll. And if a chase ever failed to finish, the
flag stayed set and silently disabled every future chase for the life of
the process.

The second failure is what showed on the device: after a while the log
contains no chase requests at all, only the scheduled polls exactly 15s
apart, and every skip costs a full cycle no matter how the retry timings
are tuned.

A generation counter replaces it. A newer chase supersedes an older one
at its next checkpoint, there is no state to get stuck in, and each
refresh is raced against a timeout so one unsettled send cannot stall
the ladder behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A track that ends on its own, and a skip made from a connected client,
are both handled the moment they happen: one is predicted from the
track's own duration, the other is observed as a command. Neither needs
the poll.

That leaves the case with no boundary to predict and no command to
observe — playback changed in the provider's own app, on a phone or a
desktop. Nothing announces it, so the only way to notice is to look, and
at the configured 15s a change made elsewhere still took up to 15s to
reach the screen. After the boundary work this was the entire remaining
delay, and it is the one users are most likely to hit.

The poll now schedules itself one tick at a time and picks the delay
from what is actually happening: about 2s while a client is connected
and something is playing, the configured rate otherwise. Idle or paused
it costs exactly what it did before, because a poll nobody can see buys
nothing and the currency here is a provider rate limit.

It never polls faster than the configured rate, so someone who
deliberately set a slow cadence still gets it.

Client traffic does not increase. The source only reports genuine state
changes, and at a 2s cadence an ordinary progress advance falls inside
its own tolerance — so a faster poll sends *fewer* redundant updates over
the link, not more.

Scheduling one tick at a time also means a slow poll can no longer stack
requests on top of itself, which a fixed interval allowed.

4 tests: fast while watched and playing, configured rate when nothing is
connected, configured rate while paused, and never faster than
configured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@edward-rosado

Copy link
Copy Markdown
Contributor Author

Pushed one more commit, which closes the case the rest of this PR does not.

The boundary scheduling and the chase cover a track that ends on its own, and a skip made from a connected client. Both are handled the moment they happen — one is predicted from the track's own duration, the other is observed as a command. Neither needs the poll.

That leaves the case with no boundary to predict and no command to observe: playback changed in Spotify's own app, on a phone or a desktop. Nothing announces that, so the only way to notice is to look — and at the configured 15s it still took up to 15s to reach the screen. Testing on hardware, this turned out to be the case a user is most likely to hit, and after the boundary work it was the entire remaining delay.

The poll now schedules itself one tick at a time and picks its delay from what is actually happening: about 2s while a client is connected and something is playing, the configured rate otherwise.

I want to be straight about this, because shortening a poll is usually the lazy answer and I argued against it earlier in this PR. What changed my mind is that the cost is not what it looks like:

  • Client traffic goes down, not up. The source only reports genuine state changes, and at a 2s cadence an ordinary progress advance falls inside its own 3000ms tolerance — so a faster poll emits fewer redundant updates, not more. On a 155 KB/s Bluetooth link that matters, and it measured as fewer duplicate sends.
  • It is off whenever it cannot help. No client connected, or playback paused, and it costs exactly what it did before. A poll nobody can see buys nothing, and the currency is a provider rate limit.
  • It never overrides a slower configured rate, so someone who deliberately set one still gets it.

Scheduling one tick at a time also fixes something the fixed interval allowed: a slow poll could stack requests on top of itself.

Measured on hardware, changing tracks from Spotify on a Mac while the Car Thing displayed it over Bluetooth:

0.13s   0.92s   1.73s   2.18s     mean 1.24s

Before this commit that same path was a flat 0-15s.

Full picture for this PR, all measured on the same setup:

before after
track ends naturally median ~11s, max 17.8s 0.51s
skip from the device ~13-15s 0.74-0.83s (8 runs)
changed from Spotify elsewhere 0-15s 1.24s mean

22 tests now. The four new ones cover the cadence: fast while watched and playing, configured rate when nothing is connected, configured rate while paused, and never faster than configured.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant