Add audio device switching UI
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package mixer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
)
|
||||
|
||||
// Device describes an audio output device as seen through SDL's core
|
||||
// enumeration APIs. Name is the raw SDL device string used to open the device
|
||||
// and to persist the selection; Label is the humanized name shown to the user.
|
||||
type Device struct {
|
||||
Name string
|
||||
Label string
|
||||
}
|
||||
|
||||
// humanizeRules maps raw SDL device names to human-readable labels. Rules are
|
||||
// evaluated in order; the first rule whose keyword is a case-insensitive
|
||||
// substring of the raw name wins.
|
||||
var humanizeRules = []struct {
|
||||
keywords []string
|
||||
label string
|
||||
}{
|
||||
{[]string{"hdmi", "monitor", "tv"}, "HDMI | Monitor | TV"},
|
||||
{[]string{"headset", "headphone"}, "Headset"},
|
||||
{[]string{"usb"}, "USB"},
|
||||
{[]string{"analog", "built-in", "builtin", "internal", "speaker"}, "Internal Speakers"},
|
||||
}
|
||||
|
||||
// HumanizeDeviceName maps a raw SDL audio device name to a human-readable
|
||||
// label. Raw names come from SDL_GetAudioDeviceName, e.g. PipeWire sink
|
||||
// descriptions ("Built-in Audio Analog Stereo", "Built-in Audio Digital
|
||||
// Stereo (HDMI)") or PulseAudio sink names ("alsa_output.pci-0000_00_1f.3.
|
||||
// analog-stereo"). If no rule matches, the raw name is returned unchanged.
|
||||
func HumanizeDeviceName(raw string) string {
|
||||
lower := strings.ToLower(raw)
|
||||
for _, rule := range humanizeRules {
|
||||
for _, kw := range rule.keywords {
|
||||
if strings.Contains(lower, kw) {
|
||||
return rule.label
|
||||
}
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// enumeratePlaybackDevices returns the available playback devices from SDL's
|
||||
// core audio enumeration, each with a humanized label. It is the single place
|
||||
// that touches SDL2's device enumeration APIs; a future SDL3 migration only
|
||||
// needs to change this function and the mixer.OpenAudioDevice boundary.
|
||||
func enumeratePlaybackDevices() []Device {
|
||||
n := sdl.GetNumAudioDevices(false)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Device, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
if name := sdl.GetAudioDeviceName(i, false); name != "" {
|
||||
out = append(out, Device{Name: name, Label: HumanizeDeviceName(name)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListDevices returns the list of available output devices (from core SDL).
|
||||
// Name holds the raw device string (use it with SwitchDevice/CurrentDevice);
|
||||
// Label holds the humanized display name.
|
||||
func ListDevices() []Device {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return enumeratePlaybackDevices()
|
||||
}
|
||||
|
||||
// matchDevice resolves a stored/preferred device name against enumerated
|
||||
// device names. An exact match wins; otherwise the first device whose name
|
||||
// contains the preferred string (case-insensitive) is returned, tolerating
|
||||
// PipeWire/PulseAudio node-name suffixes. Returns false when the preferred
|
||||
// name is empty or no device matches.
|
||||
func matchDevice(names []string, preferred string) (string, bool) {
|
||||
if preferred == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, name := range names {
|
||||
if name == preferred {
|
||||
return name, true
|
||||
}
|
||||
}
|
||||
lowerPref := strings.ToLower(preferred)
|
||||
for _, name := range names {
|
||||
if strings.Contains(strings.ToLower(name), lowerPref) {
|
||||
return name, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// resolvePreferredDevice finds the enumeration name to open for a persisted
|
||||
// device selection. Returns false when the preferred name is empty or no
|
||||
// enumerated device matches, in which case the caller should fall back to the
|
||||
// system default.
|
||||
func resolvePreferredDevice(preferred string) (string, bool) {
|
||||
devs := enumeratePlaybackDevices()
|
||||
names := make([]string, 0, len(devs))
|
||||
for _, d := range devs {
|
||||
names = append(names, d.Name)
|
||||
}
|
||||
return matchDevice(names, preferred)
|
||||
}
|
||||
+31
-20
@@ -31,10 +31,15 @@ var (
|
||||
defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE
|
||||
|
||||
refreshCacheCallback func()
|
||||
|
||||
persistConfigCallback func(*db.AppConfig) error
|
||||
)
|
||||
|
||||
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
|
||||
func Init(cfg *db.AppConfig, onUpdate func()) error {
|
||||
// persistConfig persists the given config (used to save the selected audio
|
||||
// device across restarts); it may be nil, in which case device selections are
|
||||
// not saved.
|
||||
func Init(cfg *db.AppConfig, onUpdate func(), persistConfig func(*db.AppConfig) error) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if inited {
|
||||
@@ -42,6 +47,7 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
|
||||
}
|
||||
|
||||
refreshCacheCallback = onUpdate
|
||||
persistConfigCallback = persistConfig
|
||||
|
||||
if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil {
|
||||
return fmt.Errorf("SDL audio init failed: %w", err)
|
||||
@@ -52,9 +58,20 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
|
||||
logger.Error("Failed to initialize MP3/OGG support: %v", err)
|
||||
}
|
||||
|
||||
// A stored "default" (the first-run seed) is the system default.
|
||||
deviceName := cfg.SelectedAudioDevice
|
||||
if deviceName == "default" {
|
||||
if deviceName == "" || deviceName == "default" {
|
||||
deviceName = ""
|
||||
} else {
|
||||
// Honor a persisted device selection by resolving it against the
|
||||
// currently enumerated devices, falling back to the system default if
|
||||
// it is no longer present.
|
||||
if resolved, ok := resolvePreferredDevice(deviceName); ok {
|
||||
deviceName = resolved
|
||||
} else {
|
||||
logger.Warning("Selected audio device %q not found; using system default", cfg.SelectedAudioDevice)
|
||||
deviceName = ""
|
||||
}
|
||||
}
|
||||
if err := openDeviceLocked(deviceName); err != nil {
|
||||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||||
@@ -178,7 +195,10 @@ func IsPlaying() bool {
|
||||
return mix.PlayingMusic()
|
||||
}
|
||||
|
||||
// SetVolume sets the SDL_mixer music volume to level (0.0–1.0).
|
||||
// SetVolume sets the SDL_mixer music volume to level (0.0–1.0), mapped onto
|
||||
// SDL_mixer's 0–128 scale (mix.MAX_VOLUME). This is relative to the OS audio
|
||||
// sink, which the kiosk shell pins to 100%, so in-app volume is 0–100% of the
|
||||
// sink output.
|
||||
func SetVolume(level float64) error {
|
||||
if level < 0 || level > 1 {
|
||||
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
|
||||
@@ -204,23 +224,6 @@ func ConvertVolume(volPerc int) float64 {
|
||||
return float64(volPerc) / 100.0
|
||||
}
|
||||
|
||||
// ListDevices returns the list of available output device names (from core SDL).
|
||||
func ListDevices() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
n := sdl.GetNumAudioDevices(false)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
if name := sdl.GetAudioDeviceName(i, false); name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CurrentDevice returns the last requested/opened device name ("" means default).
|
||||
func CurrentDevice() string {
|
||||
return deviceName
|
||||
@@ -265,6 +268,14 @@ func SwitchDevice(newName string, cfg *db.AppConfig) error {
|
||||
}
|
||||
|
||||
if cfg != nil {
|
||||
// Persist whatever device is actually open (openDeviceLocked falls
|
||||
// back to the system default if the requested name cannot be opened).
|
||||
cfg.SelectedAudioDevice = CurrentDevice()
|
||||
if persistConfigCallback != nil {
|
||||
if err := persistConfigCallback(cfg); err != nil {
|
||||
logger.Error("Failed to persist audio device selection: %v", err)
|
||||
}
|
||||
}
|
||||
currentVolume = cfg.SavedVolume
|
||||
v := int(math.Round(cfg.SavedVolume * float64(mix.MAX_VOLUME)))
|
||||
mix.VolumeMusic(v)
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user