fix: resolve 4 critical audio mixer bugs (v0.5)
- SwitchDevice volume silently zeroed: Init uses cfg.SavedVolume, SwitchDevice restores saved volume after reopening device - SwitchDevice not persisted to database: caller saves SelectedAudioDevice - Device restore passes invalid 'system default' to SDL: now passes '' - Stop() no-op when audio toggled off: removed AudioEnabled guard Regression tests added for all 4 bugs. CI now runs go test before build.
This commit is contained in:
@@ -17,6 +17,11 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: |
|
||||||
|
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
||||||
|
nix develop --command go test ./internal/mixer/ -v -timeout 30s
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: |
|
||||||
|
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
||||||
|
nix develop --command go test ./internal/mixer/ -v -timeout 30s
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ Only one test suite exists: `internal/mixer/mixer_test.go`. Run with:
|
|||||||
go test ./internal/mixer/
|
go test ./internal/mixer/
|
||||||
```
|
```
|
||||||
|
|
||||||
No lint or typecheck commands are configured in the repo. CI only runs `nix build -L`.
|
No lint or typecheck commands are configured in the repo. CI runs `go test` then `nix build -L`.
|
||||||
|
|
||||||
|
**Every bug fix MUST include regression tests.** When fixing a bug, write tests that:
|
||||||
|
1. Reproduce the exact conditions that caused the bug (should pass with the fix, fail without it)
|
||||||
|
2. Verify the correct behavior after the fix
|
||||||
|
3. Cover edge cases related to the bug
|
||||||
|
|
||||||
|
Do not merge a bug fix without corresponding tests.
|
||||||
|
|
||||||
## Branch Model
|
## Branch Model
|
||||||
|
|
||||||
|
|||||||
+8
-19
@@ -7,12 +7,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Audio volume silently zeroed after switching output devices
|
- Play() deadlocks when called before device is open (recursive SwitchDevice → Play)
|
||||||
- Device switch not persisted to database across restarts
|
- Audio volume silently zeroed after switching output devices (Init used hardcoded 0.20; SwitchDevice did not restore saved volume)
|
||||||
- Device restore path passes invalid device name to SDL
|
- SwitchDevice not persisted to database across restarts (caller now saves to AppConfig)
|
||||||
- Stop() no-op when audio toggled off mid-playback
|
- Device restore path passes invalid device name "system default" to SDL (now passes "" for default)
|
||||||
|
- Stop() no-op when audio toggled off mid-playback (removed AudioEnabled guard from Stop)
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
- Active device highlight incorrect when using default device
|
- Active device highlight incorrect when using default device
|
||||||
- Play() deadlocks when called before device is open
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Human-readable audio device names (Built-in Speakers, HDMI/TV/Monitor)
|
- Human-readable audio device names (Built-in Speakers, HDMI/TV/Monitor)
|
||||||
@@ -21,17 +23,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
### Changed (Major Refactors)
|
### Changed (Major Refactors)
|
||||||
- Mixer package: inject audio backend dependencies for testability
|
- Mixer package: inject audio backend dependencies for testability
|
||||||
- Mixer package: extract wpctl calls behind an interface
|
- Mixer package: extract wpctl calls behind an interface
|
||||||
|
- SwitchDevice now takes *db.AppConfig to access saved volume
|
||||||
## [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
-11
@@ -69,7 +69,7 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
SetAllSinkVolumes(0.20)
|
SetAllSinkVolumes(cfg.SavedVolume)
|
||||||
|
|
||||||
inited = true
|
inited = true
|
||||||
return nil
|
return nil
|
||||||
@@ -163,10 +163,6 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error {
|
|||||||
// Stop stops playback. If fadeMs > 0, fades out asynchronously; otherwise halts immediately.
|
// Stop stops playback. If fadeMs > 0, fades out asynchronously; otherwise halts immediately.
|
||||||
// It also frees the currently loaded music (so a new Play will reload).
|
// It also frees the currently loaded music (so a new Play will reload).
|
||||||
func Stop(fadeMs int, cfg *db.AppConfig) {
|
func Stop(fadeMs int, cfg *db.AppConfig) {
|
||||||
if !cfg.AudioEnabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
if !inited {
|
if !inited {
|
||||||
@@ -390,7 +386,7 @@ func CurrentDevice() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SwitchDevice switches output devices, preserving volume and resuming playback of the current track/loop state if any.
|
// SwitchDevice switches output devices, preserving volume and resuming playback of the current track/loop state if any.
|
||||||
func SwitchDevice(newName string) error {
|
func SwitchDevice(newName string, cfg *db.AppConfig) error {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
@@ -401,9 +397,6 @@ func SwitchDevice(newName string) error {
|
|||||||
// Log device switch attempt
|
// Log device switch attempt
|
||||||
// Automatically defaults to "system default"
|
// Automatically defaults to "system default"
|
||||||
oldDevice := CurrentDevice() // Which device is to be closed
|
oldDevice := CurrentDevice() // Which device is to be closed
|
||||||
if oldDevice == "" {
|
|
||||||
oldDevice = "system default"
|
|
||||||
}
|
|
||||||
if newName == "" { // Which device is being switched to?
|
if newName == "" { // Which device is being switched to?
|
||||||
newName = "system default"
|
newName = "system default"
|
||||||
}
|
}
|
||||||
@@ -431,6 +424,7 @@ func SwitchDevice(newName string) error {
|
|||||||
// Attempt to open the newName device
|
// Attempt to open the newName device
|
||||||
if err := openDeviceLocked(newName); err != nil {
|
if err := openDeviceLocked(newName); err != nil {
|
||||||
// If error switching to new device, switch back to the old device
|
// If error switching to new device, switch back to the old device
|
||||||
|
// oldDevice is already "" for default (which openDeviceLocked expects)
|
||||||
if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil {
|
if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil {
|
||||||
// We should not hit an error when switching back, but if we do...
|
// We should not hit an error when switching back, but if we do...
|
||||||
logger.Error("Failed to switch to device: %s", oldDevice)
|
logger.Error("Failed to switch to device: %s", oldDevice)
|
||||||
@@ -439,8 +433,10 @@ func SwitchDevice(newName string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore volume to MAX after reopening device (matches Play() behavior)
|
// Restore saved volume after reopening device
|
||||||
mix.VolumeMusic(mix.MAX_VOLUME)
|
if cfg != nil {
|
||||||
|
SetAllSinkVolumes(cfg.SavedVolume)
|
||||||
|
}
|
||||||
|
|
||||||
// Optionally resume previous track if there was one
|
// Optionally resume previous track if there was one
|
||||||
if wasPlaying && savedPath != "" {
|
if wasPlaying && savedPath != "" {
|
||||||
|
|||||||
@@ -7,6 +7,107 @@ import (
|
|||||||
"rhemawavetech.com/rhemawave/internal/db"
|
"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)
|
||||||
|
}
|
||||||
|
if currentLoops != 0 {
|
||||||
|
t.Errorf("currentLoops = %d, want 0 after Stop with AudioEnabled=false", currentLoops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
mu.Lock()
|
||||||
|
inited = wasInited
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// TestPlayDoesNotDeadlockWhenUninitialized verifies that Play() returns an error
|
||||||
// (not a deadlock) when the mixer has not been initialized.
|
// (not a deadlock) when the mixer has not been initialized.
|
||||||
// The old code would deadlock because Play() held mu then called SwitchDevice()
|
// The old code would deadlock because Play() held mu then called SwitchDevice()
|
||||||
|
|||||||
@@ -273,9 +273,22 @@ func (p *AudioOptions) SelectAudioDevice(deviceIndex int) error {
|
|||||||
return fmt.Errorf("invalid device index: %d", deviceIndex)
|
return fmt.Errorf("invalid device index: %d", deviceIndex)
|
||||||
}
|
}
|
||||||
device := p.AudioDevices[deviceIndex]
|
device := p.AudioDevices[deviceIndex]
|
||||||
if err := mixer.SwitchDevice(device.Label); err != nil {
|
|
||||||
|
config, err := db.GetConfig(p.Context.DbPtr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get config for device switch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mixer.SwitchDevice(device.Label, config); err != nil {
|
||||||
return fmt.Errorf("failed to switch to device %s: %w", device.Label, err)
|
return fmt.Errorf("failed to switch to device %s: %w", device.Label, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the selected device to the database
|
||||||
|
config.SelectedAudioDevice = device.Label
|
||||||
|
if err := p.Context.DbPtr.Save(config).Error; err != nil {
|
||||||
|
logger.Error("failed to persist device selection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
p.activeDeviceIndex = deviceIndex
|
p.activeDeviceIndex = deviceIndex
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
)
|
)
|
||||||
|
|
||||||
var GitVersion = "Audio-Bug-Fix 0.1"
|
var GitVersion = "Audio-Bug-Fix 0.5"
|
||||||
|
|
||||||
type Home struct {
|
type Home struct {
|
||||||
Base
|
Base
|
||||||
|
|||||||
Reference in New Issue
Block a user