481 lines
11 KiB
Go
481 lines
11 KiB
Go
// internal/audio/mixer/mixer.go
|
||
package mixer
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"errors"
|
||
"fmt"
|
||
"os/exec"
|
||
"strconv"
|
||
"strings"
|
||
"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()
|
||
)
|
||
|
||
type Sink struct {
|
||
ID int
|
||
Name string
|
||
Volume float64
|
||
IsDefault bool
|
||
}
|
||
|
||
// 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
|
||
|
||
if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil {
|
||
return fmt.Errorf("SDL audio init failed: %w", err)
|
||
}
|
||
|
||
// FIX 1: Initialize MP3 and OGG decoders
|
||
err := mix.Init(mix.INIT_MP3 | mix.INIT_OGG)
|
||
if err != nil {
|
||
logger.Error("Failed to initialize MP3/OGG support: %v", err)
|
||
}
|
||
|
||
if err := openDeviceLocked(cfg.SelectedAudioDevice); err != nil {
|
||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||
return err
|
||
}
|
||
|
||
SetAllSinkVolumes(1.0)
|
||
|
||
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 {
|
||
// Check if audio is enabled or disabled
|
||
if !cfg.AudioEnabled {
|
||
return nil
|
||
}
|
||
|
||
// Lock the mutex
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
// Check if mixer has been initialized and that a device is open
|
||
// JUMP: This is where the issue is...
|
||
if !inited || !deviceOpen {
|
||
logger.Error("audio mixer not initialized")
|
||
return errors.New("audio mixer not initialized")
|
||
}
|
||
if path == "" {
|
||
logger.Error("empty audio path")
|
||
return errors.New("empty audio path")
|
||
}
|
||
|
||
// Stop and free any prior music
|
||
mix.HaltMusic()
|
||
if currentMusic != nil {
|
||
currentMusic.Free()
|
||
currentMusic = nil
|
||
}
|
||
|
||
// Load the music file
|
||
m, err := mix.LoadMUS(path)
|
||
if err != nil {
|
||
return fmt.Errorf("LoadMUS(%q): %w", path, err)
|
||
}
|
||
// Set current music to the loaded music file
|
||
currentMusic = m
|
||
currentPath = path
|
||
if loopForever {
|
||
currentLoops = -1
|
||
} else {
|
||
currentLoops = 0
|
||
}
|
||
|
||
mix.VolumeMusic(mix.MAX_VOLUME)
|
||
volume := mix.VolumeMusic(-1)
|
||
logger.Info("Current volume is %d", volume)
|
||
|
||
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) {
|
||
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
|
||
}
|
||
|
||
// IsPlaying reports whether music is currently playing (or fading).
|
||
func IsPlaying() bool {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if !inited {
|
||
return false
|
||
}
|
||
return mix.PlayingMusic()
|
||
}
|
||
|
||
// SetDefaultSinkVolume sets volume 0.0–1.0 on the default sink
|
||
func SetDefaultSinkVolume(level float64) error {
|
||
if level < 0 || level > 1 {
|
||
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
|
||
}
|
||
pct := fmt.Sprintf("%.2f", level)
|
||
cmd := exec.Command("wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", pct)
|
||
if out, err := cmd.CombinedOutput(); err != nil {
|
||
return fmt.Errorf("wpctl failed: %w\noutput: %s", err, out)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// listSinkIDs returns just the numeric IDs of all audio sinks.
|
||
func listSinkIDs() ([]int, error) {
|
||
sinks, err := listSinks()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ids := make([]int, len(sinks))
|
||
for i, s := range sinks {
|
||
ids[i] = s.ID
|
||
}
|
||
return ids, nil
|
||
}
|
||
|
||
// listSinks returns full Sink info for all audio sinks.
|
||
func listSinks() ([]Sink, error) {
|
||
out, err := exec.Command("wpctl", "status").Output()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("wpctl status: %w", err)
|
||
}
|
||
return parseSinks(out)
|
||
}
|
||
|
||
// SetAllSinkVolumes sets volume on every audio sink found via wpctl status
|
||
func SetAllSinkVolumes(level float64) error {
|
||
ids, err := listSinkIDs()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
pct := fmt.Sprintf("%.2f", level)
|
||
for _, id := range ids {
|
||
cmd := exec.Command("wpctl", "set-volume", strconv.Itoa(id), pct)
|
||
if out, err := cmd.CombinedOutput(); err != nil {
|
||
return fmt.Errorf("wpctl set-volume %d failed: %w\noutput: %s", id, err, out)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// parseSinks parses the output of `wpctl status` and extracts audio sinks.
|
||
func parseSinks(data []byte) ([]Sink, error) {
|
||
var sinks []Sink
|
||
|
||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||
|
||
inAudioSection := false
|
||
inSinksSection := false
|
||
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
stripped := strings.TrimLeft(line, " │├└─")
|
||
|
||
// Detect top-level sections: "Audio", "Video", "Settings", etc.
|
||
// These are lines with no leading tree characters.
|
||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "│") {
|
||
inAudioSection = strings.TrimSpace(line) == "Audio"
|
||
inSinksSection = false
|
||
continue
|
||
}
|
||
|
||
if !inAudioSection {
|
||
continue
|
||
}
|
||
|
||
// Detect subsection headers within Audio
|
||
if strings.Contains(stripped, "Sinks:") {
|
||
inSinksSection = true
|
||
continue
|
||
}
|
||
// Any other subsection header resets sink parsing
|
||
if strings.HasSuffix(strings.TrimSpace(stripped), ":") {
|
||
inSinksSection = false
|
||
continue
|
||
}
|
||
|
||
if !inSinksSection {
|
||
continue
|
||
}
|
||
|
||
sink, ok := parseSinkLine(stripped)
|
||
if ok {
|
||
sinks = append(sinks, sink)
|
||
}
|
||
}
|
||
|
||
if err := scanner.Err(); err != nil {
|
||
return nil, fmt.Errorf("scanning wpctl output: %w", err)
|
||
}
|
||
|
||
return sinks, nil
|
||
}
|
||
|
||
// parseSinkLine parses a single sink entry line like:
|
||
//
|
||
// "*51. USB Audio [vol: 1.00]"
|
||
// "33. Built-in Audio Analog Stereo [vol: 0.65]"
|
||
func parseSinkLine(s string) (Sink, bool) {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return Sink{}, false
|
||
}
|
||
|
||
var sink Sink
|
||
|
||
// Leading '*' marks the default sink
|
||
if strings.HasPrefix(s, "*") {
|
||
sink.IsDefault = true
|
||
s = strings.TrimPrefix(s, "*")
|
||
s = strings.TrimSpace(s)
|
||
}
|
||
|
||
// Expect "<id>. <name> ..."
|
||
dotIdx := strings.Index(s, ".")
|
||
if dotIdx < 0 {
|
||
return Sink{}, false
|
||
}
|
||
|
||
idStr := strings.TrimSpace(s[:dotIdx])
|
||
id, err := strconv.Atoi(idStr)
|
||
if err != nil {
|
||
return Sink{}, false
|
||
}
|
||
sink.ID = id
|
||
|
||
rest := strings.TrimSpace(s[dotIdx+1:])
|
||
|
||
// Extract [vol: X.XX] if present
|
||
volStart := strings.Index(rest, "[vol:")
|
||
if volStart >= 0 {
|
||
sink.Name = strings.TrimSpace(rest[:volStart])
|
||
volPart := rest[volStart:]
|
||
// "[vol: 0.65]" or "[vol: 0.65 MUTED]"
|
||
volPart = strings.TrimPrefix(volPart, "[vol:")
|
||
volPart = strings.TrimSpace(volPart)
|
||
fields := strings.Fields(volPart)
|
||
if len(fields) > 0 {
|
||
v, err := strconv.ParseFloat(fields[0], 64)
|
||
if err == nil {
|
||
sink.Volume = v
|
||
}
|
||
}
|
||
} else {
|
||
sink.Name = strings.TrimSpace(rest)
|
||
}
|
||
|
||
return sink, true
|
||
}
|
||
|
||
// 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 inited {
|
||
mix.Quit() // Add this to clean up the decoders
|
||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||
inited = false
|
||
}
|
||
}
|