🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
// ABOUTME: Audio output package for playing audio
// ABOUTME: Provides Output interface with malgo implementation
// Package output provides audio playback interfaces.
//
// Currently supports:
// - malgo (miniaudio): 16/24/32-bit output, format re-initialization supported
//
// Example:
//
// out := output.NewMalgo()
// err := out.Open(192000, 2, 24) // 192kHz, stereo, 24-bit
// err = out.Write(samples)
package output

View File

@@ -0,0 +1,510 @@
// 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
// "<card-short>, <stream-description>" 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)
}
}

View File

@@ -0,0 +1,232 @@
// ABOUTME: Tests for the pure matchDevice selection logic used by Open
package output
import (
"strings"
"testing"
"github.com/gen2brain/malgo"
)
// newDevice builds a PlaybackDevice with a unique sentinel ID so tests can
// assert the correct entry was returned. The actual ID bytes are opaque to
// miniaudio at this layer; we only check that matchDevice returns the right
// slice element.
func newDevice(name string, isDefault bool, marker byte) PlaybackDevice {
var id malgo.DeviceID
id[0] = marker
return PlaybackDevice{Name: name, IsDefault: isDefault, ID: id}
}
func TestMatchDevice_EmptyRequest(t *testing.T) {
tests := []struct {
name string
devices []PlaybackDevice
wantNil bool
wantName string
wantMarker byte
}{
{
name: "empty catalog returns nil",
devices: nil,
wantNil: true,
},
{
name: "prefers the device flagged IsDefault",
devices: []PlaybackDevice{
newDevice("First", false, 0x01),
newDevice("DefaultSink", true, 0x02),
newDevice("Third", false, 0x03),
},
wantName: "DefaultSink",
wantMarker: 0x02,
},
{
name: "falls back to first device when none flagged default",
devices: []PlaybackDevice{
newDevice("Alpha", false, 0x0A),
newDevice("Beta", false, 0x0B),
},
wantName: "Alpha",
wantMarker: 0x0A,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := matchDevice(tt.devices, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.wantNil {
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
return
}
if got == nil {
t.Fatal("expected a device, got nil")
}
if got.Name != tt.wantName {
t.Errorf("name = %q, want %q", got.Name, tt.wantName)
}
if got.ID[0] != tt.wantMarker {
t.Errorf("id[0] = 0x%x, want 0x%x (wrong slice element returned)", got.ID[0], tt.wantMarker)
}
})
}
}
func TestMatchDevice_ExactNameMatch(t *testing.T) {
devices := []PlaybackDevice{
newDevice("HDA Intel PCH: ALC257 Analog", true, 0x10),
newDevice("HDMI 0", false, 0x11),
newDevice("USB Audio Device", false, 0x12),
}
got, err := matchDevice(devices, "USB Audio Device")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || got.Name != "USB Audio Device" {
t.Errorf("got %+v, want USB Audio Device", got)
}
if got.ID[0] != 0x12 {
t.Errorf("wrong device matched: id[0] = 0x%x, want 0x12", got.ID[0])
}
}
func TestMatchDevice_NoMatchListsAvailable(t *testing.T) {
devices := []PlaybackDevice{
newDevice("Charlie", false, 0x01),
newDevice("Alpha", true, 0x02),
newDevice("Bravo", false, 0x03),
}
got, err := matchDevice(devices, "DoesNotExist")
if got != nil {
t.Errorf("expected nil device, got %+v", got)
}
if err == nil {
t.Fatal("expected an error")
}
msg := err.Error()
if !strings.Contains(msg, `"DoesNotExist"`) {
t.Errorf("error should name the missing device: %q", msg)
}
// Available names must be listed, sorted, so users can copy/paste the right one.
for _, want := range []string{"Alpha", "Bravo", "Charlie"} {
if !strings.Contains(msg, want) {
t.Errorf("error should list %q; got %q", want, msg)
}
}
alphaIdx := strings.Index(msg, "Alpha")
bravoIdx := strings.Index(msg, "Bravo")
charlieIdx := strings.Index(msg, "Charlie")
if !(alphaIdx < bravoIdx && bravoIdx < charlieIdx) {
t.Errorf("available names should be sorted alphabetically; got %q", msg)
}
}
func TestMatchDevice_NoMatchEmptyCatalogGivesDistinctError(t *testing.T) {
got, err := matchDevice(nil, "Anything")
if got != nil {
t.Errorf("expected nil device, got %+v", got)
}
if err == nil {
t.Fatal("expected an error")
}
msg := err.Error()
if !strings.Contains(msg, "no playback devices available") {
t.Errorf("error should distinguish empty-catalog case: %q", msg)
}
}
// TestMatchDevice_ShortNameMatch covers miniaudio's Linux/ALSA naming where
// device.name is "<card-short>, <stream-description>" — users typing just
// the short prefix should match unambiguously when only one device has that
// prefix. Reproduces the HiFiBerry case from the field bug.
func TestMatchDevice_ShortNameMatch(t *testing.T) {
devices := []PlaybackDevice{
newDevice("Default Audio Device", true, 0x01),
newDevice("vc4-hdmi-0, MAI PCM i2s-hifi-0", false, 0x02),
newDevice("vc4-hdmi-1, MAI PCM i2s-hifi-0", false, 0x03),
newDevice("PDP Audio Device, USB Audio", false, 0x04),
}
got, err := matchDevice(devices, "vc4-hdmi-0")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || got.ID[0] != 0x02 {
t.Errorf("short-name %q should resolve to id 0x02; got %+v", "vc4-hdmi-0", got)
}
got, err = matchDevice(devices, "PDP Audio Device")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || got.ID[0] != 0x04 {
t.Errorf("short-name %q should resolve to id 0x04; got %+v", "PDP Audio Device", got)
}
}
// TestMatchDevice_ExactNameWinsOverShortName guards the precedence: if a
// device's full name happens to equal someone else's short prefix, the
// exact match takes priority over the short-name search.
func TestMatchDevice_ExactNameWinsOverShortName(t *testing.T) {
devices := []PlaybackDevice{
newDevice("Foo, long description", false, 0x01),
newDevice("Foo", false, 0x02),
}
got, err := matchDevice(devices, "Foo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || got.ID[0] != 0x02 {
t.Errorf("exact match should win: expected id 0x02; got %+v", got)
}
}
// TestMatchDevice_ShortNameAmbiguousReturnsError covers the two-HiFiBerry
// case: the same short prefix matches multiple devices. We must not silently
// pick one.
func TestMatchDevice_ShortNameAmbiguousReturnsError(t *testing.T) {
devices := []PlaybackDevice{
newDevice("HiFiBerry, card 0", false, 0x01),
newDevice("HiFiBerry, card 1", false, 0x02),
}
got, err := matchDevice(devices, "HiFiBerry")
if got != nil {
t.Errorf("expected nil on ambiguous short-name match, got %+v", got)
}
if err == nil {
t.Fatal("expected an error on ambiguous short-name match")
}
msg := err.Error()
if !strings.Contains(msg, "ambiguous") {
t.Errorf("error should mention ambiguity: %q", msg)
}
if !strings.Contains(msg, `"HiFiBerry, card 0"`) || !strings.Contains(msg, `"HiFiBerry, card 1"`) {
t.Errorf("ambiguity error should list both candidates quoted with %%q: %q", msg)
}
}
// TestMatchDevice_NoMatchQuotesNames ensures names with embedded commas are
// distinguishable from the list separator in the error output.
func TestMatchDevice_NoMatchQuotesNames(t *testing.T) {
devices := []PlaybackDevice{
newDevice("vc4-hdmi-0, MAI PCM i2s-hifi-0", false, 0x01),
}
_, err := matchDevice(devices, "nonexistent")
if err == nil {
t.Fatal("expected an error")
}
msg := err.Error()
if !strings.Contains(msg, `"vc4-hdmi-0, MAI PCM i2s-hifi-0"`) {
t.Errorf("name should appear quoted in error so embedded comma is unambiguous: %q", msg)
}
}

