333 lines
8.1 KiB
Go
333 lines
8.1 KiB
Go
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
|
||
}
|
||
}
|