Add audio device switching UI

This commit is contained in:
NeilClack
2026-08-08 00:12:57 -05:00
parent b54dbca2d5
commit 2212cc9b78
11 changed files with 1528 additions and 455 deletions
+60 -134
View File
@@ -1,145 +1,71 @@
package mixer
import (
"testing"
"time"
import "testing"
"rhemawavetech.com/rhemawave/internal/db"
)
// Regression tests for critical audio bugs (v0.5)
// Regression: Stop() used to return early when AudioEnabled was false,
// leaving music playing after the user toggled audio off mid-playback.
func TestStopCleansUpWhenAudioDisabled(t *testing.T) {
mu.Lock()
wasInited := inited
inited = true
deviceOpen = false
currentPath = "/some/track.wav"
currentLoops = -1
mu.Unlock()
defer func() {
mu.Lock()
inited = wasInited
currentPath = ""
currentLoops = 0
mu.Unlock()
}()
cfg := &db.AppConfig{AudioEnabled: false}
Stop(0, cfg)
mu.Lock()
defer mu.Unlock()
if currentPath != "" {
t.Errorf("currentPath = %q, want empty after Stop with AudioEnabled=false", currentPath)
func TestHumanizeDeviceName(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{"hdmi description", "Built-in Audio Digital Stereo (HDMI)", "HDMI | Monitor | TV"},
{"hdmi alsa name", "alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1", "HDMI | Monitor | TV"},
{"monitor", "Monitor", "HDMI | Monitor | TV"},
{"tv", "TV", "HDMI | Monitor | TV"},
{"uppercase hdmi", "HDMI", "HDMI | Monitor | TV"},
{"headset", "Headset", "Headset"},
{"headphones", "WH-1000XM4 Headphones", "Headset"},
{"bluetooth headset name", "bluez_output.00_11_22_33_44_55.headset-head-unit", "Headset"},
{"usb description", "USB Audio", "USB"},
{"usb alsa name", "alsa_output.usb-046d_0825-00.analog-stereo", "USB"},
{"internal analog description", "Built-in Audio Analog Stereo", "Internal Speakers"},
{"internal alsa name", "alsa_output.pci-0000_00_1f.3.analog-stereo", "Internal Speakers"},
{"internal speakers label", "Internal Speakers", "Internal Speakers"},
{"virtual speaker", "Virtual Speaker", "Internal Speakers"},
{"no match fallback", "Custom Node 42", "Custom Node 42"},
{"empty fallback", "", ""},
}
if currentLoops != 0 {
t.Errorf("currentLoops = %d, want 0 after Stop with AudioEnabled=false", currentLoops)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := HumanizeDeviceName(tt.raw); got != tt.want {
t.Errorf("HumanizeDeviceName(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
// Regression: SwitchDevice used to convert oldDevice="" to "system default"
// and pass that string to openDeviceLocked, which SDL does not recognize.
func TestSwitchDeviceReturnsErrorWhenUninitialized(t *testing.T) {
mu.Lock()
wasInited := inited
inited = false
deviceOpen = false
mu.Unlock()
func TestMatchDeviceName(t *testing.T) {
names := []string{
"Built-in Audio Analog Stereo",
"Built-in Audio Digital Stereo (HDMI)",
"USB Audio",
"Headset",
}
defer func() {
mu.Lock()
inited = wasInited
mu.Unlock()
}()
tests := []struct {
name string
preferred string
want string
wantOK bool
}{
{"empty preferred", "", "", false},
{"exact match", "USB Audio", "USB Audio", true},
{"substring match", "hdmi", "Built-in Audio Digital Stereo (HDMI)", true},
{"suffix tolerance", "Built-in Audio Digital Stereo", "Built-in Audio Digital Stereo (HDMI)", true},
{"case insensitive", "usb audio", "USB Audio", true},
{"no match", "Bluetooth Dongle", "", false},
}
cfg := &db.AppConfig{AudioEnabled: true, SavedVolume: 0.5}
err := SwitchDevice("Some Device", cfg)
if err == nil {
t.Fatal("SwitchDevice on uninitialized mixer should return error")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := matchDevice(names, tt.preferred)
if ok != tt.wantOK {
t.Fatalf("matchDevice(%q) ok = %v, want %v", tt.preferred, ok, tt.wantOK)
}
if ok && got != tt.want {
t.Errorf("matchDevice(%q) = %q, want %q", tt.preferred, got, tt.want)
}
})
}
}
// Regression: SwitchDevice with nil config must not panic when attempting
// to restore volume after reopening the device.
func TestSwitchDeviceNilConfigDoesNotPanic(t *testing.T) {
mu.Lock()
wasInited := inited
inited = false
deviceOpen = false
mu.Unlock()
defer func() {
mu.Lock()
inited = wasInited
mu.Unlock()
}()
err := SwitchDevice("Some Device", nil)
if err == nil {
t.Fatal("SwitchDevice on uninitialized mixer should return error")
}
}
// Regression: SwitchDevice with empty device name (system default) should
// not be converted to "system default" before passing to openDeviceLocked.
func TestSwitchDeviceAcceptsEmptyDeviceName(t *testing.T) {
mu.Lock()
wasInited := inited
inited = false
deviceOpen = false
mu.Unlock()
defer func() {
mu.Lock()
inited = wasInited
mu.Unlock()
}()
cfg := &db.AppConfig{SavedVolume: 0.75}
err := SwitchDevice("", cfg)
// Returns error because mixer is not initialized, but should not panic
// and should handle the empty name gracefully.
if err == nil {
t.Fatal("SwitchDevice on uninitialized mixer should return error")
}
}
// TestPlayDoesNotDeadlockWhenUninitialized verifies that Play() returns an error
// (not a deadlock) when the mixer has not been initialized.
// The old code would deadlock because Play() held mu then called SwitchDevice()
// which also tried to acquire mu.
func TestPlayDoesNotDeadlockWhenUninitialized(t *testing.T) {
// Ensure mixer is in uninitialized state
mu.Lock()
wasInited := inited
inited = false
deviceOpen = false
mu.Unlock()
defer func() {
mu.Lock()
inited = wasInited
mu.Unlock()
}()
cfg := &db.AppConfig{AudioEnabled: true}
done := make(chan error, 1)
go func() {
done <- Play("nonexistent.wav", false, cfg)
}()
select {
case err := <-done:
if err == nil {
t.Fatal("Play() returned nil error on uninitialized mixer, want error")
}
case <-time.After(5 * time.Second):
t.Fatal("Play() deadlocked on uninitialized mixer (did not return within 5s)")
}
}