527 lines
12 KiB
Go
527 lines
12 KiB
Go
package player
|
|
|
|
import (
|
|
"bufio"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gopxl/beep/v2"
|
|
"github.com/gopxl/beep/v2/speaker"
|
|
)
|
|
|
|
// newTestPlayer returns a Player with only the atomic accessors wired up.
|
|
// We deliberately skip speaker.Init (which would open a real audio device)
|
|
// by constructing the struct directly — this limits us to testing the
|
|
// lock-free getters/setters, not the audio pipeline itself.
|
|
func newTestPlayer() *Player {
|
|
p := &Player{}
|
|
p.volMin.Store(math.Float64bits(-50))
|
|
p.speed.Store(math.Float64bits(1.0))
|
|
return p
|
|
}
|
|
|
|
func TestSetVolumeClamps(t *testing.T) {
|
|
p := newTestPlayer()
|
|
|
|
tests := []struct {
|
|
in float64
|
|
want float64
|
|
}{
|
|
{-60, -50}, // below min
|
|
{-50, -50},
|
|
{0, 0},
|
|
{6, 6},
|
|
{12, 6}, // above max
|
|
}
|
|
for _, tt := range tests {
|
|
p.SetVolume(tt.in)
|
|
if got := p.Volume(); got != tt.want {
|
|
t.Errorf("SetVolume(%v) → Volume() = %v, want %v", tt.in, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetVolumeMinClamps(t *testing.T) {
|
|
p := newTestPlayer()
|
|
|
|
tests := []struct {
|
|
in float64
|
|
want float64
|
|
}{
|
|
{-100, -90}, // below absolute floor
|
|
{-90, -90},
|
|
{-50, -50},
|
|
{0, 0},
|
|
{5, 0}, // above max (must be ≤ 0)
|
|
}
|
|
for _, tt := range tests {
|
|
p.SetVolumeMin(tt.in)
|
|
if got := p.VolumeMin(); got != tt.want {
|
|
t.Errorf("SetVolumeMin(%v) → VolumeMin() = %v, want %v", tt.in, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetVolumeMinReconcilesCurrent(t *testing.T) {
|
|
p := newTestPlayer()
|
|
p.SetVolume(-40)
|
|
p.SetVolumeMin(-30) // raise floor above current volume
|
|
if got := p.Volume(); got != -30 {
|
|
t.Errorf("Volume after SetVolumeMin raise = %v, want -30", got)
|
|
}
|
|
}
|
|
|
|
func TestVolumeClampedToCustomMin(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
volumeMin float64
|
|
volume float64
|
|
want float64
|
|
}{
|
|
{"below floor", -30, -40, -30},
|
|
{"at floor", -30, -30, -30},
|
|
{"above floor", -30, -10, -10},
|
|
{"custom floor -60", -60, -70, -60},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p := newTestPlayer()
|
|
p.SetVolumeMin(tt.volumeMin)
|
|
p.SetVolume(tt.volume)
|
|
if got := p.Volume(); got != tt.want {
|
|
t.Errorf("Volume = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetSpeedClamps(t *testing.T) {
|
|
p := newTestPlayer()
|
|
|
|
tests := []struct {
|
|
in float64
|
|
want float64
|
|
}{
|
|
{0.1, 0.25},
|
|
{0.25, 0.25},
|
|
{1.0, 1.0},
|
|
{2.0, 2.0},
|
|
{3.0, 2.0},
|
|
}
|
|
for _, tt := range tests {
|
|
p.SetSpeed(tt.in)
|
|
if got := p.Speed(); got != tt.want {
|
|
t.Errorf("SetSpeed(%v) → Speed() = %v, want %v", tt.in, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestToggleMono(t *testing.T) {
|
|
p := newTestPlayer()
|
|
|
|
if p.Mono() {
|
|
t.Fatal("Mono() should start false")
|
|
}
|
|
p.ToggleMono()
|
|
if !p.Mono() {
|
|
t.Error("ToggleMono should flip to true")
|
|
}
|
|
p.ToggleMono()
|
|
if p.Mono() {
|
|
t.Error("ToggleMono should flip back to false")
|
|
}
|
|
}
|
|
|
|
func TestSetEQBandClamps(t *testing.T) {
|
|
p := newTestPlayer()
|
|
|
|
tests := []struct {
|
|
band int
|
|
in float64
|
|
want float64
|
|
}{
|
|
{0, 20.0, 12.0},
|
|
{0, -20.0, -12.0},
|
|
{5, 6.5, 6.5},
|
|
{9, 0.0, 0.0},
|
|
}
|
|
for _, tt := range tests {
|
|
p.SetEQBand(tt.band, tt.in)
|
|
bands := p.EQBands()
|
|
if bands[tt.band] != tt.want {
|
|
t.Errorf("SetEQBand(%d, %v) → %v, want %v", tt.band, tt.in, bands[tt.band], tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetEQBandIgnoresInvalidIndex(t *testing.T) {
|
|
p := newTestPlayer()
|
|
// Setting an out-of-range band should be a no-op, not panic.
|
|
p.SetEQBand(-1, 5)
|
|
p.SetEQBand(10, 5)
|
|
p.SetEQBand(100, 5)
|
|
|
|
for i, b := range p.EQBands() {
|
|
if b != 0 {
|
|
t.Errorf("EQBands[%d] = %v, want 0 after invalid writes", i, b)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEQBandsReturnsCopy(t *testing.T) {
|
|
p := newTestPlayer()
|
|
p.SetEQBand(0, 6)
|
|
|
|
bands := p.EQBands()
|
|
bands[0] = 999 // mutate local copy
|
|
|
|
if p.EQBands()[0] != 6 {
|
|
t.Error("EQBands() should return a copy — mutation leaked back")
|
|
}
|
|
}
|
|
|
|
func TestIsPlayingDefaultsFalse(t *testing.T) {
|
|
p := newTestPlayer()
|
|
if p.IsPlaying() {
|
|
t.Error("IsPlaying() should start false")
|
|
}
|
|
if p.IsPaused() {
|
|
t.Error("IsPaused() should start false")
|
|
}
|
|
}
|
|
|
|
func TestSampleRate(t *testing.T) {
|
|
p := &Player{sr: 44100}
|
|
if p.SampleRate() != 44100 {
|
|
t.Errorf("SampleRate() = %d, want 44100", p.SampleRate())
|
|
}
|
|
}
|
|
|
|
func TestStreamTitleEmpty(t *testing.T) {
|
|
p := newTestPlayer()
|
|
if got := p.StreamTitle(); got != "" {
|
|
t.Errorf("StreamTitle() on fresh player = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestSetStreamTitle(t *testing.T) {
|
|
p := newTestPlayer()
|
|
p.setStreamTitle("Artist - Song")
|
|
if got := p.StreamTitle(); got != "Artist - Song" {
|
|
t.Errorf("StreamTitle() = %q, want 'Artist - Song'", got)
|
|
}
|
|
}
|
|
|
|
func TestRegisterStreamerFactory(t *testing.T) {
|
|
p := newTestPlayer()
|
|
var noop StreamerFactory // nil factory is fine for this storage-only test
|
|
p.RegisterStreamerFactory("spotify:", noop)
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if len(p.customFactories) != 1 {
|
|
t.Errorf("len(customFactories) = %d, want 1", len(p.customFactories))
|
|
}
|
|
if _, ok := p.customFactories["spotify:"]; !ok {
|
|
t.Error("factory not stored under 'spotify:'")
|
|
}
|
|
}
|
|
|
|
func TestRegisterBufferedURLMatcher(t *testing.T) {
|
|
p := newTestPlayer()
|
|
match := func(u string) bool { return u == "foo" }
|
|
p.RegisterBufferedURLMatcher(match)
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.bufferedURLMatch == nil {
|
|
t.Error("matcher not stored")
|
|
}
|
|
}
|
|
|
|
func TestConcurrentVolumeSetRead(t *testing.T) {
|
|
p := newTestPlayer()
|
|
var wg sync.WaitGroup
|
|
const N = 100
|
|
|
|
wg.Add(N * 2)
|
|
for i := 0; i < N; i++ {
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
p.SetVolume(float64(-i % 30))
|
|
}(i)
|
|
go func() {
|
|
defer wg.Done()
|
|
_ = p.Volume()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
// If the race detector didn't flag anything, we're happy.
|
|
}
|
|
|
|
// Ensure the atomic uint64 storage actually wraps dB as expected.
|
|
func TestVolumeStoragePrecision(t *testing.T) {
|
|
p := newTestPlayer()
|
|
p.SetVolume(-3.14)
|
|
v := math.Float64frombits(p.volume.Load())
|
|
if math.Abs(v-(-3.14)) > 1e-12 {
|
|
t.Errorf("stored volume = %v, want -3.14", v)
|
|
}
|
|
}
|
|
|
|
type playbackTestDecoder struct {
|
|
closeOnce sync.Once
|
|
closed chan struct{}
|
|
}
|
|
|
|
func newPlaybackTestDecoder() *playbackTestDecoder {
|
|
return &playbackTestDecoder{closed: make(chan struct{})}
|
|
}
|
|
|
|
func (d *playbackTestDecoder) Stream(samples [][2]float64) (int, bool) {
|
|
clear(samples)
|
|
return len(samples), true
|
|
}
|
|
|
|
func (*playbackTestDecoder) Err() error { return nil }
|
|
func (*playbackTestDecoder) Len() int { return 1000 }
|
|
func (*playbackTestDecoder) Position() int { return 0 }
|
|
func (*playbackTestDecoder) Seek(int) error { return nil }
|
|
func (d *playbackTestDecoder) Close() error {
|
|
d.closeOnce.Do(func() { close(d.closed) })
|
|
return nil
|
|
}
|
|
|
|
func TestPlayerBlockedNavStreamCanBeInterruptedBeforeSpeakerLock(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
run func(*Player) error
|
|
}{
|
|
{
|
|
name: "stop",
|
|
run: func(p *Player) error {
|
|
p.suspended = true // Avoid a real speaker context in this lifecycle test.
|
|
p.Stop()
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
name: "source replacement",
|
|
run: func(p *Player) error {
|
|
decoder := newPlaybackTestDecoder()
|
|
tp := &trackPipeline{
|
|
decoder: decoder,
|
|
stream: decoder,
|
|
format: beep.Format{SampleRate: 100, NumChannels: 2, Precision: 2},
|
|
}
|
|
return p.playPipeline(tp)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
old, audioStarted, audioDone, closeInput := blockedNavPlayback(t)
|
|
defer closeInput()
|
|
queuedDecoder := newPlaybackTestDecoder()
|
|
queued := &trackPipeline{decoder: queuedDecoder, stream: queuedDecoder}
|
|
p := &Player{
|
|
sr: 100,
|
|
gapless: &gaplessStreamer{},
|
|
current: old,
|
|
nextPipeline: queued,
|
|
started: true,
|
|
ctrl: &beep.Ctrl{},
|
|
suspended: false,
|
|
}
|
|
p.gapless.Replace(old.stream)
|
|
p.gapless.SetNext(queued.stream)
|
|
<-audioStarted
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- tt.run(p) }()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("playback operation error = %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
old.interrupt()
|
|
<-audioDone
|
|
t.Fatal("playback operation deadlocked behind blocked nav Stream")
|
|
}
|
|
select {
|
|
case <-audioDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("blocked nav Stream was not released")
|
|
}
|
|
select {
|
|
case <-queuedDecoder.closed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("playback operation retained the queued preload")
|
|
}
|
|
|
|
if tt.name == "source replacement" {
|
|
p.mu.Lock()
|
|
current := p.current
|
|
p.mu.Unlock()
|
|
if current == nil || current == old {
|
|
t.Fatal("late gapless advance overwrote source replacement")
|
|
}
|
|
p.suspended = true
|
|
p.Stop()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func blockedNavPlayback(t *testing.T) (*trackPipeline, <-chan struct{}, <-chan struct{}, func()) {
|
|
t.Helper()
|
|
reader, writer := io.Pipe()
|
|
decoder := &navFFmpegStreamer{
|
|
ffmpegPipe: ffmpegPipe{
|
|
reader: bufio.NewReader(reader),
|
|
pipe: reader,
|
|
state: newPipeStreamState(0),
|
|
},
|
|
nb: newCompletedTestNavBuffer(t, nil),
|
|
sr: 100,
|
|
}
|
|
tp := &trackPipeline{
|
|
decoder: decoder,
|
|
stream: decoder,
|
|
format: beep.Format{SampleRate: 100, NumChannels: 2, Precision: 2},
|
|
seekable: true,
|
|
}
|
|
started := make(chan struct{})
|
|
done := make(chan struct{})
|
|
go func() {
|
|
speaker.Lock()
|
|
close(started)
|
|
decoder.Stream(make([][2]float64, 1))
|
|
speaker.Unlock()
|
|
close(done)
|
|
}()
|
|
return tp, started, done, func() { _ = writer.Close() }
|
|
}
|
|
|
|
func TestPlayerFFmpegSeekPreparesOutsideSpeakerLock(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses POSIX shell fixtures")
|
|
}
|
|
for _, kind := range []string{"local", "nav"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
countPath := filepath.Join(dir, "ffmpeg-count")
|
|
readyPath := filepath.Join(dir, "replacement-ready")
|
|
writeExecutable(t, filepath.Join(dir, "ffmpeg"), `#!/bin/sh
|
|
n=0
|
|
if [ -f "$FFMPEG_COUNT" ]; then n=$(cat "$FFMPEG_COUNT"); fi
|
|
n=$((n + 1))
|
|
printf '%s' "$n" > "$FFMPEG_COUNT"
|
|
if [ "$n" -gt 1 ]; then
|
|
sleep 0.2
|
|
: > "$FFMPEG_READY"
|
|
fi
|
|
printf '\000\100\000\300'
|
|
exec sleep 30
|
|
`)
|
|
writeExecutable(t, filepath.Join(dir, "ffprobe"), `#!/bin/sh
|
|
printf '10\n'
|
|
`)
|
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
t.Setenv("FFMPEG_COUNT", countPath)
|
|
t.Setenv("FFMPEG_READY", readyPath)
|
|
|
|
var decoder beep.StreamSeekCloser
|
|
var err error
|
|
switch kind {
|
|
case "local":
|
|
decoder, _, err = decodeFFmpegLocal(filepath.Join(dir, "track.m4a"), 100, 16)
|
|
case "nav":
|
|
nb := newCompletedTestNavBuffer(t, []byte("HEADpayload"))
|
|
decoder, _, err = decodeNavFFmpeg(nb, 100, 16, 1000)
|
|
waitForFileValue(t, countPath, "1")
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("build %s decoder: %v", kind, err)
|
|
}
|
|
defer decoder.Close()
|
|
|
|
tp := &trackPipeline{
|
|
decoder: decoder,
|
|
stream: decoder,
|
|
format: beep.Format{SampleRate: 100, NumChannels: 2, Precision: 2},
|
|
seekable: true,
|
|
}
|
|
preloadedDecoder := newPlaybackTestDecoder()
|
|
preloaded := &trackPipeline{decoder: preloadedDecoder, stream: preloadedDecoder}
|
|
p := &Player{sr: 100, gapless: &gaplessStreamer{}, current: tp, nextPipeline: preloaded}
|
|
p.gapless.Replace(tp.stream)
|
|
p.gapless.SetNext(preloaded.stream)
|
|
|
|
speaker.Lock()
|
|
seekDone := make(chan error, 1)
|
|
go func() { seekDone <- p.Seek(time.Second) }()
|
|
if !waitForPath(readyPath) {
|
|
speaker.Unlock()
|
|
t.Fatalf("timed out waiting for %s", readyPath)
|
|
}
|
|
select {
|
|
case err := <-seekDone:
|
|
speaker.Unlock()
|
|
t.Fatalf("Seek completed before speaker commit lock was available: %v", err)
|
|
default:
|
|
}
|
|
speaker.Unlock()
|
|
|
|
select {
|
|
case err := <-seekDone:
|
|
if err != nil {
|
|
t.Fatalf("Seek() error = %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Seek did not complete after speaker lock was released")
|
|
}
|
|
if got := decoder.Position(); got != 100 {
|
|
t.Fatalf("Position() = %d, want relative seek target 100", got)
|
|
}
|
|
if p.HasPreload() {
|
|
t.Fatal("successful Seek retained stale preload")
|
|
}
|
|
select {
|
|
case <-preloadedDecoder.closed:
|
|
default:
|
|
t.Fatal("successful Seek did not close stale preload")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func waitForPath(path string) bool {
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return true
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func waitForFileValue(t *testing.T, path, want string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if got, err := os.ReadFile(path); err == nil && string(got) == want {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for %s to contain %q", path, want)
|
|
}
|