View File

@@ -0,0 +1,12 @@
// ABOUTME: Audio output interface definition
// ABOUTME: Common interface for audio playback backends
package output
// Output represents an audio output device
type Output interface {
Open(sampleRate, channels, bitDepth int) error
Write(samples []int32) error
Close() error
SetVolume(volume int)
SetMuted(muted bool)
}

View File

@@ -0,0 +1,9 @@
// ABOUTME: Audio output interface tests
// ABOUTME: Verifies Output interface implementation
package output
import "testing"
func TestMalgoImplementsOutput(t *testing.T) {
var _ Output = (*Malgo)(nil)
}

View File

@@ -0,0 +1,110 @@
// ABOUTME: Capability probe for malgo playback devices (rate/bit-depth ceilings)
// ABOUTME: Used by Player to filter advertised SupportedFormats before handshake
package output
import (
"fmt"
"github.com/gen2brain/malgo"
)
// QueryDeviceCapabilities returns the highest sample rate and bit depth the
// named playback device's malgo (miniaudio) backend reports as natively
// supported. deviceName matches the same way ListPlaybackDevices and Open
// accept it; an empty string selects the platform default.
//
// Best-effort. When the backend reports zero native formats — some devices
// don't, especially on cold-start Windows / Pulse — this returns (0, 0, nil)
// so the caller can fall back to "no cap". On Linux/ALSA the answer can also
// be optimistic, because miniaudio reports what the driver claims to accept,
// and ALSA layers software resampling under formats the underlying hardware
// (e.g. bcm2835 onboard headphones) can't actually sustain. The user-facing
// override knob exists exactly for that case.
//
// Does NOT InitDevice. Cheaper than opening the device, but the trade-off is
// that the answer is a best-guess from miniaudio rather than ground truth.
func QueryDeviceCapabilities(deviceName string) (maxSampleRate, maxBitDepth int, err error) {
ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil)
if err != nil {
return 0, 0, fmt.Errorf("init malgo context: %w", err)
}
defer func() {
_ = ctx.Uninit()
ctx.Free()
}()
infos, err := ctx.Devices(malgo.Playback)
if err != nil {
return 0, 0, 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, deviceName)
if err != nil {
return 0, 0, err
}
if chosen == nil {
// No devices at all. Treat as "no cap" — no audio output is going to
// happen anyway, so the caller's handshake will fail for unrelated
// reasons.
return 0, 0, nil
}
detail, err := ctx.DeviceInfo(malgo.Playback, chosen.ID, malgo.Shared)
if err != nil {
return 0, 0, fmt.Errorf("query device info for %q: %w", chosen.Name, err)
}
maxRate, maxDepth := capsFromFormats(detail.Formats)
return maxRate, maxDepth, nil
}
// capsFromFormats walks a DeviceInfo's native-format list and returns the
// highest sample rate and bit depth observed. Formats with unknown bit
// representations (FormatU8, FormatUnknown) are ignored — we'd rather report
// a lower cap than advertise rates only achievable in unsupported formats.
//
// Pure helper so the cgo-bound QueryDeviceCapabilities doesn't need test
// coverage of its own — capsFromFormats covers the interesting logic.
func capsFromFormats(formats []malgo.DataFormat) (maxSampleRate, maxBitDepth int) {
for _, f := range formats {
bits := formatBits(f.Format)
if bits == 0 {
continue
}
if int(f.SampleRate) > maxSampleRate {
maxSampleRate = int(f.SampleRate)
}
if bits > maxBitDepth {
maxBitDepth = bits
}
}
return maxSampleRate, maxBitDepth
}
// formatBits returns the linear bit count for a malgo FormatType.
// 0 means unknown/unsupported and the caller should ignore the entry.
func formatBits(f malgo.FormatType) int {
switch f {
case malgo.FormatS16:
return 16
case malgo.FormatS24:
return 24
case malgo.FormatS32:
return 32
case malgo.FormatF32:
// 32-bit float carries the same dynamic range as S32 for our
// purposes — both clear our 24-bit advertised ceiling.
return 32
default:
return 0
}
}

