diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7c74de5..cab405e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,3 +10,33 @@ There just a few guidelines to follow when contributing to this project. Please * Follow Golang conventions for naming, formatting, and structuring code. Use `gofmt` to format your code. * If you're adding a new feature, please consider opening an issue first to discuss it. This can save you time if the feature is not something that can be merged into the project. + +## Testing on macOS + +Install Go and the native decoder dependencies, then run the same test tags as CI: + +```sh +brew install go libogg libvorbis flac mpg123 pkgconf +go test -count=1 -tags "test_unit test_integration" ./... +go build -o /tmp/go-librespot-test ./cmd/daemon +``` + +The AudioToolbox device test requires a Mac with a working default output device. +It is skipped unless explicitly enabled, so the regular test suite can run without +audio hardware. To test the actual native queue with the race detector and strict +cgo pointer checks: + +```sh +GO_LIBRESPOT_TEST_AUDIO_DEVICE=1 GOEXPERIMENT=cgocheck2 \ + go test -race -tags test_unit ./output -run TestAudioToolbox -count=5 -v +``` + +This submits generated silence at 44.1 kHz and 48 kHz to the default output. It +checks native initial mute and volume changes, callback progress after garbage +collection, three pause/resume cycles per output, and repeated disposal. Pausing +must stop reader consumption; resuming must restart it; no further reads may +occur after disposal. The error-delivery test also runs without an audio device. + +These tests do not need Spotify credentials. They verify the local audio queue, +not audible output or successful Spotify authentication, licensing, or track +playback. Report those separately when validating end-to-end playback. diff --git a/README.md b/README.md index ff6a3c87..99450e96 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ ## Features - 🎵 **Spotify Connect** — show up as a speaker in the Spotify app and stream to it from any device on your network (Spotify Premium required). -- 🔊 **Multiple audio backends** — ALSA, PulseAudio, WASAPI on Windows, or a raw named pipe for custom routing. +- 🔊 **Multiple audio backends** — ALSA, PulseAudio, AudioToolbox on macOS, WASAPI on Windows, or a raw named pipe for custom routing. - 📊 **Loudness normalization** — Spotify-standard −14 LUFS (ITU-R BS.1770) with configurable pregain. - 🔀 **Crossfade** — configurable overlap between consecutive tracks. - 🎙️ **Podcast resume** — episodes pick up where you left off, and progress syncs back to your other devices. @@ -271,7 +271,7 @@ log_disable_timestamp: false # Whether to disable timestamps in log output device_id: '' # Spotify device ID (auto-generated) device_name: '' # Spotify device name device_type: computer # Spotify device type (icon) -audio_backend: alsa # Audio backend to use (alsa, pipe, pulseaudio, audio-toolbox, wasapi). Default is alsa, or wasapi on Windows. +audio_backend: alsa # Default: audio-toolbox on macOS, wasapi on Windows, alsa elsewhere. Can also use pipe or pulseaudio. audio_backend_runtime_socket: '' # Audio backends' runtime socket to use, if backend is pulseaudio audio_device: default # ALSA audio device to use for playback mixer_device: '' # ALSA mixer device for volume synchronization diff --git a/cmd/daemon/cli_config.go b/cmd/daemon/cli_config.go index a7cf2557..bf22ef30 100644 --- a/cmd/daemon/cli_config.go +++ b/cmd/daemon/cli_config.go @@ -273,10 +273,14 @@ func loadCLIConfig(cfg *cliConfig) error { } func defaultAudioBackend() string { - if runtime.GOOS == "windows" { + switch runtime.GOOS { + case "windows": return "wasapi" + case "darwin": + return "audio-toolbox" + default: + return "alsa" } - return "alsa" } // parseSize parses a human-readable size string such as "1GB", "500MB" or a diff --git a/cmd/daemon/cli_config_test.go b/cmd/daemon/cli_config_test.go index 499537a3..6321cf0b 100644 --- a/cmd/daemon/cli_config_test.go +++ b/cmd/daemon/cli_config_test.go @@ -13,11 +13,47 @@ import ( func TestDefaultAudioBackend(t *testing.T) { got := defaultAudioBackend() - if runtime.GOOS == "windows" { + switch runtime.GOOS { + case "windows": require.Equal(t, "wasapi", got) - return + case "darwin": + require.Equal(t, "audio-toolbox", got) + default: + require.Equal(t, "alsa", got) + } +} + +func TestLoadCLIConfigAudioBackend(t *testing.T) { + defaultBackend := "alsa" + switch runtime.GOOS { + case "darwin": + defaultBackend = "audio-toolbox" + case "windows": + defaultBackend = "wasapi" + } + for _, tc := range []struct { + name, config, want string + }{ + {"platform default", "initial_volume: 0\n", defaultBackend}, + {"explicit override", "audio_backend: pipe\ninitial_volume: 0\n", "pipe"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yml"), []byte(tc.config), 0o600)) + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"test", "--config_dir", dir} + cfg := new(cliConfig) + require.NoError(t, loadCLIConfig(cfg)) + t.Cleanup(func() { + if cfg.configLock != nil { + require.NoError(t, cfg.configLock.Unlock()) + } + }) + require.Equal(t, tc.want, cfg.AudioBackend) + require.Zero(t, cfg.InitialVolume, "explicit mute must survive default config merging") + }) } - require.Equal(t, "alsa", got) } func TestParseSize(t *testing.T) { diff --git a/output/driver-audio-toolbox-test-helpers.go b/output/driver-audio-toolbox-test-helpers.go new file mode 100644 index 00000000..de7bc435 --- /dev/null +++ b/output/driver-audio-toolbox-test-helpers.go @@ -0,0 +1,32 @@ +//go:build darwin && test_unit + +package output + +// #cgo LDFLAGS: -framework AudioToolbox +// #include +import "C" +import ( + "fmt" + "unsafe" +) + +// cgo cannot be imported from a _test.go file. Keep the native parameter +// check behind the same build tag as the tests instead of adding a public API. +func toolboxVolumeForTest(out *toolboxOutput) (float32, error) { + var volume C.AudioQueueParameterValue + status := C.AudioQueueGetParameter(out.audioQueue, C.kAudioQueueParam_Volume, &volume) + if status != C.noErr { + return 0, fmt.Errorf("AudioQueueGetParameter: %d", status) + } + return float32(volume), nil +} + +func toolboxRunningForTest(out *toolboxOutput) (bool, error) { + var running C.UInt32 + size := C.UInt32(unsafe.Sizeof(running)) + status := C.AudioQueueGetProperty(out.audioQueue, C.kAudioQueueProperty_IsRunning, unsafe.Pointer(&running), &size) + if status != C.noErr { + return false, fmt.Errorf("AudioQueueGetProperty(IsRunning): %d", status) + } + return running != 0, nil +} diff --git a/output/driver-audio-toolbox.go b/output/driver-audio-toolbox.go index 04bd49ab..213dfefe 100644 --- a/output/driver-audio-toolbox.go +++ b/output/driver-audio-toolbox.go @@ -6,10 +6,11 @@ package output // #include // #include // #include +// #include // extern void audioCallback(void * inUserData, AudioQueueRef inAQ, AudioQueueBufferRef inBuffer); // // typedef struct { -// void *output; +// uintptr_t output; // } AudioContext; // // static AudioContext* allocateAudioContext() { @@ -23,11 +24,12 @@ import "C" import ( "errors" "fmt" + "runtime/cgo" + "sync/atomic" "unsafe" librespot "github.com/devgianlu/go-librespot" log "github.com/sirupsen/logrus" - "golang.org/x/sys/unix" ) type toolboxOutput struct { @@ -39,6 +41,7 @@ type toolboxOutput struct { context *C.AudioContext paused bool volume float32 + closing atomic.Bool err chan error } @@ -52,7 +55,8 @@ func newAudioToolboxOutput(opts *NewOutputOptions) (*toolboxOutput, error) { err: make(chan error, 1), } - // We need the C.AudioContext to give the callback safe access to the output context + // AudioQueue retains this C allocation until disposal. Store an integer + // handle, never a Go pointer: the output contains Go-managed references. log.Tracef("allocating audio context") ctx := C.allocateAudioContext() if ctx == nil { @@ -60,8 +64,14 @@ func newAudioToolboxOutput(opts *NewOutputOptions) (*toolboxOutput, error) { out.err <- allocErr return nil, allocErr } - ctx.output = unsafe.Pointer(out) + ctx.output = C.uintptr_t(cgo.NewHandle(out)) out.context = ctx + ready := false + defer func() { + if !ready { + _ = out.Close() + } + }() // Create a new Audio Toolbox output log.Tracef("configuring output") @@ -85,7 +95,6 @@ func newAudioToolboxOutput(opts *NewOutputOptions) (*toolboxOutput, error) { &out.audioQueue, ) if err != 0 { - C.freeAudioContext(out.context) return nil, out.toolboxError("setupAudioQueue", err) } @@ -95,7 +104,7 @@ func newAudioToolboxOutput(opts *NewOutputOptions) (*toolboxOutput, error) { var buffer C.AudioQueueBufferRef status := C.AudioQueueAllocateBuffer(out.audioQueue, C.UInt32(out.bufferSize*4), &buffer) if status != C.noErr { - return nil, out.toolboxError("allocateAudioQueue", err) + return nil, out.toolboxError("allocateAudioQueue", status) } // Init buffer with silence @@ -103,10 +112,16 @@ func newAudioToolboxOutput(opts *NewOutputOptions) (*toolboxOutput, error) { buffer.mAudioDataByteSize = C.UInt32(out.bufferSize * 4) status = C.AudioQueueEnqueueBuffer(out.audioQueue, buffer, 0, nil) if status != C.noErr { - return nil, out.toolboxError("enqueueAudioQueue", err) + return nil, out.toolboxError("enqueueAudioQueue", status) } } + // The queue defaults to full volume. Apply the requested level before any + // samples can be played, including when startup is explicitly muted. + if status := C.AudioQueueSetParameter(out.audioQueue, C.kAudioQueueParam_Volume, C.Float32(opts.InitialVolume)); status != C.noErr { + return nil, out.toolboxError("setInitialVolume", status) + } + // Start the Audio Toolbox output log.Tracef("starting audio queue") if err := C.AudioQueueStart(out.audioQueue, nil); err != 0 { @@ -114,24 +129,36 @@ func newAudioToolboxOutput(opts *NewOutputOptions) (*toolboxOutput, error) { } log.Info("started audio-toolbox output") + ready = true return out, nil } // Error handler - returns new error obj func (out *toolboxOutput) toolboxError(name string, err C.int) error { - if errors.Is(unix.Errno(-err), unix.EPIPE) { - _ = out.Close() + result := fmt.Errorf("%s: %d", name, err) + // Do not block the audio callback or dispose its own queue from inside it. + select { + case out.err <- result: + default: } - out.err <- fmt.Errorf("%s: %d", name, err) - return fmt.Errorf("%s: %d", name, err) + return result } // Gets samples from the reader and writes them to the output buffer func (out *toolboxOutput) bufferSamples(buffer C.AudioQueueBufferRef) { + if out.closing.Load() { + return + } data := make([]float32, out.bufferSize) n, err := out.reader.Read(data) + if out.closing.Load() { + return + } if err != nil { - out.err <- fmt.Errorf("error reading samples: %v", err) + select { + case out.err <- fmt.Errorf("error reading samples: %w", err): + default: + } return } @@ -139,7 +166,7 @@ func (out *toolboxOutput) bufferSamples(buffer C.AudioQueueBufferRef) { buffer.mAudioDataByteSize = C.UInt32(n * 4) status := C.AudioQueueEnqueueBuffer(out.audioQueue, buffer, 0, nil) - if status != C.noErr { + if status != C.noErr && !out.closing.Load() { log.Errorf("error queuing samples for output: %v", status) } } @@ -147,7 +174,7 @@ func (out *toolboxOutput) bufferSamples(buffer C.AudioQueueBufferRef) { //export audioCallback func audioCallback(inUserData unsafe.Pointer, inAQ C.AudioQueueRef, inBuffer C.AudioQueueBufferRef) { ctx := (*C.AudioContext)(inUserData) - out := (*toolboxOutput)(ctx.output) + out := cgo.Handle(ctx.output).Value().(*toolboxOutput) out.bufferSamples(inBuffer) } @@ -236,16 +263,18 @@ func (out *toolboxOutput) Error() <-chan error { } func (out *toolboxOutput) Close() error { - - // Stop the audio queue - C.AudioQueueStop(out.audioQueue, C.Boolean(1)) - - // Dispose of the audio queue + out.closing.Store(true) + // Immediate disposal waits for callbacks to finish before their context + // and handle can be released. Also covers partially constructed outputs. if out.audioQueue != nil { - C.AudioQueueDispose(out.audioQueue, C.Boolean(1)) + if status := C.AudioQueueDispose(out.audioQueue, C.Boolean(1)); status != C.noErr { + return out.toolboxError("disposeAudioQueue", status) + } + out.audioQueue = nil } if out.context != nil { + cgo.Handle(out.context.output).Delete() C.freeAudioContext(out.context) out.context = nil } diff --git a/output/driver-audio-toolbox_test.go b/output/driver-audio-toolbox_test.go new file mode 100644 index 00000000..92f5e03e --- /dev/null +++ b/output/driver-audio-toolbox_test.go @@ -0,0 +1,142 @@ +//go:build darwin && test_unit + +package output + +import ( + "fmt" + "os" + "runtime" + "sync/atomic" + "testing" + "time" +) + +type toolboxSilenceReader struct{ reads atomic.Int32 } + +func (r *toolboxSilenceReader) Read(samples []float32) (int, error) { + clear(samples) + r.reads.Add(1) + return len(samples), nil +} + +// This test opens the real default audio device, but only submits silence. +// Opt in on a Mac with an output device; GOEXPERIMENT=cgocheck2 also verifies +// that callbacks never retain an unpinned Go pointer in C memory. +func TestAudioToolboxDefaultDevice(t *testing.T) { + if os.Getenv("GO_LIBRESPOT_TEST_AUDIO_DEVICE") != "1" { + t.Skip("set GO_LIBRESPOT_TEST_AUDIO_DEVICE=1 to open the default audio device") + } + for _, sampleRate := range []int{44100, 48000} { + t.Run(fmt.Sprintf("%dHz", sampleRate), func(t *testing.T) { + reader := new(toolboxSilenceReader) + out, err := newAudioToolboxOutput(&NewOutputOptions{ + Reader: reader, SampleRate: sampleRate, ChannelCount: 2, InitialVolume: 0, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := out.Close(); err != nil { + t.Errorf("cleanup: %v", err) + } + }) + assertToolboxVolume(t, out, 0) + waitToolboxRunning(t, out, true) + waitToolbox(t, out, func() bool { return reader.reads.Load() > 0 }) + beforeGC := reader.reads.Load() + runtime.GC() + waitToolbox(t, out, func() bool { return reader.reads.Load() > beforeGC }) + for cycle := 0; cycle < 3; cycle++ { + if err := out.Pause(); err != nil { + t.Fatal(err) + } + // IsRunning describes the audio device and can stay true while + // the queue is paused. Check reader consumption instead. + // Allow callbacks already in flight to return before checking + // that a paused queue is no longer consuming the reader. + time.Sleep(100 * time.Millisecond) + pausedReads := reader.reads.Load() + time.Sleep(150 * time.Millisecond) + if got := reader.reads.Load(); got != pausedReads { + t.Fatalf("reader advanced while paused: %d -> %d", pausedReads, got) + } + if err := out.Resume(); err != nil { + t.Fatal(err) + } + waitToolboxRunning(t, out, true) + waitToolbox(t, out, func() bool { return reader.reads.Load() > pausedReads }) + } + for _, volume := range []float32{0.25, 1, 0} { + out.SetVolume(volume) + assertToolboxVolume(t, out, volume) + } + if err := out.Close(); err != nil { + t.Fatal(err) + } + if out.context != nil || out.audioQueue != nil { + t.Fatal("Close retained the callback context or queue") + } + closedReads := reader.reads.Load() + time.Sleep(100 * time.Millisecond) + if got := reader.reads.Load(); got != closedReads { + t.Fatalf("reader advanced after Close: %d -> %d", closedReads, got) + } + select { + case err := <-out.Error(): + t.Fatalf("audio callback: %v", err) + default: + } + // Cleanup calls Close again and checks its result too. + }) + } +} + +func assertToolboxVolume(t *testing.T, out *toolboxOutput, want float32) { + t.Helper() + if volume, err := toolboxVolumeForTest(out); err != nil || volume != want { + t.Fatalf("native volume = %v, error = %v; want %v", volume, err, want) + } +} + +func waitToolboxRunning(t *testing.T, out *toolboxOutput, want bool) { + t.Helper() + waitToolbox(t, out, func() bool { + running, err := toolboxRunningForTest(out) + if err != nil { + t.Fatal(err) + } + return running == want + }) +} + +func waitToolbox(t *testing.T, out *toolboxOutput, condition func() bool) { + t.Helper() + deadline := time.After(3 * time.Second) + for !condition() { + select { + case err := <-out.Error(): + t.Fatalf("audio callback: %v", err) + case <-deadline: + t.Fatal("timed out waiting for the native audio queue") + case <-time.After(10 * time.Millisecond): + } + } +} + +func TestAudioToolboxErrorDeliveryDoesNotBlock(t *testing.T) { + out := &toolboxOutput{err: make(chan error, 1)} + done := make(chan struct{}) + go func() { + out.toolboxError("first", -1) + out.toolboxError("second", -2) + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("error reporting blocked on a full channel") + } + if got := <-out.Error(); got.Error() != "first: -1" { + t.Fatalf("first error was lost: %v", got) + } +} diff --git a/output/output.go b/output/output.go index 871c462c..4470f025 100644 --- a/output/output.go +++ b/output/output.go @@ -88,7 +88,7 @@ type NewOutputOptions struct { // InitialVolume specifies the initial output volume. // - // This is supported on the alsa, pipe, and wasapi backends. The PulseAudio + // This is supported on the alsa, pipe, audio-toolbox, and wasapi backends. The PulseAudio // backend uses the PulseAudio default volume. InitialVolume float32