Replace wpctl volume control with SDL_mixer VolumeMusic

Remove all PipeWire/wpctl system calls (SetDefaultSinkVolume,
listSinkIDs, listSinks, SetAllSinkVolumes, parseSinks, parseSinkLine,
Sink struct). Volume is now managed entirely through SDL_mixer's
VolumeMusic(), making audio fully system-agnostic.

Changes:
- mixer.go: delete ~170 lines of wpctl code; add SetVolume() public
  API using mix.VolumeMusic(math.Round(level * MAX_VOLUME))
- Init/Play/SwitchDevice set volume inline (they already hold the mutex)
- Play() now uses stored currentVolume instead of hardcoded MAX_VOLUME
- AudioOptions: SetAllSinkVolumes -> SetVolume
- mixer_test.go: add TestSetVolume (validation + error path coverage)
This commit is contained in:
NeilClack
2026-07-29 14:58:15 -05:00
parent 7a3b6a66b7
commit 3480e7594a
3 changed files with 62 additions and 204 deletions
+27 -202
View File
@@ -1,14 +1,9 @@
// internal/audio/mixer/mixer.go
package mixer package mixer
import ( import (
"bufio"
"bytes"
"errors" "errors"
"fmt" "fmt"
"os/exec" "math"
"strconv"
"strings"
"sync" "sync"
"github.com/veandco/go-sdl2/mix" "github.com/veandco/go-sdl2/mix"
@@ -25,9 +20,10 @@ var (
currentMusic *mix.Music currentMusic *mix.Music
currentPath string currentPath string
currentLoops int // 0 = once, -1 = forever currentLoops int
currentVolume float64
// Config knobs (could be made public or read from your config)
defaultFreq = 48000 defaultFreq = 48000
defaultFormat = uint16(sdl.AUDIO_F32) defaultFormat = uint16(sdl.AUDIO_F32)
defaultChannels = 2 defaultChannels = 2
@@ -37,13 +33,6 @@ var (
refreshCacheCallback func() refreshCacheCallback func()
) )
type Sink struct {
ID int
Name string
Volume float64
IsDefault bool
}
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times. // Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
func Init(cfg *db.AppConfig, onUpdate func()) error { func Init(cfg *db.AppConfig, onUpdate func()) error {
mu.Lock() mu.Lock()
@@ -58,7 +47,6 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
return fmt.Errorf("SDL audio init failed: %w", err) return fmt.Errorf("SDL audio init failed: %w", err)
} }
// FIX 1: Initialize MP3 and OGG decoders
err := mix.Init(mix.INIT_MP3 | mix.INIT_OGG) err := mix.Init(mix.INIT_MP3 | mix.INIT_OGG)
if err != nil { if err != nil {
logger.Error("Failed to initialize MP3/OGG support: %v", err) logger.Error("Failed to initialize MP3/OGG support: %v", err)
@@ -73,7 +61,9 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
return err return err
} }
SetAllSinkVolumes(cfg.SavedVolume) currentVolume = cfg.SavedVolume
v := int(math.Round(currentVolume * float64(mix.MAX_VOLUME)))
mix.VolumeMusic(v)
inited = true inited = true
return nil return nil
@@ -83,8 +73,6 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
func openDeviceLocked(name string) error { func openDeviceLocked(name string) error {
if err := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, name, defaultAllow); err != nil { if err := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, name, defaultAllow); err != nil {
if name != "" { if name != "" {
// Log the failure of the requested device
// Fallback to default device // Fallback to default device
if err2 := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, "", defaultAllow); err2 != nil { if err2 := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, "", defaultAllow); err2 != nil {
return fmt.Errorf("failed to open device %q and default device: %w", name, err2) return fmt.Errorf("failed to open device %q and default device: %w", name, err2)
@@ -106,16 +94,13 @@ func openDeviceLocked(name string) error {
// Play loads a WAV (as music) and plays it. If loopForever is true, loops forever. // Play loads a WAV (as music) and plays it. If loopForever is true, loops forever.
// If something is already playing, it stops and replaces it. // If something is already playing, it stops and replaces it.
func Play(path string, loopForever bool, cfg *db.AppConfig) error { func Play(path string, loopForever bool, cfg *db.AppConfig) error {
// Check if audio is enabled or disabled
if !cfg.AudioEnabled { if !cfg.AudioEnabled {
return nil return nil
} }
// Lock the mutex
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
// Check if mixer has been initialized and that a device is open
if !inited { if !inited {
logger.Error("audio mixer not initialized") logger.Error("audio mixer not initialized")
return errors.New("audio mixer not initialized") return errors.New("audio mixer not initialized")
@@ -130,19 +115,16 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error {
return errors.New("empty audio path") return errors.New("empty audio path")
} }
// Stop and free any prior music
mix.HaltMusic() mix.HaltMusic()
if currentMusic != nil { if currentMusic != nil {
currentMusic.Free() currentMusic.Free()
currentMusic = nil currentMusic = nil
} }
// Load the music file
m, err := mix.LoadMUS(path) m, err := mix.LoadMUS(path)
if err != nil { if err != nil {
return fmt.Errorf("LoadMUS(%q): %w", path, err) return fmt.Errorf("LoadMUS(%q): %w", path, err)
} }
// Set current music to the loaded music file
currentMusic = m currentMusic = m
currentPath = path currentPath = path
if loopForever { if loopForever {
@@ -151,9 +133,9 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error {
currentLoops = 0 currentLoops = 0
} }
mix.VolumeMusic(mix.MAX_VOLUME) v := int(math.Round(currentVolume * float64(mix.MAX_VOLUME)))
volume := mix.VolumeMusic(-1) mix.VolumeMusic(v)
logger.Info("Current volume is %d", volume) logger.Info("Current volume is %d", v)
if err := currentMusic.Play(currentLoops); err != nil { if err := currentMusic.Play(currentLoops); err != nil {
currentMusic.Free() currentMusic.Free()
@@ -174,7 +156,7 @@ func Stop(fadeMs int, cfg *db.AppConfig) {
} }
if fadeMs > 0 && mix.PlayingMusic() { if fadeMs > 0 && mix.PlayingMusic() {
_ = mix.FadeOutMusic(fadeMs) // async fade; we’ll still free below _ = mix.FadeOutMusic(fadeMs)
} }
mix.HaltMusic() mix.HaltMusic()
@@ -196,59 +178,24 @@ func IsPlaying() bool {
return mix.PlayingMusic() return mix.PlayingMusic()
} }
// SetDefaultSinkVolume sets volume 0.0–1.0 on the default sink // SetVolume sets the SDL_mixer music volume to level (0.0–1.0).
func SetDefaultSinkVolume(level float64) error { func SetVolume(level float64) error {
if level < 0 || level > 1 { if level < 0 || level > 1 {
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level) return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
} }
pct := fmt.Sprintf("%.2f", level) mu.Lock()
cmd := exec.Command("wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", pct) defer mu.Unlock()
if out, err := cmd.CombinedOutput(); err != nil { if !inited {
return fmt.Errorf("wpctl failed: %w\noutput: %s", err, out) return errors.New("audio mixer not initialized")
}
return nil
}
// listSinkIDs returns just the numeric IDs of all audio sinks.
func listSinkIDs() ([]int, error) {
sinks, err := listSinks()
if err != nil {
return nil, err
}
ids := make([]int, len(sinks))
for i, s := range sinks {
ids[i] = s.ID
}
return ids, nil
}
// listSinks returns full Sink info for all audio sinks.
func listSinks() ([]Sink, error) {
out, err := exec.Command("wpctl", "status").Output()
if err != nil {
return nil, fmt.Errorf("wpctl status: %w", err)
}
return parseSinks(out)
}
// SetAllSinkVolumes sets volume on every audio sink found via wpctl status
func SetAllSinkVolumes(level float64) error {
ids, err := listSinkIDs()
if err != nil {
return err
}
pct := fmt.Sprintf("%.2f", level)
for _, id := range ids {
cmd := exec.Command("wpctl", "set-volume", strconv.Itoa(id), pct)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("wpctl set-volume %d failed: %w\noutput: %s", id, err, out)
}
} }
currentVolume = level
v := int(math.Round(level * float64(mix.MAX_VOLUME)))
mix.VolumeMusic(v)
return nil return nil
} }
// ConvertVolume converts a desired volume percentage (0%, 25%, 33%, 90%, etc) to // ConvertVolume converts a desired volume percentage (0%, 25%, 33%, 90%, etc) to
// the float64 value needed to pass to SetAllSinkVolumes 0..1.0 // the float64 value needed to pass to SetVolume 0..1.0
// Percentages should be passed as integers. 25% -> 25 // Percentages should be passed as integers. 25% -> 25
func ConvertVolume(volPerc int) float64 { func ConvertVolume(volPerc int) float64 {
if volPerc == 0 { if volPerc == 0 {
@@ -257,116 +204,6 @@ func ConvertVolume(volPerc int) float64 {
return float64(volPerc) / 100.0 return float64(volPerc) / 100.0
} }
// parseSinks parses the output of `wpctl status` and extracts audio sinks.
func parseSinks(data []byte) ([]Sink, error) {
var sinks []Sink
scanner := bufio.NewScanner(bytes.NewReader(data))
inAudioSection := false
inSinksSection := false
for scanner.Scan() {
line := scanner.Text()
stripped := strings.TrimLeft(line, " │├└─")
// Detect top-level sections: "Audio", "Video", "Settings", etc.
// These are lines with no leading tree characters.
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "│") {
inAudioSection = strings.TrimSpace(line) == "Audio"
inSinksSection = false
continue
}
if !inAudioSection {
continue
}
// Detect subsection headers within Audio
if strings.Contains(stripped, "Sinks:") {
inSinksSection = true
continue
}
// Any other subsection header resets sink parsing
if strings.HasSuffix(strings.TrimSpace(stripped), ":") {
inSinksSection = false
continue
}
if !inSinksSection {
continue
}
sink, ok := parseSinkLine(stripped)
if ok {
sinks = append(sinks, sink)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanning wpctl output: %w", err)
}
return sinks, nil
}
// parseSinkLine parses a single sink entry line like:
//
// "*51. USB Audio [vol: 1.00]"
// "33. Built-in Audio Analog Stereo [vol: 0.65]"
func parseSinkLine(s string) (Sink, bool) {
s = strings.TrimSpace(s)
if s == "" {
return Sink{}, false
}
var sink Sink
// Leading '*' marks the default sink
if strings.HasPrefix(s, "*") {
sink.IsDefault = true
s = strings.TrimPrefix(s, "*")
s = strings.TrimSpace(s)
}
// Expect "<id>. <name> ..."
dotIdx := strings.Index(s, ".")
if dotIdx < 0 {
return Sink{}, false
}
idStr := strings.TrimSpace(s[:dotIdx])
id, err := strconv.Atoi(idStr)
if err != nil {
return Sink{}, false
}
sink.ID = id
rest := strings.TrimSpace(s[dotIdx+1:])
// Extract [vol: X.XX] if present
volStart := strings.Index(rest, "[vol:")
if volStart >= 0 {
sink.Name = strings.TrimSpace(rest[:volStart])
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 {
v, err := strconv.ParseFloat(fields[0], 64)
if err == nil {
sink.Volume = v
}
}
} else {
sink.Name = strings.TrimSpace(rest)
}
return sink, true
}
// ListDevices returns the list of available output device names (from core SDL). // ListDevices returns the list of available output device names (from core SDL).
func ListDevices() []string { func ListDevices() []string {
mu.Lock() mu.Lock()
@@ -398,20 +235,15 @@ func SwitchDevice(newName string, cfg *db.AppConfig) error {
return errors.New("audio mixer not initialized") return errors.New("audio mixer not initialized")
} }
// Log device switch attempt oldDevice := CurrentDevice()
// Automatically defaults to "system default" if newName == "" {
oldDevice := CurrentDevice() // Which device is to be closed
// SDL expects "" for default device, not "system default"
if newName == "" { // Which device is being switched to?
newName = "" newName = ""
} }
// Capture current state
wasPlaying := mix.PlayingMusic() wasPlaying := mix.PlayingMusic()
savedPath := currentPath savedPath := currentPath
savedLoops := currentLoops savedLoops := currentLoops
// Stop and release current music before reopening device
mix.HaltMusic() mix.HaltMusic()
if currentMusic != nil { if currentMusic != nil {
currentMusic.Free() currentMusic.Free()
@@ -420,34 +252,27 @@ func SwitchDevice(newName string, cfg *db.AppConfig) error {
currentPath = "" currentPath = ""
currentLoops = 0 currentLoops = 0
// Close and reopen device
if deviceOpen { if deviceOpen {
mix.CloseAudio() mix.CloseAudio()
deviceOpen = false deviceOpen = false
} }
// 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
// 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...
logger.Error("Failed to switch to device: %s", oldDevice) logger.Error("Failed to switch to device: %s", oldDevice)
} }
// Could not switch to the new device, return the error
return err return err
} }
// Restore saved volume after reopening device
if cfg != nil { if cfg != nil {
SetAllSinkVolumes(cfg.SavedVolume) currentVolume = cfg.SavedVolume
v := int(math.Round(cfg.SavedVolume * float64(mix.MAX_VOLUME)))
mix.VolumeMusic(v)
} }
// Optionally resume previous track if there was one
if wasPlaying && savedPath != "" { if wasPlaying && savedPath != "" {
m, err := mix.LoadMUS(savedPath) m, err := mix.LoadMUS(savedPath)
if err != nil { if err != nil {
// Don't fail the device switch just because reload failed
return fmt.Errorf("device switched, but reloading %q failed: %w", savedPath, err) return fmt.Errorf("device switched, but reloading %q failed: %w", savedPath, err)
} }
currentMusic = m currentMusic = m
@@ -489,7 +314,7 @@ func Shutdown() {
} }
if inited { if inited {
mix.Quit() // Add this to clean up the decoders mix.Quit()
sdl.QuitSubSystem(sdl.INIT_AUDIO) sdl.QuitSubSystem(sdl.INIT_AUDIO)
inited = false inited = false
} }
+33
View File
@@ -7,6 +7,39 @@ import (
"rhemawavetech.com/rhemawave/internal/mixer" "rhemawavetech.com/rhemawave/internal/mixer"
) )
func TestSetVolume(t *testing.T) {
tests := []struct {
name string
level float64
wantErr bool
}{
{"zero volume", 0.0, false},
{"half volume", 0.5, false},
{"max volume", 1.0, false},
{"above range", 1.5, true},
{"negative", -0.1, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := mixer.SetVolume(tt.level)
if tt.wantErr {
if err == nil {
t.Errorf("SetVolume(%v) = nil, want error", tt.level)
}
return
}
if err != nil {
// For valid input, failure is expected when SDL isn't initialized
// (that's fine — we're testing validation, not SDL integration)
if err.Error() != "audio mixer not initialized" {
t.Errorf("SetVolume(%v) = %v, want 'audio mixer not initialized' or nil", tt.level, err)
}
}
})
}
}
func TestConvertVolume(t *testing.T) { func TestConvertVolume(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+2 -2
View File
@@ -333,8 +333,8 @@ func (p *AudioOptions) UpdateVolume(level int) {
} }
config.SavedVolume = newVol config.SavedVolume = newVol
p.Context.DbPtr.Save(&config) p.Context.DbPtr.Save(&config)
if err := mixer.SetAllSinkVolumes(newVol); err != nil { if err := mixer.SetVolume(newVol); err != nil {
logger.Error("Error setting allSinkVolumes: %v", err) logger.Error("Error setting volume: %v", err)
} }
if err := p.CreateVolumePercentItem(); err != nil { if err := p.CreateVolumePercentItem(); err != nil {
logger.Error("failed to refresh volume percent item: %v", err) logger.Error("failed to refresh volume percent item: %v", err)