// internal/audio/mixer/mixer.go package mixer import ( "bufio" "bytes" "errors" "fmt" "os/exec" "strconv" "strings" "sync" "github.com/veandco/go-sdl2/mix" "github.com/veandco/go-sdl2/sdl" "rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/logger" ) var ( mu sync.Mutex inited bool deviceOpen bool deviceName string currentMusic *mix.Music currentPath string currentLoops int // 0 = once, -1 = forever // Config knobs (could be made public or read from your config) defaultFreq = 48000 defaultFormat = uint16(sdl.AUDIO_F32) defaultChannels = 2 defaultChunkSize = 1024 defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE 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() defer mu.Unlock() if inited { return nil } refreshCacheCallback = onUpdate if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil { 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) if err != nil { logger.Error("Failed to initialize MP3/OGG support: %v", err) } deviceName := cfg.SelectedAudioDevice if deviceName == "default" { deviceName = "" } if err := openDeviceLocked(deviceName); err != nil { sdl.QuitSubSystem(sdl.INIT_AUDIO) return err } SetAllSinkVolumes(cfg.SavedVolume) inited = true return nil } // openDeviceLocked tries to open the named device; if it fails and the name wasn't empty, falls back to default. func openDeviceLocked(name string) error { if err := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, name, defaultAllow); err != nil { if name != "" { // Log the failure of the requested device // Fallback to default device 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) } deviceName = "" // actually opened default } else { return fmt.Errorf("failed to open default audio device: %w", err) } } else { if name != "" { } else { } deviceName = name } deviceOpen = true return nil } // 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. func Play(path string, loopForever bool, cfg *db.AppConfig) error { // Check if audio is enabled or disabled if !cfg.AudioEnabled { return nil } // Lock the mutex mu.Lock() defer mu.Unlock() // Check if mixer has been initialized and that a device is open if !inited { logger.Error("audio mixer not initialized") return errors.New("audio mixer not initialized") } if !deviceOpen { if err := openDeviceLocked(""); err != nil { return fmt.Errorf("failed to open audio device: %w", err) } } if path == "" { logger.Error("empty audio path") return errors.New("empty audio path") } // Stop and free any prior music mix.HaltMusic() if currentMusic != nil { currentMusic.Free() currentMusic = nil } // Load the music file m, err := mix.LoadMUS(path) if err != nil { return fmt.Errorf("LoadMUS(%q): %w", path, err) } // Set current music to the loaded music file currentMusic = m currentPath = path if loopForever { currentLoops = -1 } else { currentLoops = 0 } 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() currentMusic = nil logger.Error("music.Play failed: %v", err) return fmt.Errorf("music.Play: %w", err) } return nil } // 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). func Stop(fadeMs int, cfg *db.AppConfig) { mu.Lock() defer mu.Unlock() if !inited { return } if fadeMs > 0 && mix.PlayingMusic() { _ = mix.FadeOutMusic(fadeMs) // async fade; we’ll still free below } mix.HaltMusic() if currentMusic != nil { currentMusic.Free() currentMusic = nil } currentPath = "" currentLoops = 0 } // IsPlaying reports whether music is currently playing (or fading). func IsPlaying() bool { mu.Lock() defer mu.Unlock() if !inited { return false } 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 } // ConvertVolume converts a desired volume percentage (0%, 25%, 33%, 90%, etc) to // the float64 value needed to pass to SetAllSinkVolumes 0..1.0 // Percentages should be passed as integers. 25% -> 25 func ConvertVolume(volPerc int) float64 { if volPerc == 0 { return 0.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 ". ..." 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). func ListDevices() []string { mu.Lock() defer mu.Unlock() n := sdl.GetNumAudioDevices(false) if n <= 0 { return nil } out := make([]string, 0, n) for i := 0; i < n; i++ { if name := sdl.GetAudioDeviceName(i, false); name != "" { out = append(out, name) } } return out } // CurrentDevice returns the last requested/opened device name ("" means default). func CurrentDevice() string { return deviceName } // SwitchDevice switches output devices, preserving volume and resuming playback of the current track/loop state if any. func SwitchDevice(newName string, cfg *db.AppConfig) error { mu.Lock() defer mu.Unlock() if !inited { return errors.New("audio mixer not initialized") } // Log device switch attempt // Automatically defaults to "system default" 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 = "" } // Capture current state wasPlaying := mix.PlayingMusic() savedPath := currentPath savedLoops := currentLoops // Stop and release current music before reopening device mix.HaltMusic() if currentMusic != nil { currentMusic.Free() currentMusic = nil } currentPath = "" currentLoops = 0 // Close and reopen device if deviceOpen { mix.CloseAudio() deviceOpen = false } // Attempt to open the newName device 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 { // We should not hit an error when switching back, but if we do... logger.Error("Failed to switch to device: %s", oldDevice) } // Could not switch to the new device, return the error return err } // Restore saved volume after reopening device if cfg != nil { SetAllSinkVolumes(cfg.SavedVolume) } // Optionally resume previous track if there was one if wasPlaying && savedPath != "" { m, err := mix.LoadMUS(savedPath) 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) } currentMusic = m currentPath = savedPath currentLoops = savedLoops if err := currentMusic.Play(currentLoops); err != nil { currentMusic.Free() currentMusic = nil currentPath = "" currentLoops = 0 return fmt.Errorf("device switched, but restarting playback failed: %w", err) } } logger.Info("Audio device switched to %s", newName) return nil } func GetVolume128() int { mu.Lock() defer mu.Unlock() return mix.VolumeMusic(-1) } // Shutdown fully tears everything down. Call at app exit. func Shutdown() { mu.Lock() defer mu.Unlock() mix.HaltMusic() if currentMusic != nil { currentMusic.Free() currentMusic = nil } if deviceOpen { mix.CloseAudio() deviceOpen = false } if inited { mix.Quit() // Add this to clean up the decoders sdl.QuitSubSystem(sdl.INIT_AUDIO) inited = false } }