View File

@@ -0,0 +1,90 @@
// ABOUTME: Tests for the pure capsFromFormats / formatBits helpers
// ABOUTME: cgo-bound QueryDeviceCapabilities is not exercised here
package output
import (
"testing"
"github.com/gen2brain/malgo"
)
func TestFormatBits(t *testing.T) {
tests := []struct {
format malgo.FormatType
want int
}{
{malgo.FormatS16, 16},
{malgo.FormatS24, 24},
{malgo.FormatS32, 32},
{malgo.FormatF32, 32},
{malgo.FormatUnknown, 0},
{malgo.FormatU8, 0}, // we don't currently advertise 8-bit formats
}
for _, tt := range tests {
if got := formatBits(tt.format); got != tt.want {
t.Errorf("formatBits(%v) = %d, want %d", tt.format, got, tt.want)
}
}
}
func TestCapsFromFormats_Empty(t *testing.T) {
rate, depth := capsFromFormats(nil)
if rate != 0 || depth != 0 {
t.Errorf("expected (0, 0) for empty input, got (%d, %d)", rate, depth)
}
}
func TestCapsFromFormats_TakesMaxAcrossEntries(t *testing.T) {
// Mixed-rate / mixed-depth list. Rate and depth caps come from
// different entries — neither field's max needs to come from the same row.
formats := []malgo.DataFormat{
{Format: malgo.FormatS16, Channels: 2, SampleRate: 192000},
{Format: malgo.FormatS24, Channels: 2, SampleRate: 48000},
}
rate, depth := capsFromFormats(formats)
if rate != 192000 {
t.Errorf("expected max rate 192000, got %d", rate)
}
if depth != 24 {
t.Errorf("expected max depth 24, got %d", depth)
}
}
func TestCapsFromFormats_IgnoresUnknownFormat(t *testing.T) {
// An entry with an unknown FormatType must not contribute to either cap,
// even if its SampleRate is huge — we'd be advertising a rate we couldn't
// actually drive in any of our supported formats.
formats := []malgo.DataFormat{
{Format: malgo.FormatUnknown, Channels: 2, SampleRate: 384000},
{Format: malgo.FormatS16, Channels: 2, SampleRate: 48000},
}
rate, depth := capsFromFormats(formats)
if rate != 48000 {
t.Errorf("expected unknown-format entry to be ignored; got rate %d", rate)
}
if depth != 16 {
t.Errorf("expected depth 16, got %d", depth)
}
}
func TestCapsFromFormats_AllUnknownReturnsZero(t *testing.T) {
formats := []malgo.DataFormat{
{Format: malgo.FormatUnknown, Channels: 2, SampleRate: 192000},
{Format: malgo.FormatU8, Channels: 2, SampleRate: 48000},
}
rate, depth := capsFromFormats(formats)
if rate != 0 || depth != 0 {
t.Errorf("expected (0, 0) when no entry has a known bit depth, got (%d, %d)", rate, depth)
}
}
func TestCapsFromFormats_F32MapsToThirtyTwo(t *testing.T) {
formats := []malgo.DataFormat{
{Format: malgo.FormatF32, Channels: 2, SampleRate: 96000},
}
_, depth := capsFromFormats(formats)
if depth != 32 {
t.Errorf("FormatF32 should map to 32 bits; got %d", depth)
}
}

