Five faults found while auditing the play/pause and position path for a desktop report of the pause icon showing over a seek bar that was not moving. They are one commit because they are one file's worth of tangled state, and two of them do not compile apart. The finished callback did not know which chain it came from. It is dispatched as a goroutine from the beep callback and then queues for p.mu, so a user pressing Next in the last second of a track had it wake up holding the lock for a player that had loaded something else -- and rewind it, stop it, and hand a stale finish to the queue's auto-advance. updateStreamers now stamps a chainID and the callback carries the one it was registered with. (#123) It also emitted PlaybackFinished and PlaybackStateChanged(stopped) *after* releasing p.mu, alone in this file, so a Play() taking the lock in that gap emitted `playing` first and the stale `stopped` landed last -- the button showing play over a track that was audibly running. Both emits are back under the lock. (#123) A source that failed mid-track was reported to the queue as a natural end, so a broken file auto-advanced in silence and was counted as played. The handler takes the reason now: the player cannot name the track, because the metadata is the queue's, so the queue emits PlaybackFailed and skips recording the play. (#123) p.format was assigned once, in the constructor, to the *speaker's* rate, and never again -- so it claimed 44.1 kHz for every file. The replay-after-finish path resamples from it, meaning a finished track played a second time was resampled from a rate the decoder never produced: audibly wrong speed and pitch, and the length and position fallbacks wrong with it. The fixtures are 22050 Hz, which is what lets a test see this at all. (#124) p.trackLengthMs was written only when the database had a row and cleared only by UnloadTrack, so a file with no row inherited the previous track's duration -- and every position report is scaled by it, so the bar reported one track's progress on another's scale. (#125) Queue.OnPlaybackFinished indexed q.tracks[currentIndex] having checked only that the queue was non-empty. currentIndex is -1 whenever the queue has been exhausted, and onQueueExhausted deliberately leaves the finished track loaded -- so playing it from there and letting it end panicked, on a goroutine with no caller to recover it. (#126) The position readers guarded the decoder with the speaker lock, which the read-ahead goroutine has no reason to hold and never takes -- so Position() raced readAhead's Stream() on every position emit, once a second for the whole of playback. srcMu is the lock that excludes that goroutine, and taking it naively deadlocks, because seekLocked already holds it and then emits the landing position from inside that region. seekSourceLocked is that region extracted, so the lock is released before anything is emitted. Found by the race detector, via the test added here for the chain guard: the existing suite never loads a file outside the integration guard, so make test was green over it. (#127) OnPlaybackFinished picks up //wails:ignore along with its error parameter: v3's generator segfaults on a bound method taking an error, and this was never IPC. That removes a binding the frontend could have called to force an auto-advance. Closes #123 Closes #124 Closes #125 Closes #126 Closes #127
79 lines
2.0 KiB
Go
79 lines
2.0 KiB
Go
package queue
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"yellowjacket/backend/events"
|
|
)
|
|
|
|
// errTestDecode stands in for a decoder blowing up mid-track.
|
|
var errTestDecode = errors.New("decode blew up")
|
|
|
|
// currentIndex == -1 against a non-empty queue is a state this
|
|
// package produces on purpose: onQueueExhausted(false) sets it and
|
|
// deliberately leaves the finished track loaded in the player, so it
|
|
// stays on the now-playing bar. Pressing play from there and letting
|
|
// it finish re-enters OnPlaybackFinished with exactly that pair --
|
|
// which used to index q.tracks[-1] and panic, on a goroutine
|
|
// dispatched from the audio callback with no caller to recover it.
|
|
func TestFinishedWithNoCurrentTrackDoesNotPanic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
index int
|
|
}{
|
|
{"exhausted queue leaves -1", -1},
|
|
{"index past the end", 3},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
q, _, _ := setupRecordedQueue(t)
|
|
q.tracks = []Track{
|
|
{FilePath: "/a.mp3"},
|
|
{FilePath: "/b.mp3"},
|
|
}
|
|
q.currentIndex = tt.index
|
|
|
|
// The assertion is that this returns at all.
|
|
q.OnPlaybackFinished(nil)
|
|
|
|
if q.currentIndex != tt.index {
|
|
t.Errorf(
|
|
"an out-of-range index was acted on: %d became %d",
|
|
tt.index, q.currentIndex,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A track that broke mid-playback is not a track that was listened
|
|
// to. The player cannot say so itself -- the metadata is here -- so
|
|
// it hands the reason over and this is where it becomes a
|
|
// PlaybackFailed rather than a silent auto-advance.
|
|
func TestAFailedTrackIsReportedAndNotCountedAsAPlay(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
q, _, rec := setupRecordedQueue(t)
|
|
q.tracks = []Track{
|
|
{FilePath: "/a.mp3", Title: "A", AudioFileID: 1},
|
|
{FilePath: "/b.mp3", Title: "B", AudioFileID: 2},
|
|
}
|
|
q.currentIndex = 0
|
|
|
|
q.OnPlaybackFinished(errTestDecode)
|
|
|
|
if _, ok := rec.Last(events.PlaybackFailed); !ok {
|
|
t.Errorf(
|
|
"a track that failed mid-playback told the user nothing; "+
|
|
"got %v",
|
|
rec.Names(),
|
|
)
|
|
}
|
|
}
|