Files
radiantwave-legacy/internal/mixer/mixer.go
T
NeilClack 312f5e2a90
build-prod.yml / build (push) Failing after 2s
build-dev.yml / build (push) Failing after 2s
Rename rhemawave to radiantwave throughout
2026-08-08 01:20:11 -05:00

333 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}