Database is more usable now. All pages have been updated to use the database, and share an application configuration. Page Stack has been removed, no more pages hanging around in memory. Replaced with a 'Router' which uses an Enum to determine which page should be displayed, destroys the old page and initializes the new page all in one go. No more push, pop, rollback, etc. No more holding instances of pages. Logger has been updated with proper timestamps and sorting now works as expected."
340 lines
7.7 KiB
Go
340 lines
7.7 KiB
Go
// internal/audio/mixer/mixer.go
|
||
package mixer
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"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
|
||
currentVolume int // 0..128, defaults to MAX_VOLUME
|
||
|
||
// 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()
|
||
)
|
||
|
||
// 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
|
||
|
||
// Let SDL choose the best audio driver for the system
|
||
// Remove hardcoded ALSA preference to support PulseAudio, PipeWire, etc.
|
||
// Users can set SDL_AUDIODRIVER environment variable if needed
|
||
|
||
if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil {
|
||
return fmt.Errorf("SDL audio init failed: %w", err)
|
||
}
|
||
|
||
// WAV-only does not require mix.Init(); add when you support OGG/MP3/etc.
|
||
if err := openDeviceLocked(cfg.SelectedAudioDevice); err != nil {
|
||
// Clean up the subsystem on failure so callers can retry later.
|
||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||
return err
|
||
}
|
||
|
||
// TODO: Check if this logic is still needed; move to application
|
||
// if lastVolume < 0 {
|
||
// lastVolume = int(mix.MAX_VOLUME)
|
||
// currentVolume = lastVolume
|
||
// if err := db.SetConfigValue("last_volume", strconv.Itoa(lastVolume)); err != nil {
|
||
// }
|
||
// } else {
|
||
// currentVolume = lastVolume
|
||
// }
|
||
|
||
// mix.VolumeMusic(currentVolume)
|
||
|
||
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 {
|
||
if !cfg.AudioEnabled {
|
||
return nil
|
||
}
|
||
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
if !inited || !deviceOpen {
|
||
return errors.New("audio mixer not initialized")
|
||
}
|
||
if path == "" {
|
||
return errors.New("empty audio path")
|
||
}
|
||
|
||
// Stop and free any prior music
|
||
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
|
||
}
|
||
|
||
// Open the device
|
||
if err := openDeviceLocked(deviceName); err != nil {
|
||
logger.Error("Failed to open device: %w", err)
|
||
}
|
||
|
||
// Apply remembered volume
|
||
mix.VolumeMusic(currentVolume)
|
||
|
||
if err := currentMusic.Play(currentLoops); err != nil {
|
||
currentMusic.Free()
|
||
currentMusic = nil
|
||
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) {
|
||
if !cfg.AudioEnabled {
|
||
return
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 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()
|
||
defer mu.Unlock()
|
||
if !inited {
|
||
return false
|
||
}
|
||
return mix.PlayingMusic()
|
||
}
|
||
|
||
// 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 {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
return deviceName
|
||
}
|
||
|
||
// SwitchDevice switches output devices, preserving volume and resuming playback of the current track/loop state if any.
|
||
func SwitchDevice(newName string) error {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
if !inited {
|
||
return errors.New("audio mixer not initialized")
|
||
}
|
||
|
||
// Log device switch attempt
|
||
oldDevice := deviceName
|
||
if oldDevice == "" {
|
||
oldDevice = "system default"
|
||
}
|
||
newDeviceLog := newName
|
||
if newDeviceLog == "" {
|
||
newDeviceLog = "system default"
|
||
}
|
||
|
||
// Capture current state
|
||
wasPlaying := mix.PlayingMusic()
|
||
savedPath := currentPath
|
||
savedLoops := currentLoops
|
||
savedVolume := currentVolume
|
||
|
||
// Stop and release current music before reopening device
|
||
if wasPlaying {
|
||
}
|
||
mix.HaltMusic()
|
||
if currentMusic != nil {
|
||
currentMusic.Free()
|
||
currentMusic = nil
|
||
}
|
||
currentPath = ""
|
||
currentLoops = 0
|
||
|
||
// Close and reopen device
|
||
if deviceOpen {
|
||
mix.CloseAudio()
|
||
deviceOpen = false
|
||
}
|
||
if err := openDeviceLocked(newName); err != nil {
|
||
// Best effort: try to reopen the previous device (or default) so we don't leave audio dead
|
||
if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil {
|
||
} else {
|
||
}
|
||
return err
|
||
}
|
||
|
||
// Restore volume
|
||
mix.VolumeMusic(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)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func GetVolume128() int {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
return currentVolume
|
||
}
|
||
|
||
// 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 you add mix.Init(decoderFlags) in the future, pair it with mix.Quit() here.
|
||
if inited {
|
||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||
inited = false
|
||
}
|
||
}
|