package mixer import ( "errors" "fmt" "math" "sync" "github.com/veandco/go-sdl2/mix" "github.com/veandco/go-sdl2/sdl" "radiantwavetech.com/radiantwave/internal/db" "radiantwavetech.com/radiantwave/internal/logger" ) var ( mu sync.Mutex inited bool deviceOpen bool deviceName string currentMusic *mix.Music currentPath string currentLoops int currentVolume float64 defaultFreq = 48000 defaultFormat = uint16(sdl.AUDIO_F32) defaultChannels = 2 defaultChunkSize = 1024 defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE refreshCacheCallback func() persistConfigCallback func(*db.AppConfig) error ) // Init initializes SDL audio + SDL_mixer. Safe to call multiple times. // persistConfig persists the given config (used to save the selected audio // device across restarts); it may be nil, in which case device selections are // not saved. func Init(cfg *db.AppConfig, onUpdate func(), persistConfig func(*db.AppConfig) error) error { mu.Lock() defer mu.Unlock() if inited { return nil } refreshCacheCallback = onUpdate persistConfigCallback = persistConfig if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil { return fmt.Errorf("SDL audio init failed: %w", err) } err := mix.Init(mix.INIT_MP3 | mix.INIT_OGG) if err != nil { logger.Error("Failed to initialize MP3/OGG support: %v", err) } // A stored "default" (the first-run seed) is the system default. deviceName := cfg.SelectedAudioDevice if deviceName == "" || deviceName == "default" { deviceName = "" } else { // Honor a persisted device selection by resolving it against the // currently enumerated devices, falling back to the system default if // it is no longer present. if resolved, ok := resolvePreferredDevice(deviceName); ok { deviceName = resolved } else { logger.Warning("Selected audio device %q not found; using system default", cfg.SelectedAudioDevice) deviceName = "" } } if err := openDeviceLocked(deviceName); err != nil { sdl.QuitSubSystem(sdl.INIT_AUDIO) return err } currentVolume = cfg.SavedVolume v := int(math.Round(currentVolume * float64(mix.MAX_VOLUME))) mix.VolumeMusic(v) 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 != "" { // 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 { if !cfg.AudioEnabled { return nil } mu.Lock() defer mu.Unlock() 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") } mix.HaltMusic() if currentMusic != nil { currentMusic.Free() currentMusic = nil } m, err := mix.LoadMUS(path) if err != nil { return fmt.Errorf("LoadMUS(%q): %w", path, err) } currentMusic = m currentPath = path if loopForever { currentLoops = -1 } else { currentLoops = 0 } v := int(math.Round(currentVolume * float64(mix.MAX_VOLUME))) mix.VolumeMusic(v) logger.Info("Current volume is %d", v) 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) } 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() } // SetVolume sets the SDL_mixer music volume to level (0.0–1.0), mapped onto // SDL_mixer's 0–128 scale (mix.MAX_VOLUME). This is relative to the OS audio // sink, which the kiosk shell pins to 100%, so in-app volume is 0–100% of the // sink output. func SetVolume(level float64) error { if level < 0 || level > 1 { return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level) } mu.Lock() defer mu.Unlock() if !inited { return errors.New("audio mixer not initialized") } currentVolume = level v := int(math.Round(level * float64(mix.MAX_VOLUME))) mix.VolumeMusic(v) return nil } // ConvertVolume converts a desired volume percentage (0%, 25%, 33%, 90%, etc) to // the float64 value needed to pass to SetVolume 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 } // 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") } oldDevice := CurrentDevice() if newName == "" { newName = "" } wasPlaying := mix.PlayingMusic() savedPath := currentPath savedLoops := currentLoops mix.HaltMusic() if currentMusic != nil { currentMusic.Free() currentMusic = nil } currentPath = "" currentLoops = 0 if deviceOpen { mix.CloseAudio() deviceOpen = false } if err := openDeviceLocked(newName); err != nil { if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil { logger.Error("Failed to switch to device: %s", oldDevice) } return err } if cfg != nil { // Persist whatever device is actually open (openDeviceLocked falls // back to the system default if the requested name cannot be opened). cfg.SelectedAudioDevice = CurrentDevice() if persistConfigCallback != nil { if err := persistConfigCallback(cfg); err != nil { logger.Error("Failed to persist audio device selection: %v", err) } } currentVolume = cfg.SavedVolume v := int(math.Round(cfg.SavedVolume * float64(mix.MAX_VOLUME))) mix.VolumeMusic(v) } if wasPlaying && savedPath != "" { m, err := mix.LoadMUS(savedPath) if err != nil { 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() sdl.QuitSubSystem(sdl.INIT_AUDIO) inited = false } }