Initial release: RadiantWave v1.0
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
// internal/audio/mixer/mixer.go
|
||||
package mixer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"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
|
||||
)
|
||||
|
||||
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
|
||||
func Init(wantedDevice string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if inited {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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(wantedDevice); err != nil {
|
||||
// Clean up the subsystem on failure so callers can retry later.
|
||||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if the volume is stored in the config; if not, default to max.
|
||||
storedVolume, err := db.GetConfigValue("last_volume")
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving last_volume from config: %w", err)
|
||||
}
|
||||
|
||||
lastVolume, err := strconv.Atoi(storedVolume)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing last_volume %q: %w", storedVolume, err)
|
||||
}
|
||||
|
||||
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) error {
|
||||
if !db.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) {
|
||||
if !db.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) {
|
||||
if !db.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
|
||||
if err := db.SetConfigValue("last_volume", strconv.Itoa(currentVolume)); err != nil {
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user