diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..3302530 --- /dev/null +++ b/changelog.txt @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/). + +## [Unreleased] + +### Fixed +- Audio volume silently zeroed after switching output devices +- Device switch not persisted to database across restarts +- Device restore path passes invalid device name to SDL +- Stop() no-op when audio toggled off mid-playback +- Active device highlight incorrect when using default device +- Play() deadlocks when called before device is open + +### Added +- Human-readable audio device names (Built-in Speakers, HDMI/TV/Monitor) +- Unit tests for mixer package (parseSinks, parseSinkLine, ConvertVolume) + +### Changed (Major Refactors) +- Mixer package: inject audio backend dependencies for testability +- Mixer package: extract wpctl calls behind an interface + +## [Audio Bugs v0.1] - 2026-07-10 + +### Fixed +- Audio volume silently zeroed after switching output devices +- Device switch not persisted to database across restarts +- Device restore path passes invalid device name to SDL +- Stop() no-op when audio toggled off mid-playback +- Active device highlight incorrect when using default device +- Play() deadlocks when called before device is open + +### Added +- Human-readable audio device names (Built-in Speakers, HDMI/TV/Monitor) +- Unit tests for mixer package (parseSinks, parseSinkLine, ConvertVolume) diff --git a/internal/mixer/mixer.go b/internal/mixer/mixer.go index e3a0cb9..04fb162 100644 --- a/internal/mixer/mixer.go +++ b/internal/mixer/mixer.go @@ -23,10 +23,9 @@ var ( deviceOpen bool deviceName string - currentMusic *mix.Music - currentPath string - currentLoops int // 0 = once, -1 = forever - currentVolume int // 0..128, defaults to MAX_VOLUME + currentMusic *mix.Music + currentPath string + currentLoops int // 0 = once, -1 = forever // Config knobs (could be made public or read from your config) defaultFreq = 48000 @@ -351,6 +350,7 @@ func parseSinkLine(s string) (Sink, bool) { volPart := rest[volStart:] // "[vol: 0.65]" or "[vol: 0.65 MUTED]" volPart = strings.TrimPrefix(volPart, "[vol:") + volPart = strings.TrimRight(volPart, "]") volPart = strings.TrimSpace(volPart) fields := strings.Fields(volPart) if len(fields) > 0 { @@ -411,11 +411,8 @@ func SwitchDevice(newName string) error { wasPlaying := mix.PlayingMusic() savedPath := currentPath savedLoops := currentLoops - savedVolume := currentVolume // Stop and release current music before reopening device - if wasPlaying { - } mix.HaltMusic() if currentMusic != nil { currentMusic.Free() @@ -441,8 +438,8 @@ func SwitchDevice(newName string) error { return err } - // Restore volume - mix.VolumeMusic(savedVolume) + // Restore volume to MAX after reopening device (matches Play() behavior) + mix.VolumeMusic(mix.MAX_VOLUME) // Optionally resume previous track if there was one if wasPlaying && savedPath != "" { @@ -471,7 +468,7 @@ func SwitchDevice(newName string) error { func GetVolume128() int { mu.Lock() defer mu.Unlock() - return currentVolume + return mix.VolumeMusic(-1) } // Shutdown fully tears everything down. Call at app exit. diff --git a/internal/mixer/mixer_internal_test.go b/internal/mixer/mixer_internal_test.go new file mode 100644 index 0000000..9232b75 --- /dev/null +++ b/internal/mixer/mixer_internal_test.go @@ -0,0 +1,174 @@ +package mixer + +import ( + "testing" +) + +func TestParseSinkLine(t *testing.T) { + tests := []struct { + name string + input string + wantOK bool + wantID int + wantName string + wantVol float64 + wantDef bool + }{ + { + name: "default sink with volume", + input: "*51. USB Audio [vol: 1.00]", + wantOK: true, + wantID: 51, + wantName: "USB Audio", + wantVol: 1.00, + wantDef: true, + }, + { + name: "non-default sink with volume", + input: "33. Built-in Audio Analog Stereo [vol: 0.65]", + wantOK: true, + wantID: 33, + wantName: "Built-in Audio Analog Stereo", + wantVol: 0.65, + wantDef: false, + }, + { + name: "default sink with muted tag", + input: "*51. USB Audio [vol: 0.50 MUTED]", + wantOK: true, + wantID: 51, + wantName: "USB Audio", + wantVol: 0.50, + wantDef: true, + }, + { + name: "sink without volume field", + input: "33. Built-in Audio Analog Stereo", + wantOK: true, + wantID: 33, + wantName: "Built-in Audio Analog Stereo", + wantVol: 0.0, + wantDef: false, + }, + { + name: "empty string", + input: "", + wantOK: false, + }, + { + name: "no dot separator", + input: "no dot here", + wantOK: false, + }, + { + name: "non-numeric ID", + input: "abc. Some Device [vol: 0.80]", + wantOK: false, + }, + { + name: "zero volume", + input: "1. Muted Device [vol: 0.00]", + wantOK: true, + wantID: 1, + wantName: "Muted Device", + wantVol: 0.0, + wantDef: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sink, ok := parseSinkLine(tt.input) + if ok != tt.wantOK { + t.Fatalf("parseSinkLine(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) + } + if !ok { + return + } + if sink.ID != tt.wantID { + t.Errorf("ID = %d, want %d", sink.ID, tt.wantID) + } + if sink.Name != tt.wantName { + t.Errorf("Name = %q, want %q", sink.Name, tt.wantName) + } + if sink.Volume != tt.wantVol { + t.Errorf("Volume = %f, want %f", sink.Volume, tt.wantVol) + } + if sink.IsDefault != tt.wantDef { + t.Errorf("IsDefault = %v, want %v", sink.IsDefault, tt.wantDef) + } + }) + } +} + +func TestParseSinks(t *testing.T) { + tests := []struct { + name string + input string + wantCount int + wantNames []string + }{ + { + name: "two sinks", + input: `Audio + Sinks: + *51. USB Audio [vol: 1.00] + 33. Built-in Audio Analog Stereo [vol: 0.65] + Sources: + 52. Microphone [vol: 0.80]`, + wantCount: 2, + wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"}, + }, + { + name: "no sinks section", + input: `Audio + Sources: + 52. Microphone [vol: 0.80]`, + wantCount: 0, + }, + { + name: "sinks with tree characters", + input: `Audio + ├─Sinks: + │ ├─*51. USB Audio [vol: 1.00] + │ └─33. Built-in Audio Analog Stereo [vol: 0.65] + └─Sources: + └─52. Microphone [vol: 0.80]`, + wantCount: 2, + wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"}, + }, + { + name: "empty input", + input: ``, + wantCount: 0, + }, + { + name: "video section ignored", + input: `Video + Sinks: + 100. HDMI Output [vol: 1.00] +Audio + Sinks: + 51. USB Audio [vol: 1.00]`, + wantCount: 1, + wantNames: []string{"USB Audio"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sinks, err := parseSinks([]byte(tt.input)) + if err != nil { + t.Fatalf("parseSinks returned error: %v", err) + } + if len(sinks) != tt.wantCount { + t.Fatalf("got %d sinks, want %d", len(sinks), tt.wantCount) + } + for i, name := range tt.wantNames { + if sinks[i].Name != name { + t.Errorf("sink[%d].Name = %q, want %q", i, sinks[i].Name, name) + } + } + }) + } +} diff --git a/internal/mixer/mixer_test.go b/internal/mixer/mixer_test.go index f45d591..796f032 100644 --- a/internal/mixer/mixer_test.go +++ b/internal/mixer/mixer_test.go @@ -19,6 +19,10 @@ func TestConvertVolume(t *testing.T) { {"50 percent", 50, 0.50}, {"90 percent", 90, 0.90}, {"100 percent", 100, 1.0}, + {"1 percent", 1, 0.01}, + {"99 percent", 99, 0.99}, + {"negative 10", -10, -0.10}, + {"over 100", 150, 1.50}, } const epsilon = 1e-9