Replace in-memory atomic AppConfig caching with direct DB queries via db.GetConfig across application and page layers. This simplifies state management and ensures all components always operate on the latest persisted configuration. Key changes: - Remove Application.Config atomic cache and refreshCache mechanism - Eliminate PageCtx.CfgPtr and pass DB handle instead - Replace all cfg reads with db.GetConfig(app.DB) - Update mixer, audio, UI pages, and initialization paths accordingly - Explicitly set AppConfig table name to avoid GORM pluralization issues - Reduce DB connection pooling (SetMaxIdleConns(1)) - Standardize application font to SpaceMonoNerdFont-Regular - Lower default audio volume (1.0 → 0.2) - Add Unicode checkmark glyph to font map Behavioral impact: - Configuration is no longer cached in memory - Every config access now hits the DB (more consistent, slightly more overhead)
498 lines
12 KiB
Go
498 lines
12 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(0.20)
|
||
|
||
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
|
||
if !inited || !deviceOpen {
|
||
if !deviceOpen {
|
||
SwitchDevice("") // Re-switch to the default device
|
||
Play(path, loopForever, cfg) // Re-run the function
|
||
}
|
||
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
|
||
}
|
||
|
||
// ConvertVolume converts a desired volume percentage (0%, 25%, 33%, 90%, etc) to
|
||
// the float64 value needed to pass to SetAllSinkVolumes 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
|
||
}
|
||
|
||
// 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 {
|
||
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
|
||
// Automatically defaults to "system default"
|
||
oldDevice := CurrentDevice() // Which device is to be closed
|
||
if oldDevice == "" {
|
||
oldDevice = "system default"
|
||
}
|
||
if newName == "" { // Which device is being switched to?
|
||
newName = "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
|
||
}
|
||
|
||
// Attempt to open the newName device
|
||
if err := openDeviceLocked(newName); err != nil {
|
||
// If error switching to new device, switch back to the old device
|
||
if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil {
|
||
// We should not hit an error when switching back, but if we do...
|
||
logger.Error("Failed to switch to device: %s", oldDevice)
|
||
}
|
||
// Could not switch to the new device, return the error
|
||
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)
|
||
}
|
||
}
|
||
|
||
logger.Info("Audio device switched to %s", newName)
|
||
|
||
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
|
||
}
|
||
}
|