p.format is never updated on load: replaying a finished 48 kHz track resamples 44100 to 44100 #124

Closed
opened 2026-08-19 12:38:15 +00:00 by yonlu · 1 comment
Owner

Findings

Player.format is assigned exactly once, in the constructor, to the speaker's sample rate (backend/player/player.go:151-155):

state:        Stopped,
baseStreamer: generators.Silence(-1),
format: beep.Format{
	SampleRate: speakerSampleRate,
},

loadFileLocked decodes the file and gets a real beep.Format back from metadata.DecodeFile, then passes only format.SampleRate to updateStreamers and drops the rest on the floorp.format is never updated. So p.format.SampleRate is 44100 for the life of the process regardless of what is playing. Three call sites read it:

  • player.go:658 — the replay path, and this one is audible. When a track has finished naturally and is played again, Play() rebuilds the chain with p.updateStreamers(p.seeker, p.format.SampleRate), i.e. it declares the decoder's output to be 44100 Hz. For a 48 kHz file the first load resampled 48000→44100 correctly (the rate came from the decoder), and the replay resamples 44100→44100 — a no-op. The track plays back at the wrong speed and pitch, and its position/length arithmetic goes with it. Press play on a finished 48 kHz track and you hear it.
  • player.go:1144trackLengthLocked's fallback, p.seeker.Len() / int(p.format.SampleRate), used whenever the database has no duration for the track (an unscanned or unmatched file). Wrong duration for anything not 44.1 kHz.
  • player.go:1170displayPositionSecsLocked's fallback, same division, same condition.

Direction

Assign p.format = format in loadFileLocked alongside p.currentFile = f, and take the replay path's rate from it. A test that loads a 48 kHz fixture and asserts trackLengthLocked against the known duration covers all three, and internal/testfixtures can select by case.

**Findings** `Player.format` is assigned exactly once, in the constructor, to the *speaker's* sample rate (`backend/player/player.go:151-155`): ```go state: Stopped, baseStreamer: generators.Silence(-1), format: beep.Format{ SampleRate: speakerSampleRate, }, ``` `loadFileLocked` decodes the file and gets a real `beep.Format` back from `metadata.DecodeFile`, then passes only `format.SampleRate` to `updateStreamers` and **drops the rest on the floor** — `p.format` is never updated. So `p.format.SampleRate` is 44100 for the life of the process regardless of what is playing. Three call sites read it: - **`player.go:658` — the replay path, and this one is audible.** When a track has finished naturally and is played again, `Play()` rebuilds the chain with `p.updateStreamers(p.seeker, p.format.SampleRate)`, i.e. it declares the decoder's output to be 44100 Hz. For a 48 kHz file the first load resampled 48000→44100 correctly (the rate came from the decoder), and the replay resamples 44100→44100 — a no-op. **The track plays back at the wrong speed and pitch**, and its position/length arithmetic goes with it. Press play on a finished 48 kHz track and you hear it. - **`player.go:1144`** — `trackLengthLocked`'s fallback, `p.seeker.Len() / int(p.format.SampleRate)`, used whenever the database has no duration for the track (an unscanned or unmatched file). Wrong duration for anything not 44.1 kHz. - **`player.go:1170`** — `displayPositionSecsLocked`'s fallback, same division, same condition. **Direction** Assign `p.format = format` in `loadFileLocked` alongside `p.currentFile = f`, and take the replay path's rate from it. A test that loads a 48 kHz fixture and asserts `trackLengthLocked` against the known duration covers all three, and `internal/testfixtures` can select by case.
yonlu self-assigned this 2026-08-19 12:39:07 +00:00
yonlu added the
Status
In Progress
label 2026-08-19 12:39:07 +00:00
Author
Owner

Working this as part of a batch off fix/player-playing-state (#122, #123, #124, #125, #126) — all five are in backend/player and backend/queue, and #124 and #125 are the two halves of the same fallback expression, so splitting them across branches would mean three passes over the same twenty lines.

Approach:

  • #122done = true on every readAhead exit; bound the underrun fill and return 0, false past it; give the finished path a way to tell "drained" from "failed" so PlaybackFailed is emitted instead of a silent auto-advance.
  • #123 — move the two emits back under p.mu, and capture the chain's trackChangeID in the beep.Callback closure so a stale callback returns without touching the player.
  • #124/#125 — assign p.format in loadFileLocked, reset p.trackLengthMs there, take the replay path's rate from p.format.
  • #126 — bounds-check currentIndex the way loadCurrentTrack already does.

Verified with make test (all three build configurations) plus new unit tests per exit path; the audible half of #124 needs a 48 kHz fixture.

Working this as part of a batch off `fix/player-playing-state` (#122, #123, #124, #125, #126) — all five are in `backend/player` and `backend/queue`, and #124 and #125 are the two halves of the same fallback expression, so splitting them across branches would mean three passes over the same twenty lines. Approach: - **#122** — `done = true` on every `readAhead` exit; bound the underrun fill and return `0, false` past it; give the finished path a way to tell "drained" from "failed" so `PlaybackFailed` is emitted instead of a silent auto-advance. - **#123** — move the two emits back under `p.mu`, and capture the chain's `trackChangeID` in the `beep.Callback` closure so a stale callback returns without touching the player. - **#124/#125** — assign `p.format` in `loadFileLocked`, reset `p.trackLengthMs` there, take the replay path's rate from `p.format`. - **#126** — bounds-check `currentIndex` the way `loadCurrentTrack` already does. Verified with `make test` (all three build configurations) plus new unit tests per exit path; the audible half of #124 needs a 48 kHz fixture.
logan closed this issue 2026-08-19 14:53:46 +00:00
gitea-actions bot removed the
Status
In Progress
label 2026-08-19 14:54:50 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: yonlu/yellowjacket#124