Merge dev: AudioTrack table, download sync, audio fallback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -107,6 +107,6 @@ func Migrate(db *gorm.DB) error {
|
||||
&Proclamation{},
|
||||
&ProclamationCategory{},
|
||||
&SolfeggioFrequency{},
|
||||
&AudioFile{},
|
||||
&AudioTrack{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,10 +50,12 @@ type SolfeggioFrequency struct {
|
||||
Value int
|
||||
}
|
||||
|
||||
type AudioFile struct {
|
||||
ID int `gorm:"primaryKey"`
|
||||
Name string
|
||||
Path string
|
||||
Selected bool
|
||||
Order int
|
||||
type AudioTrack struct {
|
||||
ID int `gorm:"primaryKey"`
|
||||
Filename string
|
||||
Name string
|
||||
AssignedFrequency int
|
||||
MD5 string
|
||||
Selected bool
|
||||
Order int
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+230
-127
@@ -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,7 +501,35 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
||||
p.updateCursorPosition()
|
||||
} else if p.focusState > 0 {
|
||||
p.focusState--
|
||||
p.updateCursorPosition()
|
||||
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:
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
var GitVersion = "07112026-1710"
|
||||
var GitVersion = "07292026_1618"
|
||||
|
||||
type Home struct {
|
||||
Base
|
||||
|
||||
@@ -38,6 +38,7 @@ type ScrollerPage struct {
|
||||
|
||||
audioRetryTimer float32
|
||||
audioStarted bool
|
||||
originalAudioStarted bool
|
||||
dynamicAudio bool
|
||||
|
||||
lines *LineDeque
|
||||
@@ -284,34 +285,32 @@ 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,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Playing %s", selectedAudio.Path)
|
||||
if err := mixer.Play(selectedAudio.Path, true, config); err != nil {
|
||||
logger.Error("Mixer returned an error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
p.audioStarted = started
|
||||
|
||||
currentDevice := mixer.CurrentDevice()
|
||||
if currentDevice == "" {
|
||||
currentDevice = "default"
|
||||
@@ -438,36 +437,55 @@ 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 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)
|
||||
}
|
||||
mixer.Stop(3000, config)
|
||||
if err := mixer.Play(path, true, config); err != nil {
|
||||
logger.Error("Mixer returned an error: %v", err)
|
||||
return false
|
||||
}
|
||||
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("No audio file for %dHz (%s), keeping current track", freq, path)
|
||||
logger.Warning("Original audio file not found: %s", path)
|
||||
return false
|
||||
}
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
@@ -479,10 +497,17 @@ func (p *ScrollerPage) switchAudioForFrequency(freq int) bool {
|
||||
logger.Error("Mixer returned an error: %v", err)
|
||||
return false
|
||||
}
|
||||
p.currentAudioFreq = freq
|
||||
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 {
|
||||
if len(p.streams) == 0 {
|
||||
@@ -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
|
||||
freq := p.streams[p.streamIndex].frequencyValue
|
||||
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 {
|
||||
|
||||
Executable
+71
@@ -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
|
||||
Reference in New Issue
Block a user