Set default volume to MAX

Set MAX volume for SDL2 Mixer, as well as the volume for all audio sinks via wpctl to 1.0.
This forces the computer to always be at absolute max volume, without creating scripts on the machine to run wpctl commands. Leaving the display or connected speakers to be in full control over the volume of music.
This commit is contained in:
NeilClack
2026-03-30 00:08:52 -05:00
parent 6e28b90ee8
commit aea646f788
3 changed files with 176 additions and 47 deletions
-9
View File
@@ -120,7 +120,6 @@ func Run() {
InitialFontSize: 128,
StandardFontSize: 32,
LinePattern: "fibonacci",
LastVolume: 128,
AudioEnabled: true,
}
if err := DB.Create(&initialConfig).Error; err != nil {
@@ -443,14 +442,6 @@ func (app *Application) initKeybinds() {
keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() {
app.Stop()
})
keybinds.Register(sdl.K_UP, sdl.KMOD_SHIFT, func() {
mixer.SetVolume128(volumeStepSize, app.Config.Load())
})
keybinds.Register(sdl.K_DOWN, sdl.KMOD_SHIFT, func() {
mixer.SetVolume128(-volumeStepSize, app.Config.Load())
})
}
func (app *Application) Stop() {
-1
View File
@@ -15,7 +15,6 @@ type AppConfig struct {
InitialFontSize int // How large to create the font atlas
StandardFontSize int // The font size used for most text rendering
LinePattern string // "fibonacci"
LastVolume int // 0-128
AudioEnabled bool
}
+176 -37
View File
@@ -2,8 +2,13 @@
package mixer
import (
"bufio"
"bytes"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"sync"
"github.com/veandco/go-sdl2/mix"
@@ -33,6 +38,13 @@ var (
refreshCacheCallback func()
)
type Sink struct {
ID int
Name string
Volume float64
IsDefault bool
}
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
func Init(cfg *db.AppConfig, onUpdate func()) error {
mu.Lock()
@@ -58,12 +70,7 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
return err
}
// FIX 2: Actually set a default volume!
currentVolume = 128 // MAX_VOLUME
if cfg.LastVolume > 0 {
currentVolume = cfg.LastVolume
}
mix.VolumeMusic(currentVolume)
SetAllSinkVolumes(1.0)
inited = true
return nil
@@ -131,10 +138,9 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error {
currentLoops = 0
}
// DELETE the openDeviceLocked block that was here!
// Apply remembered volume
mix.VolumeMusic(currentVolume)
mix.VolumeMusic(mix.MAX_VOLUME)
volume := mix.VolumeMusic(-1)
logger.Info("Current volume is %d", volume)
if err := currentMusic.Play(currentLoops); err != nil {
currentMusic.Free()
@@ -171,33 +177,6 @@ func Stop(fadeMs int, cfg *db.AppConfig) {
currentLoops = 0
}
// SetVolume128 sets music volume using SDL_mixer’s 0..128 scale.
func SetVolume128(increment int, cfg *db.AppConfig) {
if !cfg.AudioEnabled {
return
}
mu.Lock()
defer mu.Unlock()
currentVolume += increment
if currentVolume < 0 {
currentVolume = 0
} else if currentVolume > mix.MAX_VOLUME {
currentVolume = int(mix.MAX_VOLUME)
}
// Store in config
cfg.LastVolume = currentVolume
refreshCacheCallback()
// Apply to SDL_mixer
if inited && deviceOpen {
mix.VolumeMusic(currentVolume)
}
}
// IsPlaying reports whether music is currently playing (or fading).
func IsPlaying() bool {
mu.Lock()
@@ -208,6 +187,166 @@ func IsPlaying() bool {
return mix.PlayingMusic()
}
// SetDefaultSinkVolume sets volume 0.0–1.0 on the default sink
func SetDefaultSinkVolume(level float64) error {
if level < 0 || level > 1 {
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
}
pct := fmt.Sprintf("%.2f", level)
cmd := exec.Command("wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", pct)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("wpctl failed: %w\noutput: %s", err, out)
}
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)
}
}
return nil
}
// 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.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).
func ListDevices() []string {
mu.Lock()