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 - Fix parseSinkLine not stripping trailing ']' from [vol: X.XX] (caused volume to silently parse as 0 for sinks without MUTED tag) - Fix flake.nix: make wayland/X11 buildInputs Linux-only for macOS compat - Add unit tests for parseSinks, parseSinkLine, and ConvertVolume - Add changelog.txt tracking all audio bug fixes
This commit is contained in:
@@ -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)
|
||||||
+7
-10
@@ -23,10 +23,9 @@ var (
|
|||||||
deviceOpen bool
|
deviceOpen bool
|
||||||
deviceName string
|
deviceName string
|
||||||
|
|
||||||
currentMusic *mix.Music
|
currentMusic *mix.Music
|
||||||
currentPath string
|
currentPath string
|
||||||
currentLoops int // 0 = once, -1 = forever
|
currentLoops int // 0 = once, -1 = forever
|
||||||
currentVolume int // 0..128, defaults to MAX_VOLUME
|
|
||||||
|
|
||||||
// Config knobs (could be made public or read from your config)
|
// Config knobs (could be made public or read from your config)
|
||||||
defaultFreq = 48000
|
defaultFreq = 48000
|
||||||
@@ -351,6 +350,7 @@ func parseSinkLine(s string) (Sink, bool) {
|
|||||||
volPart := rest[volStart:]
|
volPart := rest[volStart:]
|
||||||
// "[vol: 0.65]" or "[vol: 0.65 MUTED]"
|
// "[vol: 0.65]" or "[vol: 0.65 MUTED]"
|
||||||
volPart = strings.TrimPrefix(volPart, "[vol:")
|
volPart = strings.TrimPrefix(volPart, "[vol:")
|
||||||
|
volPart = strings.TrimRight(volPart, "]")
|
||||||
volPart = strings.TrimSpace(volPart)
|
volPart = strings.TrimSpace(volPart)
|
||||||
fields := strings.Fields(volPart)
|
fields := strings.Fields(volPart)
|
||||||
if len(fields) > 0 {
|
if len(fields) > 0 {
|
||||||
@@ -411,11 +411,8 @@ func SwitchDevice(newName string) error {
|
|||||||
wasPlaying := mix.PlayingMusic()
|
wasPlaying := mix.PlayingMusic()
|
||||||
savedPath := currentPath
|
savedPath := currentPath
|
||||||
savedLoops := currentLoops
|
savedLoops := currentLoops
|
||||||
savedVolume := currentVolume
|
|
||||||
|
|
||||||
// Stop and release current music before reopening device
|
// Stop and release current music before reopening device
|
||||||
if wasPlaying {
|
|
||||||
}
|
|
||||||
mix.HaltMusic()
|
mix.HaltMusic()
|
||||||
if currentMusic != nil {
|
if currentMusic != nil {
|
||||||
currentMusic.Free()
|
currentMusic.Free()
|
||||||
@@ -441,8 +438,8 @@ func SwitchDevice(newName string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore volume
|
// Restore volume to MAX after reopening device (matches Play() behavior)
|
||||||
mix.VolumeMusic(savedVolume)
|
mix.VolumeMusic(mix.MAX_VOLUME)
|
||||||
|
|
||||||
// Optionally resume previous track if there was one
|
// Optionally resume previous track if there was one
|
||||||
if wasPlaying && savedPath != "" {
|
if wasPlaying && savedPath != "" {
|
||||||
@@ -471,7 +468,7 @@ func SwitchDevice(newName string) error {
|
|||||||
func GetVolume128() int {
|
func GetVolume128() int {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
return currentVolume
|
return mix.VolumeMusic(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown fully tears everything down. Call at app exit.
|
// Shutdown fully tears everything down. Call at app exit.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ func TestConvertVolume(t *testing.T) {
|
|||||||
{"50 percent", 50, 0.50},
|
{"50 percent", 50, 0.50},
|
||||||
{"90 percent", 90, 0.90},
|
{"90 percent", 90, 0.90},
|
||||||
{"100 percent", 100, 1.0},
|
{"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
|
const epsilon = 1e-9
|
||||||
|
|||||||
Reference in New Issue
Block a user