fix: restore SDL volume to MAX after device switch

Remove dead 'currentVolume' variable that was never written to, causing
SwitchDevice to always restore volume to 0 after switching audio devices.

- Remove currentVolume from package state
- SwitchDevice now sets mix.MAX_VOLUME after reopening device
- GetVolume128 now queries SDL directly instead of reading stale variable
- Add unit tests for parseSinks, parseSinkLine, and ConvertVolume
- Add changelog.txt tracking all audio bug fixes
This commit is contained in:
NeilClack
2026-07-10 13:56:06 -05:00
parent fd10201f75
commit 2bcb36669f
4 changed files with 221 additions and 10 deletions
+37
View File
@@ -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)
+6 -10
View File
@@ -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
@@ -411,11 +410,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 +437,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 +467,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.
+174
View File
@@ -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)
}
}
})
}
}
+4
View File
@@ -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