Merge dev: AudioTrack table, download sync, audio fallback

This commit is contained in:
NeilClack
2026-07-29 16:19:24 -05:00
13 changed files with 713 additions and 598 deletions
+3 -11
View File
@@ -18,7 +18,7 @@
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfreePredicate = pkg: (nixpkgs.lib.getName pkg) == "rhemawave";
config.allowUnfreePredicate = pkg: (nixpkgs.lib.getName pkg) == "radiantwave";
};
buildInputs = with pkgs; [
@@ -27,8 +27,8 @@
SDL2_image
SDL2_mixer
sqlite
dbus
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
dbus
libGL
wayland
wayland-protocols
@@ -36,15 +36,7 @@
libdecor
libx11
libxext
] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (with pkgs.darwin.apple_sdk.frameworks; [
OpenGL
CoreAudio
CoreFoundation
CoreServices
ForceFeedback
IOKit
AppKit
]);
]; # Darwin: system SDK provides OpenGL, AppKit, etc. automatically
nativeBuildInputs = with pkgs; [
go
+1 -1
View File
@@ -195,7 +195,7 @@ func Run() {
}
}
go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, audioDir, "https://files.radiantwavetech.com/audio/")
go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, app.DB, audioDir, "https://files.radiantwavetech.com/audio/")
app.runEventLoop()
}
+1 -1
View File
@@ -107,6 +107,6 @@ func Migrate(db *gorm.DB) error {
&Proclamation{},
&ProclamationCategory{},
&SolfeggioFrequency{},
&AudioFile{},
&AudioTrack{},
)
}
+4 -2
View File
@@ -50,10 +50,12 @@ type SolfeggioFrequency struct {
Value int
}
type AudioFile struct {
type AudioTrack struct {
ID int `gorm:"primaryKey"`
Filename string
Name string
Path string
AssignedFrequency int
MD5 string
Selected bool
Order int
}
+71 -29
View File
@@ -10,6 +10,8 @@ import (
"path/filepath"
"strings"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/logger"
)
@@ -25,36 +27,36 @@ type DownloadStatus struct {
// manifest maps filename → expected MD5 hex string
type manifest map[string]string
// ─── Server script to generate checksums.md5 ─────────────────────────────────
//
// Run this on your file server whenever you add or replace audio files:
//
// cd /path/to/audio && md5sum *.mp3 > checksums.md5
//
// That's it. The app will pick up changes automatically on next run.
// ─────────────────────────────────────────────────────────────────────────────
const manifestFilename = "checksums.md5"
// DownloadAudioFiles downloads audio files, skipping any that already exist
// locally with a matching MD5. Files with a wrong checksum are deleted and
// re-downloaded. Progress is sent to progressChan, which is closed on return.
func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlBase string) {
// DownloadAudioFiles downloads audio files using the remote checksums.md5 as
// the source of truth. It:
// - Downloads any file in the manifest that is missing locally.
// - Re-downloads files whose local MD5 differs from the manifest.
// - Removes local files not listed in the manifest (orphans).
// - Removes DB AudioTrack records not listed in the manifest.
// - Updates AudioTrack.MD5 in the database after every successful download.
//
// Progress is sent to progressChan, which is closed on return.
func DownloadAudioFiles(progressChan chan<- DownloadStatus, dbHandle *gorm.DB, destDir string, urlBase string) {
defer close(progressChan)
send := func(cur, max int64, msg string, err error) {
progressChan <- DownloadStatus{Cur: cur, Max: max, Msg: msg, Err: err}
var totalDownloaded int64
var filesDownloaded int
send := func(cur int64, msg string, err error) {
progressChan <- DownloadStatus{Cur: cur, Max: 0, Msg: msg, Err: err}
}
send(0, 0, "Fetching file manifest...", nil)
send(0, "Fetching file manifest...", nil)
m, err := fetchManifest(urlBase + manifestFilename)
if err != nil {
send(0, 0, "Failed to fetch manifest", err)
send(0, "Failed to fetch manifest", err)
return
}
logger.Info("download: manifest loaded, %d files listed", len(m))
send(0, 0, "Comparing checksums...", nil)
send(0, "Comparing checksums...", nil)
type work struct {
filename string
@@ -82,19 +84,47 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB
}
}
// ── Remove local files not in manifest (orphans) ─────────────────────
entries, err := os.ReadDir(destDir)
if err == nil {
for _, entry := range entries {
if entry.IsDir() {
continue
}
if _, ok := m[entry.Name()]; !ok {
orphanPath := filepath.Join(destDir, entry.Name())
logger.Info("download: removing orphan file %s", orphanPath)
if err := os.Remove(orphanPath); err != nil {
logger.Warning("download: failed to remove orphan %s: %v", orphanPath, err)
}
}
}
} else {
logger.Warning("download: could not read destDir for orphan cleanup: %v", err)
}
// ── Remove DB records not in manifest ────────────────────────────────
if dbHandle != nil {
var allTracks []db.AudioTrack
dbHandle.Find(&allTracks)
for _, track := range allTracks {
if _, ok := m[track.Filename]; !ok {
logger.Info("download: removing orphan DB record %s (filename=%s)", track.Name, track.Filename)
dbHandle.Delete(&track)
}
}
}
if len(queue) == 0 {
send(0, 0, "All files are up to date.", nil)
send(0, "All files are up to date.", nil)
logger.Info("download: nothing to do")
return
}
total := len(queue)
send(0, 0, fmt.Sprintf("0 / %d Files Downloaded", total), nil)
send(0, fmt.Sprintf("0 / %d Files Downloaded", total), nil)
logger.Info("download: %d file(s) to download", total)
totalDownloaded := int64(0)
filesDownloaded := 0
for _, w := range queue {
url := urlBase + w.filename
destPath := filepath.Join(destDir, w.filename)
@@ -103,12 +133,12 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB
downloaded, err := downloadFile(url, destPath, func(n int64) {
totalDownloaded += n
send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), nil)
send(totalDownloaded, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), nil)
})
if err != nil {
logger.Error("download: failed to download %s: %v", w.filename, err)
send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), err)
send(totalDownloaded, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), err)
return
}
@@ -116,24 +146,35 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB
if err != nil {
logger.Error("download: could not hash %s after download: %v", w.filename, err)
os.Remove(destPath)
send(totalDownloaded, 0, "Checksum verification failed", err)
send(totalDownloaded, "Checksum verification failed", err)
return
}
if gotMD5 != w.expectedMD5 {
err := fmt.Errorf("checksum mismatch after download: got %s, want %s", gotMD5, w.expectedMD5)
logger.Error("download: %s %v", w.filename, err)
os.Remove(destPath)
send(totalDownloaded, 0, "Checksum verification failed — file removed", err)
send(totalDownloaded, "Checksum verification failed — file removed", err)
return
}
// Update MD5 in database
if dbHandle != nil {
var track db.AudioTrack
result := dbHandle.Where("filename = ?", w.filename).First(&track)
if result.Error == nil {
track.MD5 = gotMD5
dbHandle.Save(&track)
logger.Debug("download: updated MD5 for %s", w.filename)
}
}
filesDownloaded++
statusMsg := fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total)
logger.Info("download: %s (%d bytes)", statusMsg, downloaded)
send(totalDownloaded, 0, statusMsg, nil)
send(totalDownloaded, statusMsg, nil)
}
send(totalDownloaded, totalDownloaded, "All downloads complete!", nil)
send(totalDownloaded, "All downloads complete!", nil)
logger.Info("download: all files downloaded and verified")
}
@@ -167,6 +208,7 @@ func fetchManifest(url string) (manifest, error) {
}
hash := strings.TrimSpace(parts[0])
name := strings.TrimSpace(parts[1])
name = strings.TrimPrefix(name, "./")
m[name] = hash
}
+180
View File
@@ -0,0 +1,180 @@
package download_test
import (
"crypto/md5"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/download"
)
func md5Hex(data []byte) string {
return fmt.Sprintf("%x", md5.Sum(data))
}
// TestDownloadFileHashChanged verifies that when the server manifest reports a
// new MD5 for an existing local file, the old file is removed, the new version
// is downloaded, and the database record is updated with the new hash.
func TestDownloadFileHashChanged(t *testing.T) {
audioDir := t.TempDir()
dbDir := t.TempDir()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(dbDir, "test.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, err := gdb.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
if err := gdb.AutoMigrate(&db.AudioTrack{}); err != nil {
t.Fatal(err)
}
oldContent := []byte("this is the OLD version of the audio file")
oldMD5 := md5Hex(oldContent)
newContent := []byte("this is the NEW version of the audio file")
newMD5 := md5Hex(newContent)
track := db.AudioTrack{
Filename: "174Hz.wav",
Name: "174 Hz",
AssignedFrequency: 174,
MD5: oldMD5,
Selected: true,
Order: 2,
}
if err := gdb.Create(&track).Error; err != nil {
t.Fatal(err)
}
oldPath := filepath.Join(audioDir, "174Hz.wav")
if err := os.WriteFile(oldPath, oldContent, 0644); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/checksums.md5":
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "%s 174Hz.wav\n", newMD5)
case "/174Hz.wav":
w.WriteHeader(http.StatusOK)
w.Write(newContent)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
progress := make(chan download.DownloadStatus, 100)
done := make(chan struct{})
go func() {
download.DownloadAudioFiles(progress, gdb, audioDir, server.URL+"/")
close(done)
}()
for range progress {
}
<-done
newPath := filepath.Join(audioDir, "174Hz.wav")
gotContent, err := os.ReadFile(newPath)
if err != nil {
t.Fatalf("reading new file: %v", err)
}
if string(gotContent) != string(newContent) {
t.Errorf("new file content = %q, want %q", string(gotContent), string(newContent))
}
var updated db.AudioTrack
if err := gdb.First(&updated, track.ID).Error; err != nil {
t.Fatalf("fetching updated track: %v", err)
}
if updated.MD5 != newMD5 {
t.Errorf("DB MD5 = %q, want %q", updated.MD5, newMD5)
}
}
// TestDownloadOrphanRemoval verifies that files and DB records not in the
// manifest are removed.
func TestDownloadOrphanRemoval(t *testing.T) {
audioDir := t.TempDir()
dbDir := t.TempDir()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(dbDir, "test.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, err := gdb.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
if err := gdb.AutoMigrate(&db.AudioTrack{}); err != nil {
t.Fatal(err)
}
orphanPath := filepath.Join(audioDir, "orphan.wav")
if err := os.WriteFile(orphanPath, []byte("orphan"), 0644); err != nil {
t.Fatal(err)
}
gdb.Create(&db.AudioTrack{Filename: "ghost.wav", Name: "Ghost Track", Order: 99})
keptContent := []byte("kept file")
keptMD5 := md5Hex(keptContent)
keptPath := filepath.Join(audioDir, "kept.wav")
if err := os.WriteFile(keptPath, keptContent, 0644); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/checksums.md5":
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "%s kept.wav\n", keptMD5)
case "/kept.wav":
w.WriteHeader(http.StatusOK)
w.Write(keptContent)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
progress := make(chan download.DownloadStatus, 100)
done := make(chan struct{})
go func() {
download.DownloadAudioFiles(progress, gdb, audioDir, server.URL+"/")
close(done)
}()
for range progress {
}
<-done
if _, err := os.Stat(orphanPath); !os.IsNotExist(err) {
t.Errorf("orphan file %s should have been removed", orphanPath)
}
var count int64
gdb.Model(&db.AudioTrack{}).Where("filename = ?", "ghost.wav").Count(&count)
if count != 0 {
t.Errorf("orphan DB record ghost.wav should have been deleted, count=%d", count)
}
if _, err := os.Stat(keptPath); os.IsNotExist(err) {
t.Errorf("kept file %s should still exist after download", keptPath)
}
}
+27 -202
View File
@@ -1,14 +1,9 @@
// internal/audio/mixer/mixer.go
package mixer
import (
"bufio"
"bytes"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"math"
"sync"
"github.com/veandco/go-sdl2/mix"
@@ -25,9 +20,10 @@ var (
currentMusic *mix.Music
currentPath string
currentLoops int // 0 = once, -1 = forever
currentLoops int
currentVolume float64
// Config knobs (could be made public or read from your config)
defaultFreq = 48000
defaultFormat = uint16(sdl.AUDIO_F32)
defaultChannels = 2
@@ -37,13 +33,6 @@ var (
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()
@@ -58,7 +47,6 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
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)
@@ -73,7 +61,9 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
return err
}
SetAllSinkVolumes(cfg.SavedVolume)
currentVolume = cfg.SavedVolume
v := int(math.Round(currentVolume * float64(mix.MAX_VOLUME)))
mix.VolumeMusic(v)
inited = true
return nil
@@ -83,8 +73,6 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
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)
@@ -106,16 +94,13 @@ func openDeviceLocked(name string) error {
// 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 {
logger.Error("audio mixer not initialized")
return errors.New("audio mixer not initialized")
@@ -130,19 +115,16 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error {
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 {
@@ -151,9 +133,9 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error {
currentLoops = 0
}
mix.VolumeMusic(mix.MAX_VOLUME)
volume := mix.VolumeMusic(-1)
logger.Info("Current volume is %d", volume)
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()
@@ -174,7 +156,7 @@ func Stop(fadeMs int, cfg *db.AppConfig) {
}
if fadeMs > 0 && mix.PlayingMusic() {
_ = mix.FadeOutMusic(fadeMs) // async fade; we’ll still free below
_ = mix.FadeOutMusic(fadeMs)
}
mix.HaltMusic()
@@ -196,59 +178,24 @@ func IsPlaying() bool {
return mix.PlayingMusic()
}
// SetDefaultSinkVolume sets volume 0.0–1.0 on the default sink
func SetDefaultSinkVolume(level float64) error {
// SetVolume sets the SDL_mixer music volume to level (0.0–1.0).
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)
}
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)
}
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 SetAllSinkVolumes 0..1.0
// 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 {
@@ -257,116 +204,6 @@ func ConvertVolume(volPerc int) float64 {
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.TrimRight(volPart, "]")
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()
@@ -398,20 +235,15 @@ func SwitchDevice(newName string, cfg *db.AppConfig) error {
return errors.New("audio mixer not initialized")
}
// Log device switch attempt
// Automatically defaults to "system default"
oldDevice := CurrentDevice() // Which device is to be closed
// SDL expects "" for default device, not "system default"
if newName == "" { // Which device is being switched to?
oldDevice := CurrentDevice()
if newName == "" {
newName = ""
}
// Capture current state
wasPlaying := mix.PlayingMusic()
savedPath := currentPath
savedLoops := currentLoops
// Stop and release current music before reopening device
mix.HaltMusic()
if currentMusic != nil {
currentMusic.Free()
@@ -420,34 +252,27 @@ func SwitchDevice(newName string, cfg *db.AppConfig) error {
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
// oldDevice is already "" for default (which openDeviceLocked expects)
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 saved volume after reopening device
if cfg != nil {
SetAllSinkVolumes(cfg.SavedVolume)
currentVolume = cfg.SavedVolume
v := int(math.Round(cfg.SavedVolume * float64(mix.MAX_VOLUME)))
mix.VolumeMusic(v)
}
// 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
@@ -489,7 +314,7 @@ func Shutdown() {
}
if inited {
mix.Quit() // Add this to clean up the decoders
mix.Quit()
sdl.QuitSubSystem(sdl.INIT_AUDIO)
inited = false
}
-167
View File
@@ -142,171 +142,4 @@ func TestPlayDoesNotDeadlockWhenUninitialized(t *testing.T) {
}
}
func TestParseSinkLine(t *testing.T) {
tests := []struct {
name string
input string
wantOK bool
wantID int
wantName string
wantVol float64
wantDef bool
}{
{
name: "default sink with volume",
input: "*51. USB Audio [vol: 1.00]",
wantOK: true,
wantID: 51,
wantName: "USB Audio",
wantVol: 1.00,
wantDef: true,
},
{
name: "non-default sink with volume",
input: "33. Built-in Audio Analog Stereo [vol: 0.65]",
wantOK: true,
wantID: 33,
wantName: "Built-in Audio Analog Stereo",
wantVol: 0.65,
wantDef: false,
},
{
name: "default sink with muted tag",
input: "*51. USB Audio [vol: 0.50 MUTED]",
wantOK: true,
wantID: 51,
wantName: "USB Audio",
wantVol: 0.50,
wantDef: true,
},
{
name: "sink without volume field",
input: "33. Built-in Audio Analog Stereo",
wantOK: true,
wantID: 33,
wantName: "Built-in Audio Analog Stereo",
wantVol: 0.0,
wantDef: false,
},
{
name: "empty string",
input: "",
wantOK: false,
},
{
name: "no dot separator",
input: "no dot here",
wantOK: false,
},
{
name: "non-numeric ID",
input: "abc. Some Device [vol: 0.80]",
wantOK: false,
},
{
name: "zero volume",
input: "1. Muted Device [vol: 0.00]",
wantOK: true,
wantID: 1,
wantName: "Muted Device",
wantVol: 0.0,
wantDef: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sink, ok := parseSinkLine(tt.input)
if ok != tt.wantOK {
t.Fatalf("parseSinkLine(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
}
if !ok {
return
}
if sink.ID != tt.wantID {
t.Errorf("ID = %d, want %d", sink.ID, tt.wantID)
}
if sink.Name != tt.wantName {
t.Errorf("Name = %q, want %q", sink.Name, tt.wantName)
}
if sink.Volume != tt.wantVol {
t.Errorf("Volume = %f, want %f", sink.Volume, tt.wantVol)
}
if sink.IsDefault != tt.wantDef {
t.Errorf("IsDefault = %v, want %v", sink.IsDefault, tt.wantDef)
}
})
}
}
func TestParseSinks(t *testing.T) {
tests := []struct {
name string
input string
wantCount int
wantNames []string
}{
{
name: "two sinks",
input: `Audio
Sinks:
*51. USB Audio [vol: 1.00]
33. Built-in Audio Analog Stereo [vol: 0.65]
Sources:
52. Microphone [vol: 0.80]`,
wantCount: 2,
wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"},
},
{
name: "no sinks section",
input: `Audio
Sources:
52. Microphone [vol: 0.80]`,
wantCount: 0,
},
{
name: "sinks with tree characters",
input: `Audio
├─Sinks:
│ ├─*51. USB Audio [vol: 1.00]
│ └─33. Built-in Audio Analog Stereo [vol: 0.65]
└─Sources:
└─52. Microphone [vol: 0.80]`,
wantCount: 2,
wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"},
},
{
name: "empty input",
input: ``,
wantCount: 0,
},
{
name: "video section ignored",
input: `Video
Sinks:
100. HDMI Output [vol: 1.00]
Audio
Sinks:
51. USB Audio [vol: 1.00]`,
wantCount: 1,
wantNames: []string{"USB Audio"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sinks, err := parseSinks([]byte(tt.input))
if err != nil {
t.Fatalf("parseSinks returned error: %v", err)
}
if len(sinks) != tt.wantCount {
t.Fatalf("got %d sinks, want %d", len(sinks), tt.wantCount)
}
for i, name := range tt.wantNames {
if sinks[i].Name != name {
t.Errorf("sink[%d].Name = %q, want %q", i, sinks[i].Name, name)
}
}
})
}
}
+33
View File
@@ -7,6 +7,39 @@ import (
"rhemawavetech.com/rhemawave/internal/mixer"
)
func TestSetVolume(t *testing.T) {
tests := []struct {
name string
level float64
wantErr bool
}{
{"zero volume", 0.0, false},
{"half volume", 0.5, false},
{"max volume", 1.0, false},
{"above range", 1.5, true},
{"negative", -0.1, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := mixer.SetVolume(tt.level)
if tt.wantErr {
if err == nil {
t.Errorf("SetVolume(%v) = nil, want error", tt.level)
}
return
}
if err != nil {
// For valid input, failure is expected when SDL isn't initialized
// (that's fine — we're testing validation, not SDL integration)
if err.Error() != "audio mixer not initialized" {
t.Errorf("SetVolume(%v) = %v, want 'audio mixer not initialized' or nil", tt.level, err)
}
}
})
}
}
func TestConvertVolume(t *testing.T) {
tests := []struct {
name string
+229 -126
View File
@@ -15,53 +15,45 @@ import (
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
// focusToggle is the sentinel focusState value meaning the audio on/off toggle
// row has keyboard focus. Audio-file indices (0..n-1) are used for everything below.
const focusToggle = -1
// AudioFileList is kept as the canonical seed data for the audio_files table.
var AudioFileList = []db.AudioFile{
{ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false, Order: 1},
{ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false, Order: 2},
{ID: 2, Name: "396 Hz", Path: "396Hz.wav", Selected: false, Order: 3},
{ID: 3, Name: "417 Hz", Path: "417Hz.wav", Selected: false, Order: 4},
{ID: 4, Name: "432 Hz", Path: "432Hz.wav", Selected: false, Order: 5},
{ID: 5, Name: "639 Hz", Path: "639Hz.wav", Selected: false, Order: 6},
{ID: 6, Name: "741 Hz", Path: "741Hz.wav", Selected: false, Order: 7},
{ID: 7, Name: "852 Hz", Path: "852Hz.wav", Selected: false, Order: 8},
{ID: 8, Name: "963 Hz", Path: "963Hz.wav", Selected: false, Order: 9},
{ID: 9, Name: "1111 Hz", Path: "1111Hz.wav", Selected: false, Order: 10},
{ID: 10, Name: "14 Hz Binaural Beat", Path: "14Hz_Binaural.wav", Selected: false, Order: 11},
{ID: 11, Name: "40 Hz Binaural Beat", Path: "40Hz_Binaural.wav", Selected: false, Order: 12},
{ID: 12, Name: "Alpha Waves", Path: "AlphaWaves.wav", Selected: false, Order: 13},
{ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false, Order: 14},
}
const triangleSize = float32(16)
var audioTrackSeed = []db.AudioTrack{
{Filename: "OriginalAudio.wav", Name: "Original Audio", AssignedFrequency: 0, MD5: "d39c2676c315438752ab0c55e9427d74", Order: 1},
{Filename: "174Hz.wav", Name: "174 Hz", AssignedFrequency: 174, MD5: "42abaf332e628b646ac2cc9d1b5385e7", Order: 2},
{Filename: "396Hz.wav", Name: "396 Hz", AssignedFrequency: 396, MD5: "d53d45849b042122a3c177fad95ff499", Order: 3},
{Filename: "417Hz.wav", Name: "417 Hz", AssignedFrequency: 417, MD5: "056a7aae0b53111f5c568ea7ecd1fadb", Order: 4},
{Filename: "432Hz.wav", Name: "432 Hz", AssignedFrequency: 432, MD5: "276c57179372404ff59c35024911468e", Order: 5},
{Filename: "639Hz.wav", Name: "639 Hz", AssignedFrequency: 639, MD5: "4e460148230f4417fef39e9817f75e06", Order: 6},
{Filename: "741Hz.wav", Name: "741 Hz", AssignedFrequency: 741, MD5: "cfbd9b03b539db69bcefdd91cedc9ad1", Order: 7},
{Filename: "852Hz.wav", Name: "852 Hz", AssignedFrequency: 852, MD5: "485700296d017f8a1b0a7583a55a7783", Order: 8},
{Filename: "963Hz.wav", Name: "963 Hz", AssignedFrequency: 963, MD5: "2670e13294daef8490a862c078aa8af4", Order: 9},
{Filename: "1111Hz.wav", Name: "1111 Hz", AssignedFrequency: 1111, MD5: "67bbc7619dc21d0750519113472ca335", Order: 10},
{Filename: "14Hz_Binaural.wav", Name: "14 Hz Binaural Beat", AssignedFrequency: 14, MD5: "807c14866640d2ab27a6f5d7fdd24e77", Order: 11},
{Filename: "40Hz_Binaural.wav", Name: "40 Hz Binaural Beat", AssignedFrequency: 40, MD5: "42d60d55a16db4e9b29da47a5a102f2c", Order: 12},
{Filename: "AlphaWaves.wav", Name: "Alpha Waves", AssignedFrequency: 0, MD5: "07a93fa0e633a00d97b8a5e5a259d6ff", Order: 13},
{Filename: "PinealGland.wav", Name: "Pineal Gland", AssignedFrequency: 0, MD5: "998d74e3826b30086d64ff0df7b9f133", Order: 14},
}
type AudioOptions struct {
// Page Attributes
Base
font *fontManager.FontEntry
textShader *shaderManager.Shader
solidShader *shaderManager.Shader
// Page Textures
title StringItem
prompt StringItem
// Layout
verticalMargin int32
ItemScale float32
// Audio toggle
AudioStatusToggle StringItem
// Audio file list — index 0 = "Dynamic Audio", index 1+ = AudioFileList entries
audioFileTextures []StringItem
selectedFileIndex int // which item is currently selected (highlights green)
audioTracks []db.AudioTrack
selectedFileIndex int
// Volume row
VolumeLabel StringItem
volumePercent StringItem
volumeBarX int32
@@ -69,25 +61,42 @@ type AudioOptions struct {
volumeBarW int32
volumeBarH int32
// Triangle cursor
triVAO uint32
triVBO uint32
triX float32
triCenterY float32
spinElapsed float32
// Focus: focusToggle (-1) = toggle row, 0..n-1 = audio file list item
focusState int
}
// ── Init ──────────────────────────────────────────────────────────────────────
sectionHeader StringItem
scrollOffset int
maxVisible int
listItemMargin int32
listAreaTop int32
listAreaBottom int32
listItemHeight int32
scrollTrackWidth int32
scrollTrackX int32
scrollTrackY int32
scrollTrackH int32
dotSize float32
listLeftX int32
}
func (p *AudioOptions) Init(ctx *PageCtx) error {
var err error
p.verticalMargin = int32(20)
p.listItemMargin = 6
p.focusState = focusToggle
p.scrollOffset = 0
p.dotSize = 8.0
p.scrollTrackWidth = 30
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
// Font
fm := fontManager.Get()
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
if !ok {
@@ -95,7 +104,6 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
}
p.font = font
// Shaders
sm := shaderManager.Get()
p.textShader, ok = sm.Get("text")
if !ok {
@@ -119,14 +127,12 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
titleScale := float32(64) / float32(baseFontSize)
p.ItemScale = float32(32) / float32(baseFontSize)
// Title
p.title, err = NewStringItem("Audio Options", p.font, colors.White)
if err != nil {
return fmt.Errorf("creating title: %w", err)
}
p.title.Scale(titleScale)
// Prompt
p.prompt, err = NewStringItem(
"Enter to select -- Up/Down to navigate -- Left/Right to change volume -- Backspace to go back",
p.font, colors.White,
@@ -136,7 +142,6 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
}
p.prompt.Scale(p.ItemScale)
// Volume row
p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White)
if err != nil {
return fmt.Errorf("creating volume label: %w", err)
@@ -146,20 +151,16 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
logger.Error("failed to create VolumePercentItem in AudioOptions.Init()")
}
// Audio toggle
if err = p.CreateAudioStatusItem(); err != nil {
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
}
// Make sure the audio_files table is populated.
if err = p.seedAudioFiles(); err != nil {
logger.Error("failed to seed audio files: %v", err)
if err = p.seedAudioTracks(); err != nil {
logger.Error("failed to seed audio tracks: %v", err)
}
// Build the audio file list textures: "Dynamic Audio" + AudioFileList
p.buildAudioFileList()
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords
triVerts := []float32{
0.5, 0.0,
-0.5, 0.5,
@@ -175,20 +176,26 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
gl.BindVertexArray(0)
p.sectionHeader, err = NewStringItem("Audio Track", p.font, colors.Silver)
if err != nil {
return fmt.Errorf("creating section header: %w", err)
}
p.sectionHeader.Scale(p.ItemScale * 1.2)
p.layout()
return nil
}
// buildAudioFileList creates textures for the audio file list.
// Index 0 = "Dynamic Audio", index 1+ = AudioFileList entries.
func (p *AudioOptions) buildAudioFileList() {
// Clear old textures
for i := range p.audioFileTextures {
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
}
p.audioFileTextures = nil
// "Dynamic Audio" entry at index 0
var tracks []db.AudioTrack
p.Context.DbPtr.Order("`order` ASC").Find(&tracks)
p.audioTracks = tracks
dynamicItem, err := NewStringItem("Dynamic Audio", p.font, colors.White)
if err != nil {
logger.Error("failed to create Dynamic Audio string item: %v", err)
@@ -197,26 +204,23 @@ func (p *AudioOptions) buildAudioFileList() {
p.audioFileTextures = append(p.audioFileTextures, dynamicItem)
}
// AudioFileList entries at index 1+
for _, af := range AudioFileList {
item, err := NewStringItem(af.Name, p.font, colors.White)
for _, track := range tracks {
item, err := NewStringItem(track.Name, p.font, colors.White)
if err != nil {
logger.Error("failed to create audio file string item for %s: %v", af.Name, err)
logger.Error("failed to create audio track string item for %s: %v", track.Name, err)
continue
}
item.Scale(p.ItemScale)
p.audioFileTextures = append(p.audioFileTextures, item)
}
// Determine which item is currently selected
p.updateSelectedIndex()
}
// updateSelectedIndex sets selectedFileIndex based on the DB state.
func (p *AudioOptions) updateSelectedIndex() {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
p.selectedFileIndex = 0 // default to Dynamic Audio
p.selectedFileIndex = 0
return
}
@@ -225,40 +229,33 @@ func (p *AudioOptions) updateSelectedIndex() {
return
}
// Find the audio file with Selected = true
var selected db.AudioFile
var selected db.AudioTrack
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
// No selection found — default to Dynamic Audio
p.selectedFileIndex = 0
return
}
// Map the selected AudioFile ID to its index in the list
for i, af := range AudioFileList {
if af.ID == selected.ID {
p.selectedFileIndex = i + 1 // +1 because index 0 is "Dynamic Audio"
for i, track := range p.audioTracks {
if track.ID == selected.ID {
p.selectedFileIndex = i + 1
return
}
}
// Fallback — file not in list
p.selectedFileIndex = 0
}
// seedAudioFiles ensures the audio_files table has its default rows.
func (p *AudioOptions) seedAudioFiles() error {
var existing []db.AudioFile
func (p *AudioOptions) seedAudioTracks() error {
var existing []db.AudioTrack
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
return err
}
if len(existing) == 0 {
return p.Context.DbPtr.Save(&AudioFileList).Error
return p.Context.DbPtr.Save(&audioTrackSeed).Error
}
return nil
}
// ── Audio status toggle ───────────────────────────────────────────────────────
func (p *AudioOptions) CreateAudioStatusItem() error {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
@@ -280,9 +277,6 @@ func (p *AudioOptions) CreateAudioStatusItem() error {
return nil
}
// ── Audio file selection ─────────────────────────────────────────────────────
// selectAudioFile handles the user pressing Enter on an audio file list item.
func (p *AudioOptions) selectAudioFile(index int) {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
@@ -290,19 +284,14 @@ func (p *AudioOptions) selectAudioFile(index int) {
}
if index == 0 {
// "Dynamic Audio" selected
config.DynamicAudio = true
// Clear all file selections
p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false)
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false)
} else {
// Specific audio file selected
config.DynamicAudio = false
fileIndex := index - 1 // offset for "Dynamic Audio" entry
if fileIndex >= 0 && fileIndex < len(AudioFileList) {
// Clear all selections first
p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false)
// Select the chosen file
p.Context.DbPtr.Model(&db.AudioFile{}).Where("id = ?", AudioFileList[fileIndex].ID).Update("selected", true)
trackIndex := index - 1
if trackIndex >= 0 && trackIndex < len(p.audioTracks) {
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false)
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("id = ?", p.audioTracks[trackIndex].ID).Update("selected", true)
}
}
@@ -313,8 +302,6 @@ func (p *AudioOptions) selectAudioFile(index int) {
p.selectedFileIndex = index
}
// ── Volume ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) CreateVolumePercentItem() error {
if p.volumePercent.ID != 0 {
gl.DeleteTextures(1, &p.volumePercent.ID)
@@ -347,8 +334,8 @@ func (p *AudioOptions) UpdateVolume(level int) {
}
config.SavedVolume = newVol
p.Context.DbPtr.Save(&config)
if err := mixer.SetAllSinkVolumes(newVol); err != nil {
logger.Error("Error setting allSinkVolumes: %v", err)
if err := mixer.SetVolume(newVol); err != nil {
logger.Error("Error setting volume: %v", err)
}
if err := p.CreateVolumePercentItem(); err != nil {
logger.Error("failed to refresh volume percent item: %v", err)
@@ -358,23 +345,18 @@ func (p *AudioOptions) UpdateVolume(level int) {
p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2
}
// ── Layout ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) layout() {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
// Title
p.title.X = p.ScreenCenterX - p.title.W/2
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
// Prompt
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
// Volume row
barH := int32(float32(config.StandardFontSize) * p.ItemScale)
barW := int32(float32(p.ScreenWidth) * 0.30)
barX := p.ScreenCenterX - barW/2
@@ -392,25 +374,64 @@ func (p *AudioOptions) layout() {
p.volumePercent.X = barX + barW + gap
p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2
// Audio toggle — centred below the volume row
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
// Audio file list — each item centred and stacked below the toggle
nextY := p.AudioStatusToggle.Y - p.verticalMargin
for i := range p.audioFileTextures {
item := &p.audioFileTextures[i]
item.X = p.ScreenCenterX - item.W/2
item.Y = nextY - item.H
nextY = item.Y - p.verticalMargin
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2
p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H
p.listItemHeight = p.audioFileTextures[0].H
listTop := p.sectionHeader.Y - p.listItemMargin
listBottomMargin := int32(12)
listH := listTop - listBottomMargin
if listH < p.listItemHeight {
listH = p.listItemHeight
}
// Reposition the triangle cursor for whatever row currently has focus
p.listAreaTop = listTop
p.listAreaBottom = listBottomMargin
itemStep := p.listItemHeight + p.listItemMargin
n := len(p.audioFileTextures)
p.maxVisible = int(listH / itemStep)
if p.maxVisible < 1 {
p.maxVisible = 1
}
if p.maxVisible > n {
p.maxVisible = n
}
var maxItemW int32
for i := range p.audioFileTextures {
w := p.audioFileTextures[i].W
if w > maxItemW {
maxItemW = w
}
}
p.listLeftX = p.ScreenCenterX - maxItemW/2
firstVisibleY := p.listAreaTop - p.listItemHeight
for i := range p.audioFileTextures {
item := &p.audioFileTextures[i]
item.X = p.listLeftX
item.Y = firstVisibleY - int32(i-p.scrollOffset)*itemStep
}
maxScroll := n - p.maxVisible
if maxScroll < 0 {
maxScroll = 0
}
if p.scrollOffset > maxScroll {
p.scrollOffset = maxScroll
}
p.scrollTrackY = p.listAreaBottom
p.scrollTrackH = p.listAreaTop - p.listAreaBottom
p.updateCursorPosition()
}
// updateCursorPosition moves triX / triCenterY to sit just left of the
// focused row. Call this any time focusState changes or layout() reruns.
func (p *AudioOptions) updateCursorPosition() {
cursorGap := int32(12)
@@ -428,8 +449,6 @@ func (p *AudioOptions) updateCursorPosition() {
p.triCenterY = float32(item.Y) + float32(item.H)/2
}
// ── Toggle ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) ToggleAudio() {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
@@ -442,32 +461,38 @@ func (p *AudioOptions) ToggleAudio() {
p.layout()
}
// ── Events ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
e, ok := (*event).(*sdl.KeyboardEvent)
if !ok || e.Type != sdl.KEYDOWN {
return nil
}
n := len(p.audioFileTextures)
switch e.Keysym.Sym {
case sdl.K_RETURN:
if p.focusState == focusToggle {
p.ToggleAudio()
} else if p.focusState >= 0 && p.focusState < len(p.audioFileTextures) {
} else if p.focusState >= 0 && p.focusState < n {
p.selectAudioFile(p.focusState)
p.updateCursorPosition()
}
case sdl.K_DOWN:
if p.focusState == focusToggle {
if len(p.audioFileTextures) > 0 {
if n > 0 {
p.focusState = 0
p.scrollOffset = 0
p.layout()
}
} else if p.focusState < n-1 {
p.focusState++
if p.focusState >= p.scrollOffset+p.maxVisible {
p.scrollOffset++
p.layout()
} else {
p.updateCursorPosition()
}
} else if p.focusState < len(p.audioFileTextures)-1 {
p.focusState++
p.updateCursorPosition()
}
case sdl.K_UP:
@@ -476,8 +501,36 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
p.updateCursorPosition()
} else if p.focusState > 0 {
p.focusState--
if p.focusState < p.scrollOffset {
p.scrollOffset--
p.layout()
} else {
p.updateCursorPosition()
}
}
case sdl.K_PAGEUP:
if p.focusState > 0 {
p.focusState -= p.maxVisible - 1
if p.focusState < 0 {
p.focusState = 0
}
p.scrollOffset = p.focusState
p.layout()
}
case sdl.K_PAGEDOWN:
if p.focusState < n-1 {
p.focusState += p.maxVisible - 1
if p.focusState >= n {
p.focusState = n - 1
}
p.scrollOffset = p.focusState - p.maxVisible + 1
if p.scrollOffset < 0 {
p.scrollOffset = 0
}
p.layout()
}
case sdl.K_RIGHT:
p.UpdateVolume(10)
@@ -492,8 +545,6 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
return nil
}
// ── Update ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) Update(dt float32) error {
p.spinElapsed += dt
if p.spinElapsed >= 1.0 {
@@ -502,8 +553,6 @@ func (p *AudioOptions) Update(dt float32) error {
return nil
}
// ── Render helpers ────────────────────────────────────────────────────────────
func (p *AudioOptions) renderVolumeBar() error {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
@@ -543,7 +592,6 @@ func (p *AudioOptions) renderVolumeBar() error {
return nil
}
// renderCursor draws the spinning triangle cursor next to the focused row.
func (p *AudioOptions) renderCursor() error {
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
@@ -570,7 +618,33 @@ func (p *AudioOptions) renderCursor() error {
return nil
}
// ── Render ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) renderScrollbar() {
n := len(p.audioFileTextures)
if n <= p.maxVisible {
return
}
trackColor := sdl.Color{R: 255, G: 255, B: 255, A: 30}
thumbColor := sdl.Color{R: 255, G: 255, B: 255, A: 100}
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackH)},
trackColor)
visibleRatio := float32(p.maxVisible) / float32(n)
scrollRatio := float32(p.scrollOffset) / float32(n)
thumbHeight := float32(p.scrollTrackH) * visibleRatio
if thumbHeight < 20 {
thumbHeight = 20
}
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackH)*scrollRatio
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
thumbColor)
}
func (p *AudioOptions) Render() error {
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
@@ -580,7 +654,6 @@ func (p *AudioOptions) Render() error {
return fmt.Errorf("render prompt: %w", err)
}
// Volume row
if err := p.RenderTexture(p.textShader, p.VolumeLabel.ID, p.VolumeLabel.Position(), p.VolumeLabel.RawDimensions(), p.VolumeLabel.Color); err != nil {
return fmt.Errorf("render volume label: %w", err)
}
@@ -591,24 +664,55 @@ func (p *AudioOptions) Render() error {
return fmt.Errorf("render volume percent: %w", err)
}
// Toggle
if err := p.RenderTexture(p.textShader, p.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil {
return fmt.Errorf("render audio toggle: %w", err)
}
// Audio file list
p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color)
// List background — centered width matching widest item + padding
var maxItemW int32
for i := range p.audioFileTextures {
item := &p.audioFileTextures[i]
color := colors.White
if i == p.selectedFileIndex {
color = colors.Green
w := p.audioFileTextures[i].W
if w > maxItemW {
maxItemW = w
}
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), color); err != nil {
}
bgPad := int32(16)
bgW := maxItemW + 2*bgPad
bgLeft := float32(p.ScreenCenterX) - float32(bgW)/2
bgTop := float32(p.listAreaBottom)
bgBottom := float32(p.listAreaTop)
listBgColor := sdl.Color{R: 255, G: 255, B: 255, A: 12}
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{bgLeft, bgTop},
mgl32.Vec2{float32(bgW), bgBottom - bgTop},
listBgColor)
// Audio file list (visible window only)
for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.audioFileTextures); i++ {
item := &p.audioFileTextures[i]
clr := colors.White
if i == p.selectedFileIndex {
clr = colors.Green
}
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), clr); err != nil {
return fmt.Errorf("render audio file %d: %w", i, err)
}
if i == p.selectedFileIndex {
dotX := float32(item.X) - p.dotSize - 6
dotY := float32(item.Y) + float32(item.H)/2 - p.dotSize/2
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{dotX, dotY},
mgl32.Vec2{p.dotSize, p.dotSize},
colors.Green)
}
}
// Spinning triangle cursor — always on top
p.renderScrollbar()
if err := p.renderCursor(); err != nil {
return fmt.Errorf("render cursor: %w", err)
}
@@ -616,14 +720,13 @@ func (p *AudioOptions) Render() error {
return nil
}
// ── Destroy ───────────────────────────────────────────────────────────────────
func (p *AudioOptions) Destroy() error {
gl.DeleteTextures(1, &p.title.ID)
gl.DeleteTextures(1, &p.prompt.ID)
gl.DeleteTextures(1, &p.VolumeLabel.ID)
gl.DeleteTextures(1, &p.volumePercent.ID)
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
gl.DeleteTextures(1, &p.sectionHeader.ID)
for i := range p.audioFileTextures {
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
var GitVersion = "07112026-1710"
var GitVersion = "07292026_1618"
type Home struct {
Base
+88 -54
View File
@@ -38,6 +38,7 @@ type ScrollerPage struct {
audioRetryTimer float32
audioStarted bool
originalAudioStarted bool
dynamicAudio bool
lines *LineDeque
@@ -284,33 +285,31 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
logger.Info("DynamicAudio mode: %v", p.dynamicAudio)
// Start audio for the first proclamation in the stream.
started := false
if len(p.streams) > 0 {
if p.dynamicAudio {
started = p.switchAudioForFrequency(p.streams[0].frequencyValue)
}
if len(p.streams) > 0 && p.dynamicAudio {
p.switchAudioForFrequency(p.streams[0].frequencyValue)
p.audioStarted = true
}
if !started {
// Non-dynamic mode or frequency-specific file not yet available.
// Play whatever track is marked Selected in the audio_files table.
var selectedAudio db.AudioFile
if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil {
selectedAudio = db.AudioFile{
Name: "OriginalAudio",
Path: filepath.Join(audioDir(), "OriginalAudio.wav"),
Selected: true,
Order: 0,
}
}
logger.Info("Playing %s", selectedAudio.Path)
if err := mixer.Play(selectedAudio.Path, true, config); err != nil {
if !p.audioStarted && !p.dynamicAudio {
// Static track selected in Audio Options. Play whatever track is
// marked Selected in the audio_tracks table.
var selectedTrack db.AudioTrack
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selectedTrack).Error; err != nil {
logger.Warning("No selected audio track found, audio will start when a track is chosen")
} else {
path := filepath.Join(audioDir(), selectedTrack.Filename)
if _, err := os.Stat(path); err == nil {
logger.Info("Playing %s", path)
if err := mixer.Play(path, true, config); err != nil {
logger.Error("Mixer returned an error: %v", err)
} else {
p.audioStarted = true
}
} else {
logger.Warning("Selected audio file %s not found yet (download may still be in progress)", path)
}
}
}
p.audioStarted = started
currentDevice := mixer.CurrentDevice()
if currentDevice == "" {
@@ -438,38 +437,20 @@ func audioDir() string {
return filepath.Join(home, ".radiantwave", "audio")
}
// audioPathForFrequency builds the on-disk path for a solfeggio frequency's
// audio file, e.g. 396 -> "~/.radiantwave/audio/396Hz.wav".
func (p *ScrollerPage) audioPathForFrequency(freq int) string {
return filepath.Join(audioDir(), fmt.Sprintf("%dHz.wav", freq))
}
// switchAudioForFrequency swaps the currently playing track to match freq.
// If freq is 0 or no audio track exists for it, falls back to the original
// / selected audio track (so audio never goes silent in Ready state).
//
// - freq == 0 ("no specific frequency", i.e. the default proclamation) is
// always a no-op — whatever is playing keeps playing.
// - If freq already matches what's playing, nothing happens.
// - If the matching file doesn't exist on disk yet, the current track
// keeps playing untouched (so missing solfeggio audio never causes
// silence — and starts working automatically once the file is added).
// - Otherwise the current track is stopped immediately and freq's track
// starts right away (hard cut, no fade).
//
// Returns true if freq's track is now playing (either just switched, or
// already was).
// Returns true if audio is now playing (either frequency-specific or fallback).
func (p *ScrollerPage) switchAudioForFrequency(freq int) bool {
if freq == 0 {
return false
}
if freq == p.currentAudioFreq {
if freq != 0 && freq == p.currentAudioFreq {
return true
}
path := p.audioPathForFrequency(freq)
if _, err := os.Stat(path); err != nil {
logger.Warning("No audio file for %dHz (%s), keeping current track", freq, path)
return false
}
if freq != 0 {
if track, err := p.audioTrackForFrequency(freq); err == nil {
path := filepath.Join(audioDir(), track.Filename)
if _, err := os.Stat(path); err == nil {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
@@ -482,6 +463,50 @@ func (p *ScrollerPage) switchAudioForFrequency(freq int) bool {
p.currentAudioFreq = freq
return true
}
}
}
return p.playOriginalAudio()
}
// playOriginalAudio plays the selected AudioTrack, or the OriginalAudio track
// if nothing is selected. Returns true if audio started successfully.
func (p *ScrollerPage) playOriginalAudio() bool {
if p.originalAudioStarted {
return true
}
var track db.AudioTrack
if err := p.Context.DbPtr.Where("selected = ?", true).First(&track).Error; err != nil {
if err := p.Context.DbPtr.Where("assigned_frequency = ?", 0).First(&track).Error; err != nil {
logger.Warning("No audio tracks available")
return false
}
}
path := filepath.Join(audioDir(), track.Filename)
if _, err := os.Stat(path); err != nil {
logger.Warning("Original audio file not found: %s", path)
return false
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
mixer.Stop(3000, config)
if err := mixer.Play(path, true, config); err != nil {
logger.Error("Mixer returned an error: %v", err)
return false
}
p.originalAudioStarted = true
p.currentAudioFreq = 0
return true
}
func (p *ScrollerPage) audioTrackForFrequency(freq int) (db.AudioTrack, error) {
var track db.AudioTrack
err := p.Context.DbPtr.Where("assigned_frequency = ?", freq).First(&track).Error
return track, err
}
// nextLineWords gets words for the current line and advances proclamation stream when needed.
func (p *ScrollerPage) nextLineWords() []string {
@@ -645,20 +670,29 @@ func (p *ScrollerPage) HandleEvent(event *sdl.Event) error {
func (p *ScrollerPage) Update(dt float32) error {
p.updateAutoVelocity(dt)
// In dynamic mode, retry audio switch every 5 seconds until the
// frequency-specific file is found. In non-dynamic mode the selected
// file plays continuously — nothing to retry.
if p.dynamicAudio && !p.audioStarted {
// Periodically try to switch to the frequency-specific file in case
// it was just downloaded (only when the stream's frequency differs from
// whatever is currently playing).
if p.dynamicAudio && len(p.streams) > 0 {
p.audioRetryTimer += dt
if p.audioRetryTimer >= 5.0 {
p.audioRetryTimer = 0
if len(p.streams) > 0 {
freq := p.streams[p.streamIndex].frequencyValue
p.audioStarted = p.switchAudioForFrequency(freq) || freq == 0
if freq != 0 && freq != p.currentAudioFreq {
p.switchAudioForFrequency(freq)
}
}
}
// Non-dynamic mode: retry selected track in case download just finished.
if !p.dynamicAudio && !p.audioStarted {
p.audioRetryTimer += dt
if p.audioRetryTimer >= 5.0 {
p.audioRetryTimer = 0
p.audioStarted = p.playOriginalAudio()
}
}
p.scrollOffset += dt * p.velocity
move := int32(p.scrollOffset)
if move == 0 {
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
echo "=== Debian dev environment setup ==="
# Update package index
sudo apt-get update
# --- Core dev & build tools ---
sudo apt-get install -y \
curl \
g++ \
gcc \
git \
make \
cmake \
ninja-build \
pkg-config
# --- SDL2 and all official extension libraries ---
sudo apt-get install -y \
libsdl2-dev \
libsdl2-mixer-dev \
libsdl2-net-dev \
libsdl2-ttf-dev \
libsdl2-image-dev \
libsdl2-gfx-dev
# --- SDL3 (available in Debian testing/sid, but not bookworm) ---
# If this fails, SDL3 isn't in your Debian release yet.
# See https://github.com/libsdl-org/SDL for building from source.
sudo apt-get install -y libsdl3-dev || echo "SDL3 not found in apt (expected on older Debian releases)"
# --- Go ---
if ! command -v go &>/dev/null; then
echo "Installing Go..."
GO_VERSION=$(curl -sL https://go.dev/VERSION?m=text | head -1)
GO_TAR="${GO_VERSION}.linux-amd64.tar.gz"
curl -sLO "https://go.dev/dl/${GO_TAR}"
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf "${GO_TAR}"
rm "${GO_TAR}"
export PATH=$PATH:/usr/local/go/bin
if ! grep -q '/usr/local/go/bin' "$HOME/.profile" 2>/dev/null; then
echo 'export PATH=$PATH:/usr/local/go/bin' >> "$HOME/.profile"
fi
else
echo "Go already installed: $(go version)"
fi
# --- Nix (multi-user install) ---
if ! command -v nix &>/dev/null; then
echo "Installing Nix..."
sh <(curl -sL https://nixos.org/nix/install) --daemon
echo "Nix installed. You may need to log out and back in, or run:"
echo " . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh"
else
echo "Nix already installed: $(nix --version)"
fi
echo ""
echo "=== Setup complete ==="
echo "Installed: gcc, g++, make, cmake, ninja, pkg-config"
echo "Installed: SDL2 + mixer/net/ttf/image/gfx"
echo "Installed: Go $(go version 2>/dev/null || echo '(check PATH)')"
echo "Installed: Nix $(nix --version 2>/dev/null || echo '(restart shell)')"
if dpkg -l libsdl3-dev &>/dev/null; then
echo "Installed: SDL3"
else
echo "SDL3: not in apt for this release (install from https://github.com/libsdl-org/SDL)"
fi