From aea646f7886825fbf3c99765460e76e6639c6f86 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Mon, 30 Mar 2026 00:08:52 -0500 Subject: [PATCH] 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. --- internal/application/application.go | 9 -- internal/db/schema.go | 1 - internal/mixer/mixer.go | 213 +++++++++++++++++++++++----- 3 files changed, 176 insertions(+), 47 deletions(-) diff --git a/internal/application/application.go b/internal/application/application.go index f9827df..8ad82c5 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -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() { diff --git a/internal/db/schema.go b/internal/db/schema.go index 83231cf..235bea9 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -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 } diff --git a/internal/mixer/mixer.go b/internal/mixer/mixer.go index 01c4890..54880cc 100644 --- a/internal/mixer/mixer.go +++ b/internal/mixer/mixer.go @@ -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 ". ..." + 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()