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()