Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions cmd/daemon/cli_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 39 additions & 3 deletions cmd/daemon/cli_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions output/driver-audio-toolbox-test-helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//go:build darwin && test_unit

package output

// #cgo LDFLAGS: -framework AudioToolbox
// #include <AudioToolbox/AudioToolbox.h>
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
}
69 changes: 49 additions & 20 deletions output/driver-audio-toolbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ package output
// #include <AudioToolbox/AudioToolbox.h>
// #include <CoreAudio/CoreAudio.h>
// #include <stdlib.h>
// #include <stdint.h>
// extern void audioCallback(void * inUserData, AudioQueueRef inAQ, AudioQueueBufferRef inBuffer);
//
// typedef struct {
// void *output;
// uintptr_t output;
// } AudioContext;
//
// static AudioContext* allocateAudioContext() {
Expand All @@ -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 {
Expand All @@ -39,6 +41,7 @@ type toolboxOutput struct {
context *C.AudioContext
paused bool
volume float32
closing atomic.Bool
err chan error
}

Expand All @@ -52,16 +55,23 @@ 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 {
allocErr := errors.New("failed to allocate AudioContext")
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")
Expand All @@ -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)
}

Expand All @@ -95,59 +104,77 @@ 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
C.memset(unsafe.Pointer(buffer.mAudioData), 0, C.size_t(out.bufferSize*4))
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 {
return nil, out.toolboxError("startAudioQueue", err)
}

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
}

C.memcpy(unsafe.Pointer(buffer.mAudioData), unsafe.Pointer(&data[0]), C.size_t(n*4))
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)
}
}

//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)
}

Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading