// ABOUTME: Malgo-based audio output implementation with 24-bit support // ABOUTME: Uses miniaudio library via malgo for true hi-res audio playback package output import ( "context" "fmt" "log" "runtime" "sort" "strings" "sync" "time" "unsafe" "github.com/Sendspin/sendspin-go/pkg/audio" "github.com/gen2brain/malgo" ) // PlaybackDevice describes a playback endpoint discoverable via miniaudio. // Returned by ListPlaybackDevices and used to select a specific device when // constructing a Malgo output. type PlaybackDevice struct { Name string IsDefault bool ID malgo.DeviceID } type Malgo struct { ctx context.Context cancel context.CancelFunc malgoCtx *malgo.AllocatedContext device *malgo.Device deviceName string // empty = use default sampleRate int channels int bitDepth int volume int muted bool ready bool // Ring buffer for callback-based playback ringBuffer *RingBuffer mu sync.Mutex } // RingBuffer provides thread-safe circular buffer for audio samples type RingBuffer struct { buffer []int32 readPos int writePos int size int count int // Number of samples currently in buffer mu sync.Mutex } // NewRingBuffer creates a ring buffer with given capacity (in samples) func NewRingBuffer(capacity int) *RingBuffer { return &RingBuffer{ buffer: make([]int32, capacity), size: capacity, } } // Write adds samples to the ring buffer func (rb *RingBuffer) Write(samples []int32) int { rb.mu.Lock() defer rb.mu.Unlock() written := 0 for i := 0; i < len(samples) && rb.count < rb.size; i++ { rb.buffer[rb.writePos] = samples[i] rb.writePos = (rb.writePos + 1) % rb.size rb.count++ written++ } return written } // Read retrieves samples from the ring buffer func (rb *RingBuffer) Read(samples []int32) int { rb.mu.Lock() defer rb.mu.Unlock() read := 0 for i := 0; i < len(samples) && rb.count > 0; i++ { samples[i] = rb.buffer[rb.readPos] rb.readPos = (rb.readPos + 1) % rb.size rb.count-- read++ } // Zero-fill remaining if underrun for i := read; i < len(samples); i++ { samples[i] = 0 } return read } // Available returns the number of samples available to read func (rb *RingBuffer) Available() int { rb.mu.Lock() defer rb.mu.Unlock() return rb.count } // Free returns the number of free slots in the buffer func (rb *RingBuffer) Free() int { rb.mu.Lock() defer rb.mu.Unlock() return rb.size - rb.count } // NewMalgo constructs a new malgo-backed audio output. deviceName selects a // specific playback device by name (as reported by ListPlaybackDevices). An // empty deviceName lets miniaudio pick the platform default. func NewMalgo(deviceName string) Output { ctx, cancel := context.WithCancel(context.Background()) return &Malgo{ ctx: ctx, cancel: cancel, deviceName: deviceName, volume: 100, muted: false, } } // ListPlaybackDevices enumerates every playback device miniaudio can see. // It creates a fresh context and tears it down before returning, so it is // safe to call before any player/device has been initialized. func ListPlaybackDevices() ([]PlaybackDevice, error) { ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil) if err != nil { return nil, fmt.Errorf("init malgo context: %w", err) } defer func() { _ = ctx.Uninit() ctx.Free() }() infos, err := ctx.Devices(malgo.Playback) if err != nil { return nil, fmt.Errorf("enumerate playback devices: %w", err) } out := make([]PlaybackDevice, 0, len(infos)) for _, info := range infos { out = append(out, PlaybackDevice{ Name: info.Name(), IsDefault: info.IsDefault != 0, ID: info.ID, }) } return out, nil } // matchDevice picks a PlaybackDevice from a list based on a requested name. // // Empty requested name -> the device with IsDefault set, else the first in // the list, else nil if the list is empty (caller falls back to whatever // miniaudio's default-config path does). // // Non-empty requested name -> exact Name match first, then short-name match // (the text before the first ", "). Miniaudio's Linux/ALSA backend builds // device names from snd_device_name_hint's DESC field, which follows a // ", " convention, so users naturally try // just the short part. If the short-name match is ambiguous, we error out // instead of picking one silently. // // Fail-loud on no-match: the error lists every available device name, each // quoted with %q so embedded commas are distinguishable from the list // separator. Silent fallback to default is the behavior this feature // exists to correct. func matchDevice(devices []PlaybackDevice, requested string) (*PlaybackDevice, error) { if requested == "" { if len(devices) == 0 { return nil, nil } for i := range devices { if devices[i].IsDefault { return &devices[i], nil } } return &devices[0], nil } for i := range devices { if devices[i].Name == requested { return &devices[i], nil } } var shortMatches []int for i, d := range devices { if idx := strings.Index(d.Name, ", "); idx > 0 && d.Name[:idx] == requested { shortMatches = append(shortMatches, i) } } if len(shortMatches) == 1 { return &devices[shortMatches[0]], nil } if len(devices) == 0 { return nil, fmt.Errorf("audio device %q not found (no playback devices available)", requested) } quoted := make([]string, len(devices)) for i, d := range devices { quoted[i] = fmt.Sprintf("%q", d.Name) } sort.Strings(quoted) if len(shortMatches) > 1 { return nil, fmt.Errorf("audio device %q is ambiguous (matches %d devices by short name); use the full quoted name. Available: %s", requested, len(shortMatches), strings.Join(quoted, ", ")) } return nil, fmt.Errorf("audio device %q not found; available: %s", requested, strings.Join(quoted, ", ")) } func (m *Malgo) Open(sampleRate, channels, bitDepth int) error { m.mu.Lock() defer m.mu.Unlock() // If already initialized with same format, reuse if m.device != nil && m.sampleRate == sampleRate && m.channels == channels && m.bitDepth == bitDepth { log.Printf("Audio output already initialized with same format, reusing device") return nil } // If format changed, reinitialize if m.device != nil { log.Printf("Format change detected (%dHz/%dch/%dbit -> %dHz/%dch/%dbit), reinitializing device", m.sampleRate, m.channels, m.bitDepth, sampleRate, channels, bitDepth) if err := m.closeDevice(); err != nil { return fmt.Errorf("failed to close old device: %w", err) } } if m.malgoCtx == nil { ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil) if err != nil { return fmt.Errorf("failed to initialize malgo context: %w", err) } m.malgoCtx = ctx } var format malgo.FormatType switch bitDepth { case 16: format = malgo.FormatS16 case 24: format = malgo.FormatS24 case 32: format = malgo.FormatS32 default: return fmt.Errorf("unsupported bit depth: %d (supported: 16, 24, 32)", bitDepth) } // Create ring buffer (80ms capacity - tuned for Music Assistant) bufferSamples := (sampleRate * channels * 80) / 1000 m.ringBuffer = NewRingBuffer(bufferSamples) deviceConfig := malgo.DefaultDeviceConfig(malgo.Playback) deviceConfig.Playback.Format = format deviceConfig.Playback.Channels = uint32(channels) deviceConfig.SampleRate = uint32(sampleRate) deviceConfig.Alsa.NoMMap = 1 // Pin the period to 20 ms instead of miniaudio's default low-latency // 10 ms. Several backends (bcm2835 ALSA on Pi, PulseAudio/PipeWire on // some Intel Smart Sound paths — see mackron/miniaudio#877) silently // round the requested 10 ms period to a different internal value and // then stall the audio callback after a few invocations. Asking for // 20 ms lands inside the safer range used by miniaudio's own backend // defaults (see CHANGES.md: PulseAudio default raised to 25 ms in // v0.11.8 to work around PipeWire glitches) and gives the driver // enough headroom that the negotiated period matches what we asked // for. ~20 ms of added pipeline latency is invisible inside Sendspin's // 200+ ms scheduler budget. deviceConfig.PeriodSizeInMilliseconds = 20 // Resolve the playback device. When m.deviceName is empty, miniaudio's // enumerated default is picked (and logged so the operator knows what // they're getting). When non-empty, the device must exist or Open fails // loudly — silent fallback defeats the point of the knob. infos, err := m.malgoCtx.Devices(malgo.Playback) if err != nil { return fmt.Errorf("enumerate playback devices: %w", err) } catalog := make([]PlaybackDevice, 0, len(infos)) for _, info := range infos { catalog = append(catalog, PlaybackDevice{ Name: info.Name(), IsDefault: info.IsDefault != 0, ID: info.ID, }) } chosen, err := matchDevice(catalog, m.deviceName) if err != nil { return err } // Hand miniaudio a pointer to the selected device ID, pinned across the // cgo call so Go 1.21+'s pointer check accepts it. // // Pinning &chosen.ID[0] directly would fail: chosen is an element inside // a []PlaybackDevice, and the containing heap object also holds the Go // string Name — whose backing bytes are another Go pointer that cgo's // recursive scan would find unpinned and reject. Copying the ID bytes // into a standalone []byte isolates the pointer target: a byte slice's // backing array contains only bytes (no further Go pointers), so the // scan finds nothing to complain about. var pinner runtime.Pinner defer pinner.Unpin() chosenLabel := "(miniaudio default)" if chosen != nil { idBuf := append([]byte(nil), chosen.ID[:]...) pinner.Pin(&idBuf[0]) deviceConfig.Playback.DeviceID = unsafe.Pointer(&idBuf[0]) if chosen.IsDefault { chosenLabel = fmt.Sprintf("%q (default)", chosen.Name) } else { chosenLabel = fmt.Sprintf("%q", chosen.Name) } } onSamples := func(pOutputSample, pInputSamples []byte, frameCount uint32) { m.dataCallback(pOutputSample, frameCount) } deviceCallbacks := malgo.DeviceCallbacks{ Data: onSamples, } device, err := malgo.InitDevice(m.malgoCtx.Context, deviceConfig, deviceCallbacks) if err != nil { return fmt.Errorf("failed to initialize playback device: %w", err) } if err := device.Start(); err != nil { device.Uninit() return fmt.Errorf("failed to start device: %w", err) } m.device = device m.sampleRate = sampleRate m.channels = channels m.bitDepth = bitDepth m.ready = true log.Printf("Audio output initialized: device=%s %dHz/%dch/%d-bit period=%dms (malgo/%s)", chosenLabel, sampleRate, channels, bitDepth, deviceConfig.PeriodSizeInMilliseconds, formatName(format)) return nil } // Write queues audio samples for playback. // Writes in passes if the ring is too small to absorb the whole buffer // at once, waiting for the audio callback to drain space between passes. // Buffers larger than the ring (e.g. Music Assistant's ~85 ms PCM chunks // against a 80 ms ring) succeed as long as the callback keeps draining. // Returns an error only if no drain progress occurs for maxStallTime, // which indicates the audio callback itself has stalled. func (m *Malgo) Write(samples []int32) error { if !m.ready { return fmt.Errorf("output not initialized") } const ( retryInterval = 1 * time.Millisecond maxStallTime = 50 * time.Millisecond ) volumedSamples := applyVolume(samples, m.volume, m.muted) written := 0 lastProgress := time.Now() for written < len(volumedSamples) { n := m.ringBuffer.Write(volumedSamples[written:]) if n > 0 { written += n lastProgress = time.Now() continue } // Ring is full this pass. Wait for the audio callback to // drain. If we go too long with zero progress, the callback // has likely stalled — drop the remainder rather than block // the producer indefinitely. if time.Since(lastProgress) > maxStallTime { dropped := len(volumedSamples) - written return fmt.Errorf("ring buffer stalled, dropped %d of %d samples after %v with no drain progress", dropped, len(volumedSamples), maxStallTime) } time.Sleep(retryInterval) } return nil } // dataCallback is called by malgo to fill the audio output buffer func (m *Malgo) dataCallback(pOutput []byte, frameCount uint32) { totalSamples := int(frameCount) * m.channels samples := make([]int32, totalSamples) m.ringBuffer.Read(samples) switch m.bitDepth { case 16: m.write16Bit(pOutput, samples) case 24: m.write24Bit(pOutput, samples) case 32: m.write32Bit(pOutput, samples) } } // write16Bit converts int32 samples to 16-bit output func (m *Malgo) write16Bit(output []byte, samples []int32) { for i, sample := range samples { sample16 := audio.SampleToInt16(sample) output[i*2] = byte(sample16) output[i*2+1] = byte(sample16 >> 8) } } // write24Bit converts int32 samples to 24-bit output (3 bytes per sample) func (m *Malgo) write24Bit(output []byte, samples []int32) { for i, sample := range samples { output[i*3] = byte(sample) output[i*3+1] = byte(sample >> 8) output[i*3+2] = byte(sample >> 16) } } // write32Bit converts int32 samples to 32-bit output func (m *Malgo) write32Bit(output []byte, samples []int32) { for i, sample := range samples { // Left-shift 24-bit value to fill the upper bits of the 32-bit container sample32 := sample << 8 output[i*4] = byte(sample32) output[i*4+1] = byte(sample32 >> 8) output[i*4+2] = byte(sample32 >> 16) output[i*4+3] = byte(sample32 >> 24) } } func (m *Malgo) Close() error { m.mu.Lock() defer m.mu.Unlock() if err := m.closeDevice(); err != nil { return err } if m.malgoCtx != nil { if err := m.malgoCtx.Uninit(); err != nil { log.Printf("Warning: malgo context uninit error: %v", err) } m.malgoCtx.Free() m.malgoCtx = nil } m.cancel() return nil } // closeDevice stops and uninitializes the device; caller must hold m.mu. func (m *Malgo) closeDevice() error { if m.device != nil { if err := m.device.Stop(); err != nil { log.Printf("Warning: device stop error: %v", err) } m.device.Uninit() m.device = nil m.ready = false } return nil } func (m *Malgo) SetVolume(volume int) { if volume < 0 { volume = 0 } if volume > 100 { volume = 100 } m.volume = volume log.Printf("Volume set to %d", volume) } func (m *Malgo) SetMuted(muted bool) { m.muted = muted log.Printf("Muted: %v", muted) } func (m *Malgo) GetVolume() int { return m.volume } func (m *Malgo) IsMuted() bool { return m.muted } func formatName(format malgo.FormatType) string { switch format { case malgo.FormatS16: return "S16" case malgo.FormatS24: return "S24" case malgo.FormatS32: return "S32" default: return fmt.Sprintf("Unknown(%d)", format) } }