View File

@@ -0,0 +1,34 @@
// ABOUTME: Volume and mute helpers shared by audio output backends
// ABOUTME: Extracted from oto.go during the oto removal cleanup
package output
import "github.com/Sendspin/sendspin-go/pkg/audio"
// applyVolume applies volume and mute to samples with clipping protection.
// Samples are expected to be in the int32 24-bit range.
func applyVolume(samples []int32, volume int, muted bool) []int32 {
multiplier := getVolumeMultiplier(volume, muted)
result := make([]int32, len(samples))
for i, sample := range samples {
scaled := int64(float64(sample) * multiplier)
if scaled > audio.Max24Bit {
scaled = audio.Max24Bit
} else if scaled < audio.Min24Bit {
scaled = audio.Min24Bit
}
result[i] = int32(scaled)
}
return result
}
// getVolumeMultiplier returns the float multiplier for a given volume/mute state.
func getVolumeMultiplier(volume int, muted bool) float64 {
if muted {
return 0.0
}
return float64(volume) / 100.0
}

View File

@@ -0,0 +1,70 @@
// ABOUTME: Tests for shared volume/mute helpers in pkg/audio/output
// ABOUTME: Covers multiplier table, half-scale, mute, and 24-bit clamping
package output
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio"
)
func TestVolumeMultiplier(t *testing.T) {
tests := []struct {
volume int
muted bool
expected float64
}{
{100, false, 1.0},
{50, false, 0.5},
{0, false, 0.0},
{80, true, 0.0}, // muted overrides volume
}
for _, tt := range tests {
result := getVolumeMultiplier(tt.volume, tt.muted)
if result != tt.expected {
t.Errorf("volume=%d muted=%v: expected %f, got %f",
tt.volume, tt.muted, tt.expected, result)
}
}
}
func TestApplyVolume_HalfScale(t *testing.T) {
samples := []int32{1000 << 8, -1000 << 8}
result := applyVolume(samples, 50, false)
if result[0] != int32(500<<8) {
t.Errorf("sample 0: expected %d, got %d", 500<<8, result[0])
}
if result[1] != int32(-500<<8) {
t.Errorf("sample 1: expected %d, got %d", -500<<8, result[1])
}
}
func TestApplyVolume_Muted(t *testing.T) {
samples := []int32{audio.Max24Bit, audio.Min24Bit, 1 << 20}
result := applyVolume(samples, 100, true)
for i, got := range result {
if got != 0 {
t.Errorf("sample %d: expected 0 when muted, got %d", i, got)
}
}
}
func TestApplyVolume_Clamps24Bit(t *testing.T) {
// Inputs outside the 24-bit range must clamp, not overflow.
overMax := int32(audio.Max24Bit + 1)
underMin := int32(audio.Min24Bit - 1)
result := applyVolume([]int32{overMax, underMin}, 100, false)
if result[0] != audio.Max24Bit {
t.Errorf("max clamp: expected %d, got %d", audio.Max24Bit, result[0])
}
if result[1] != audio.Min24Bit {
t.Errorf("min clamp: expected %d, got %d", audio.Min24Bit, result[1])
}
}