Merge dev: AudioTrack table, download sync, audio fallback
This commit is contained in:
@@ -18,7 +18,7 @@
|
|||||||
let
|
let
|
||||||
pkgs = import nixpkgs {
|
pkgs = import nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
config.allowUnfreePredicate = pkg: (nixpkgs.lib.getName pkg) == "rhemawave";
|
config.allowUnfreePredicate = pkg: (nixpkgs.lib.getName pkg) == "radiantwave";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
@@ -27,8 +27,8 @@
|
|||||||
SDL2_image
|
SDL2_image
|
||||||
SDL2_mixer
|
SDL2_mixer
|
||||||
sqlite
|
sqlite
|
||||||
dbus
|
|
||||||
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||||
|
dbus
|
||||||
libGL
|
libGL
|
||||||
wayland
|
wayland
|
||||||
wayland-protocols
|
wayland-protocols
|
||||||
@@ -36,15 +36,7 @@
|
|||||||
libdecor
|
libdecor
|
||||||
libx11
|
libx11
|
||||||
libxext
|
libxext
|
||||||
] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (with pkgs.darwin.apple_sdk.frameworks; [
|
]; # Darwin: system SDK provides OpenGL, AppKit, etc. automatically
|
||||||
OpenGL
|
|
||||||
CoreAudio
|
|
||||||
CoreFoundation
|
|
||||||
CoreServices
|
|
||||||
ForceFeedback
|
|
||||||
IOKit
|
|
||||||
AppKit
|
|
||||||
]);
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
go
|
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()
|
app.runEventLoop()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -107,6 +107,6 @@ func Migrate(db *gorm.DB) error {
|
|||||||
&Proclamation{},
|
&Proclamation{},
|
||||||
&ProclamationCategory{},
|
&ProclamationCategory{},
|
||||||
&SolfeggioFrequency{},
|
&SolfeggioFrequency{},
|
||||||
&AudioFile{},
|
&AudioTrack{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,10 +50,12 @@ type SolfeggioFrequency struct {
|
|||||||
Value int
|
Value int
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioFile struct {
|
type AudioTrack struct {
|
||||||
ID int `gorm:"primaryKey"`
|
ID int `gorm:"primaryKey"`
|
||||||
Name string
|
Filename string
|
||||||
Path string
|
Name string
|
||||||
Selected bool
|
AssignedFrequency int
|
||||||
Order int
|
MD5 string
|
||||||
|
Selected bool
|
||||||
|
Order int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/db"
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
"rhemawavetech.com/rhemawave/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,36 +27,36 @@ type DownloadStatus struct {
|
|||||||
// manifest maps filename → expected MD5 hex string
|
// manifest maps filename → expected MD5 hex string
|
||||||
type manifest map[string]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"
|
const manifestFilename = "checksums.md5"
|
||||||
|
|
||||||
// DownloadAudioFiles downloads audio files, skipping any that already exist
|
// DownloadAudioFiles downloads audio files using the remote checksums.md5 as
|
||||||
// locally with a matching MD5. Files with a wrong checksum are deleted and
|
// the source of truth. It:
|
||||||
// re-downloaded. Progress is sent to progressChan, which is closed on return.
|
// - Downloads any file in the manifest that is missing locally.
|
||||||
func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlBase string) {
|
// - 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)
|
defer close(progressChan)
|
||||||
|
|
||||||
send := func(cur, max int64, msg string, err error) {
|
var totalDownloaded int64
|
||||||
progressChan <- DownloadStatus{Cur: cur, Max: max, Msg: msg, Err: err}
|
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)
|
m, err := fetchManifest(urlBase + manifestFilename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
send(0, 0, "Failed to fetch manifest", err)
|
send(0, "Failed to fetch manifest", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.Info("download: manifest loaded, %d files listed", len(m))
|
logger.Info("download: manifest loaded, %d files listed", len(m))
|
||||||
|
|
||||||
send(0, 0, "Comparing checksums...", nil)
|
send(0, "Comparing checksums...", nil)
|
||||||
|
|
||||||
type work struct {
|
type work struct {
|
||||||
filename string
|
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 {
|
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")
|
logger.Info("download: nothing to do")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
total := len(queue)
|
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)
|
logger.Info("download: %d file(s) to download", total)
|
||||||
|
|
||||||
totalDownloaded := int64(0)
|
|
||||||
filesDownloaded := 0
|
|
||||||
|
|
||||||
for _, w := range queue {
|
for _, w := range queue {
|
||||||
url := urlBase + w.filename
|
url := urlBase + w.filename
|
||||||
destPath := filepath.Join(destDir, 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) {
|
downloaded, err := downloadFile(url, destPath, func(n int64) {
|
||||||
totalDownloaded += n
|
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 {
|
if err != nil {
|
||||||
logger.Error("download: failed to download %s: %v", w.filename, err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,24 +146,35 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("download: could not hash %s after download: %v", w.filename, err)
|
logger.Error("download: could not hash %s after download: %v", w.filename, err)
|
||||||
os.Remove(destPath)
|
os.Remove(destPath)
|
||||||
send(totalDownloaded, 0, "Checksum verification failed", err)
|
send(totalDownloaded, "Checksum verification failed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if gotMD5 != w.expectedMD5 {
|
if gotMD5 != w.expectedMD5 {
|
||||||
err := fmt.Errorf("checksum mismatch after download: got %s, want %s", 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)
|
logger.Error("download: %s %v", w.filename, err)
|
||||||
os.Remove(destPath)
|
os.Remove(destPath)
|
||||||
send(totalDownloaded, 0, "Checksum verification failed — file removed", err)
|
send(totalDownloaded, "Checksum verification failed — file removed", err)
|
||||||
return
|
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++
|
filesDownloaded++
|
||||||
statusMsg := fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total)
|
statusMsg := fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total)
|
||||||
logger.Info("download: %s (%d bytes)", statusMsg, downloaded)
|
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")
|
logger.Info("download: all files downloaded and verified")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +208,7 @@ func fetchManifest(url string) (manifest, error) {
|
|||||||
}
|
}
|
||||||
hash := strings.TrimSpace(parts[0])
|
hash := strings.TrimSpace(parts[0])
|
||||||
name := strings.TrimSpace(parts[1])
|
name := strings.TrimSpace(parts[1])
|
||||||
|
name = strings.TrimPrefix(name, "./")
|
||||||
m[name] = hash
|
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
|
package mixer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"math"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/veandco/go-sdl2/mix"
|
"github.com/veandco/go-sdl2/mix"
|
||||||
@@ -25,9 +20,10 @@ var (
|
|||||||
|
|
||||||
currentMusic *mix.Music
|
currentMusic *mix.Music
|
||||||
currentPath string
|
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
|
defaultFreq = 48000
|
||||||
defaultFormat = uint16(sdl.AUDIO_F32)
|
defaultFormat = uint16(sdl.AUDIO_F32)
|
||||||
defaultChannels = 2
|
defaultChannels = 2
|
||||||
@@ -37,13 +33,6 @@ var (
|
|||||||
refreshCacheCallback func()
|
refreshCacheCallback func()
|
||||||
)
|
)
|
||||||
|
|
||||||
type Sink struct {
|
|
||||||
ID int
|
|
||||||
Name string
|
|
||||||
Volume float64
|
|
||||||
IsDefault bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
|
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
|
||||||
func Init(cfg *db.AppConfig, onUpdate func()) error {
|
func Init(cfg *db.AppConfig, onUpdate func()) error {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -58,7 +47,6 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
|
|||||||
return fmt.Errorf("SDL audio init failed: %w", err)
|
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)
|
err := mix.Init(mix.INIT_MP3 | mix.INIT_OGG)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to initialize MP3/OGG support: %v", err)
|
logger.Error("Failed to initialize MP3/OGG support: %v", err)
|
||||||
@@ -73,7 +61,9 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
SetAllSinkVolumes(cfg.SavedVolume)
|
currentVolume = cfg.SavedVolume
|
||||||
|
v := int(math.Round(currentVolume * float64(mix.MAX_VOLUME)))
|
||||||
|
mix.VolumeMusic(v)
|
||||||
|
|
||||||
inited = true
|
inited = true
|
||||||
return nil
|
return nil
|
||||||
@@ -83,8 +73,6 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
|
|||||||
func openDeviceLocked(name string) error {
|
func openDeviceLocked(name string) error {
|
||||||
if err := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, name, defaultAllow); err != nil {
|
if err := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, name, defaultAllow); err != nil {
|
||||||
if name != "" {
|
if name != "" {
|
||||||
// Log the failure of the requested device
|
|
||||||
|
|
||||||
// Fallback to default device
|
// Fallback to default device
|
||||||
if err2 := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, "", defaultAllow); err2 != nil {
|
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)
|
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.
|
// 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.
|
// If something is already playing, it stops and replaces it.
|
||||||
func Play(path string, loopForever bool, cfg *db.AppConfig) error {
|
func Play(path string, loopForever bool, cfg *db.AppConfig) error {
|
||||||
// Check if audio is enabled or disabled
|
|
||||||
if !cfg.AudioEnabled {
|
if !cfg.AudioEnabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock the mutex
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
// Check if mixer has been initialized and that a device is open
|
|
||||||
if !inited {
|
if !inited {
|
||||||
logger.Error("audio mixer not initialized")
|
logger.Error("audio mixer not initialized")
|
||||||
return errors.New("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")
|
return errors.New("empty audio path")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop and free any prior music
|
|
||||||
mix.HaltMusic()
|
mix.HaltMusic()
|
||||||
if currentMusic != nil {
|
if currentMusic != nil {
|
||||||
currentMusic.Free()
|
currentMusic.Free()
|
||||||
currentMusic = nil
|
currentMusic = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the music file
|
|
||||||
m, err := mix.LoadMUS(path)
|
m, err := mix.LoadMUS(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("LoadMUS(%q): %w", path, err)
|
return fmt.Errorf("LoadMUS(%q): %w", path, err)
|
||||||
}
|
}
|
||||||
// Set current music to the loaded music file
|
|
||||||
currentMusic = m
|
currentMusic = m
|
||||||
currentPath = path
|
currentPath = path
|
||||||
if loopForever {
|
if loopForever {
|
||||||
@@ -151,9 +133,9 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error {
|
|||||||
currentLoops = 0
|
currentLoops = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
mix.VolumeMusic(mix.MAX_VOLUME)
|
v := int(math.Round(currentVolume * float64(mix.MAX_VOLUME)))
|
||||||
volume := mix.VolumeMusic(-1)
|
mix.VolumeMusic(v)
|
||||||
logger.Info("Current volume is %d", volume)
|
logger.Info("Current volume is %d", v)
|
||||||
|
|
||||||
if err := currentMusic.Play(currentLoops); err != nil {
|
if err := currentMusic.Play(currentLoops); err != nil {
|
||||||
currentMusic.Free()
|
currentMusic.Free()
|
||||||
@@ -174,7 +156,7 @@ func Stop(fadeMs int, cfg *db.AppConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if fadeMs > 0 && mix.PlayingMusic() {
|
if fadeMs > 0 && mix.PlayingMusic() {
|
||||||
_ = mix.FadeOutMusic(fadeMs) // async fade; we’ll still free below
|
_ = mix.FadeOutMusic(fadeMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
mix.HaltMusic()
|
mix.HaltMusic()
|
||||||
@@ -196,59 +178,24 @@ func IsPlaying() bool {
|
|||||||
return mix.PlayingMusic()
|
return mix.PlayingMusic()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDefaultSinkVolume sets volume 0.0–1.0 on the default sink
|
// SetVolume sets the SDL_mixer music volume to level (0.0–1.0).
|
||||||
func SetDefaultSinkVolume(level float64) error {
|
func SetVolume(level float64) error {
|
||||||
if level < 0 || level > 1 {
|
if level < 0 || level > 1 {
|
||||||
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
|
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
|
||||||
}
|
}
|
||||||
pct := fmt.Sprintf("%.2f", level)
|
mu.Lock()
|
||||||
cmd := exec.Command("wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", pct)
|
defer mu.Unlock()
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
if !inited {
|
||||||
return fmt.Errorf("wpctl failed: %w\noutput: %s", err, out)
|
return errors.New("audio mixer not initialized")
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
currentVolume = level
|
||||||
|
v := int(math.Round(level * float64(mix.MAX_VOLUME)))
|
||||||
|
mix.VolumeMusic(v)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertVolume converts a desired volume percentage (0%, 25%, 33%, 90%, etc) to
|
// 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
|
// Percentages should be passed as integers. 25% -> 25
|
||||||
func ConvertVolume(volPerc int) float64 {
|
func ConvertVolume(volPerc int) float64 {
|
||||||
if volPerc == 0 {
|
if volPerc == 0 {
|
||||||
@@ -257,116 +204,6 @@ func ConvertVolume(volPerc int) float64 {
|
|||||||
return float64(volPerc) / 100.0
|
return float64(volPerc) / 100.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSinks parses the output of `wpctl status` and extracts audio sinks.
|
|
||||||
func parseSinks(data []byte) ([]Sink, error) {
|
|
||||||
var sinks []Sink
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
||||||
|
|
||||||
inAudioSection := false
|
|
||||||
inSinksSection := false
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
stripped := strings.TrimLeft(line, " │├└─")
|
|
||||||
|
|
||||||
// Detect top-level sections: "Audio", "Video", "Settings", etc.
|
|
||||||
// These are lines with no leading tree characters.
|
|
||||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "│") {
|
|
||||||
inAudioSection = strings.TrimSpace(line) == "Audio"
|
|
||||||
inSinksSection = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !inAudioSection {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect subsection headers within Audio
|
|
||||||
if strings.Contains(stripped, "Sinks:") {
|
|
||||||
inSinksSection = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Any other subsection header resets sink parsing
|
|
||||||
if strings.HasSuffix(strings.TrimSpace(stripped), ":") {
|
|
||||||
inSinksSection = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !inSinksSection {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sink, ok := parseSinkLine(stripped)
|
|
||||||
if ok {
|
|
||||||
sinks = append(sinks, sink)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("scanning wpctl output: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sinks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSinkLine parses a single sink entry line like:
|
|
||||||
//
|
|
||||||
// "*51. USB Audio [vol: 1.00]"
|
|
||||||
// "33. Built-in Audio Analog Stereo [vol: 0.65]"
|
|
||||||
func parseSinkLine(s string) (Sink, bool) {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "" {
|
|
||||||
return Sink{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
var sink Sink
|
|
||||||
|
|
||||||
// Leading '*' marks the default sink
|
|
||||||
if strings.HasPrefix(s, "*") {
|
|
||||||
sink.IsDefault = true
|
|
||||||
s = strings.TrimPrefix(s, "*")
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect "<id>. <name> ..."
|
|
||||||
dotIdx := strings.Index(s, ".")
|
|
||||||
if dotIdx < 0 {
|
|
||||||
return Sink{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
idStr := strings.TrimSpace(s[:dotIdx])
|
|
||||||
id, err := strconv.Atoi(idStr)
|
|
||||||
if err != nil {
|
|
||||||
return Sink{}, false
|
|
||||||
}
|
|
||||||
sink.ID = id
|
|
||||||
|
|
||||||
rest := strings.TrimSpace(s[dotIdx+1:])
|
|
||||||
|
|
||||||
// Extract [vol: X.XX] if present
|
|
||||||
volStart := strings.Index(rest, "[vol:")
|
|
||||||
if volStart >= 0 {
|
|
||||||
sink.Name = strings.TrimSpace(rest[:volStart])
|
|
||||||
volPart := rest[volStart:]
|
|
||||||
// "[vol: 0.65]" or "[vol: 0.65 MUTED]"
|
|
||||||
volPart = strings.TrimPrefix(volPart, "[vol:")
|
|
||||||
volPart = strings.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).
|
// ListDevices returns the list of available output device names (from core SDL).
|
||||||
func ListDevices() []string {
|
func ListDevices() []string {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -398,20 +235,15 @@ func SwitchDevice(newName string, cfg *db.AppConfig) error {
|
|||||||
return errors.New("audio mixer not initialized")
|
return errors.New("audio mixer not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log device switch attempt
|
oldDevice := CurrentDevice()
|
||||||
// Automatically defaults to "system default"
|
if newName == "" {
|
||||||
oldDevice := CurrentDevice() // Which device is to be closed
|
|
||||||
// SDL expects "" for default device, not "system default"
|
|
||||||
if newName == "" { // Which device is being switched to?
|
|
||||||
newName = ""
|
newName = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture current state
|
|
||||||
wasPlaying := mix.PlayingMusic()
|
wasPlaying := mix.PlayingMusic()
|
||||||
savedPath := currentPath
|
savedPath := currentPath
|
||||||
savedLoops := currentLoops
|
savedLoops := currentLoops
|
||||||
|
|
||||||
// Stop and release current music before reopening device
|
|
||||||
mix.HaltMusic()
|
mix.HaltMusic()
|
||||||
if currentMusic != nil {
|
if currentMusic != nil {
|
||||||
currentMusic.Free()
|
currentMusic.Free()
|
||||||
@@ -420,34 +252,27 @@ func SwitchDevice(newName string, cfg *db.AppConfig) error {
|
|||||||
currentPath = ""
|
currentPath = ""
|
||||||
currentLoops = 0
|
currentLoops = 0
|
||||||
|
|
||||||
// Close and reopen device
|
|
||||||
if deviceOpen {
|
if deviceOpen {
|
||||||
mix.CloseAudio()
|
mix.CloseAudio()
|
||||||
deviceOpen = false
|
deviceOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to open the newName device
|
|
||||||
if err := openDeviceLocked(newName); err != nil {
|
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 {
|
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)
|
logger.Error("Failed to switch to device: %s", oldDevice)
|
||||||
}
|
}
|
||||||
// Could not switch to the new device, return the error
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore saved volume after reopening device
|
|
||||||
if cfg != nil {
|
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 != "" {
|
if wasPlaying && savedPath != "" {
|
||||||
m, err := mix.LoadMUS(savedPath)
|
m, err := mix.LoadMUS(savedPath)
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("device switched, but reloading %q failed: %w", savedPath, err)
|
||||||
}
|
}
|
||||||
currentMusic = m
|
currentMusic = m
|
||||||
@@ -489,7 +314,7 @@ func Shutdown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if inited {
|
if inited {
|
||||||
mix.Quit() // Add this to clean up the decoders
|
mix.Quit()
|
||||||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||||||
inited = false
|
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"
|
"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) {
|
func TestConvertVolume(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+230
-127
@@ -15,53 +15,45 @@ import (
|
|||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
"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
|
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)
|
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 {
|
type AudioOptions struct {
|
||||||
// Page Attributes
|
|
||||||
Base
|
Base
|
||||||
font *fontManager.FontEntry
|
font *fontManager.FontEntry
|
||||||
textShader *shaderManager.Shader
|
textShader *shaderManager.Shader
|
||||||
solidShader *shaderManager.Shader
|
solidShader *shaderManager.Shader
|
||||||
|
|
||||||
// Page Textures
|
|
||||||
title StringItem
|
title StringItem
|
||||||
prompt StringItem
|
prompt StringItem
|
||||||
|
|
||||||
// Layout
|
|
||||||
verticalMargin int32
|
verticalMargin int32
|
||||||
ItemScale float32
|
ItemScale float32
|
||||||
|
|
||||||
// Audio toggle
|
|
||||||
AudioStatusToggle StringItem
|
AudioStatusToggle StringItem
|
||||||
|
|
||||||
// Audio file list — index 0 = "Dynamic Audio", index 1+ = AudioFileList entries
|
|
||||||
audioFileTextures []StringItem
|
audioFileTextures []StringItem
|
||||||
selectedFileIndex int // which item is currently selected (highlights green)
|
audioTracks []db.AudioTrack
|
||||||
|
selectedFileIndex int
|
||||||
|
|
||||||
// Volume row
|
|
||||||
VolumeLabel StringItem
|
VolumeLabel StringItem
|
||||||
volumePercent StringItem
|
volumePercent StringItem
|
||||||
volumeBarX int32
|
volumeBarX int32
|
||||||
@@ -69,25 +61,42 @@ type AudioOptions struct {
|
|||||||
volumeBarW int32
|
volumeBarW int32
|
||||||
volumeBarH int32
|
volumeBarH int32
|
||||||
|
|
||||||
// Triangle cursor
|
|
||||||
triVAO uint32
|
triVAO uint32
|
||||||
triVBO uint32
|
triVBO uint32
|
||||||
triX float32
|
triX float32
|
||||||
triCenterY float32
|
triCenterY float32
|
||||||
spinElapsed float32
|
spinElapsed float32
|
||||||
|
|
||||||
// Focus: focusToggle (-1) = toggle row, 0..n-1 = audio file list item
|
|
||||||
focusState int
|
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 {
|
func (p *AudioOptions) Init(ctx *PageCtx) error {
|
||||||
var err error
|
var err error
|
||||||
p.verticalMargin = int32(20)
|
p.verticalMargin = int32(20)
|
||||||
|
p.listItemMargin = 6
|
||||||
p.focusState = focusToggle
|
p.focusState = focusToggle
|
||||||
|
p.scrollOffset = 0
|
||||||
|
p.dotSize = 8.0
|
||||||
|
p.scrollTrackWidth = 30
|
||||||
|
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
|
||||||
|
|
||||||
// Font
|
|
||||||
fm := fontManager.Get()
|
fm := fontManager.Get()
|
||||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -95,7 +104,6 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
|
|||||||
}
|
}
|
||||||
p.font = font
|
p.font = font
|
||||||
|
|
||||||
// Shaders
|
|
||||||
sm := shaderManager.Get()
|
sm := shaderManager.Get()
|
||||||
p.textShader, ok = sm.Get("text")
|
p.textShader, ok = sm.Get("text")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -119,14 +127,12 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
|
|||||||
titleScale := float32(64) / float32(baseFontSize)
|
titleScale := float32(64) / float32(baseFontSize)
|
||||||
p.ItemScale = float32(32) / float32(baseFontSize)
|
p.ItemScale = float32(32) / float32(baseFontSize)
|
||||||
|
|
||||||
// Title
|
|
||||||
p.title, err = NewStringItem("Audio Options", p.font, colors.White)
|
p.title, err = NewStringItem("Audio Options", p.font, colors.White)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating title: %w", err)
|
return fmt.Errorf("creating title: %w", err)
|
||||||
}
|
}
|
||||||
p.title.Scale(titleScale)
|
p.title.Scale(titleScale)
|
||||||
|
|
||||||
// Prompt
|
|
||||||
p.prompt, err = NewStringItem(
|
p.prompt, err = NewStringItem(
|
||||||
"Enter to select -- Up/Down to navigate -- Left/Right to change volume -- Backspace to go back",
|
"Enter to select -- Up/Down to navigate -- Left/Right to change volume -- Backspace to go back",
|
||||||
p.font, colors.White,
|
p.font, colors.White,
|
||||||
@@ -136,7 +142,6 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
|
|||||||
}
|
}
|
||||||
p.prompt.Scale(p.ItemScale)
|
p.prompt.Scale(p.ItemScale)
|
||||||
|
|
||||||
// Volume row
|
|
||||||
p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White)
|
p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating volume label: %w", err)
|
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()")
|
logger.Error("failed to create VolumePercentItem in AudioOptions.Init()")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio toggle
|
|
||||||
if err = p.CreateAudioStatusItem(); err != nil {
|
if err = p.CreateAudioStatusItem(); err != nil {
|
||||||
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
|
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure the audio_files table is populated.
|
if err = p.seedAudioTracks(); err != nil {
|
||||||
if err = p.seedAudioFiles(); err != nil {
|
logger.Error("failed to seed audio tracks: %v", err)
|
||||||
logger.Error("failed to seed audio files: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the audio file list textures: "Dynamic Audio" + AudioFileList
|
|
||||||
p.buildAudioFileList()
|
p.buildAudioFileList()
|
||||||
|
|
||||||
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords
|
|
||||||
triVerts := []float32{
|
triVerts := []float32{
|
||||||
0.5, 0.0,
|
0.5, 0.0,
|
||||||
-0.5, 0.5,
|
-0.5, 0.5,
|
||||||
@@ -175,20 +176,26 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
|
|||||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
||||||
gl.BindVertexArray(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()
|
p.layout()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAudioFileList creates textures for the audio file list.
|
|
||||||
// Index 0 = "Dynamic Audio", index 1+ = AudioFileList entries.
|
|
||||||
func (p *AudioOptions) buildAudioFileList() {
|
func (p *AudioOptions) buildAudioFileList() {
|
||||||
// Clear old textures
|
|
||||||
for i := range p.audioFileTextures {
|
for i := range p.audioFileTextures {
|
||||||
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
|
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
|
||||||
}
|
}
|
||||||
p.audioFileTextures = nil
|
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)
|
dynamicItem, err := NewStringItem("Dynamic Audio", p.font, colors.White)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to create Dynamic Audio string item: %v", err)
|
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)
|
p.audioFileTextures = append(p.audioFileTextures, dynamicItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AudioFileList entries at index 1+
|
for _, track := range tracks {
|
||||||
for _, af := range AudioFileList {
|
item, err := NewStringItem(track.Name, p.font, colors.White)
|
||||||
item, err := NewStringItem(af.Name, p.font, colors.White)
|
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
item.Scale(p.ItemScale)
|
item.Scale(p.ItemScale)
|
||||||
p.audioFileTextures = append(p.audioFileTextures, item)
|
p.audioFileTextures = append(p.audioFileTextures, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which item is currently selected
|
|
||||||
p.updateSelectedIndex()
|
p.updateSelectedIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateSelectedIndex sets selectedFileIndex based on the DB state.
|
|
||||||
func (p *AudioOptions) updateSelectedIndex() {
|
func (p *AudioOptions) updateSelectedIndex() {
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
config, err := db.GetConfig(p.Context.DbPtr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.selectedFileIndex = 0 // default to Dynamic Audio
|
p.selectedFileIndex = 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,40 +229,33 @@ func (p *AudioOptions) updateSelectedIndex() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the audio file with Selected = true
|
var selected db.AudioTrack
|
||||||
var selected db.AudioFile
|
|
||||||
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
|
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
|
||||||
// No selection found — default to Dynamic Audio
|
|
||||||
p.selectedFileIndex = 0
|
p.selectedFileIndex = 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map the selected AudioFile ID to its index in the list
|
for i, track := range p.audioTracks {
|
||||||
for i, af := range AudioFileList {
|
if track.ID == selected.ID {
|
||||||
if af.ID == selected.ID {
|
p.selectedFileIndex = i + 1
|
||||||
p.selectedFileIndex = i + 1 // +1 because index 0 is "Dynamic Audio"
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback — file not in list
|
|
||||||
p.selectedFileIndex = 0
|
p.selectedFileIndex = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedAudioFiles ensures the audio_files table has its default rows.
|
func (p *AudioOptions) seedAudioTracks() error {
|
||||||
func (p *AudioOptions) seedAudioFiles() error {
|
var existing []db.AudioTrack
|
||||||
var existing []db.AudioFile
|
|
||||||
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
|
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(existing) == 0 {
|
if len(existing) == 0 {
|
||||||
return p.Context.DbPtr.Save(&AudioFileList).Error
|
return p.Context.DbPtr.Save(&audioTrackSeed).Error
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audio status toggle ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) CreateAudioStatusItem() error {
|
func (p *AudioOptions) CreateAudioStatusItem() error {
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
config, err := db.GetConfig(p.Context.DbPtr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -280,9 +277,6 @@ func (p *AudioOptions) CreateAudioStatusItem() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audio file selection ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// selectAudioFile handles the user pressing Enter on an audio file list item.
|
|
||||||
func (p *AudioOptions) selectAudioFile(index int) {
|
func (p *AudioOptions) selectAudioFile(index int) {
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
config, err := db.GetConfig(p.Context.DbPtr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -290,19 +284,14 @@ func (p *AudioOptions) selectAudioFile(index int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if index == 0 {
|
if index == 0 {
|
||||||
// "Dynamic Audio" selected
|
|
||||||
config.DynamicAudio = true
|
config.DynamicAudio = true
|
||||||
// Clear all file selections
|
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false)
|
||||||
p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false)
|
|
||||||
} else {
|
} else {
|
||||||
// Specific audio file selected
|
|
||||||
config.DynamicAudio = false
|
config.DynamicAudio = false
|
||||||
fileIndex := index - 1 // offset for "Dynamic Audio" entry
|
trackIndex := index - 1
|
||||||
if fileIndex >= 0 && fileIndex < len(AudioFileList) {
|
if trackIndex >= 0 && trackIndex < len(p.audioTracks) {
|
||||||
// Clear all selections first
|
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false)
|
||||||
p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false)
|
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("id = ?", p.audioTracks[trackIndex].ID).Update("selected", true)
|
||||||
// Select the chosen file
|
|
||||||
p.Context.DbPtr.Model(&db.AudioFile{}).Where("id = ?", AudioFileList[fileIndex].ID).Update("selected", true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,8 +302,6 @@ func (p *AudioOptions) selectAudioFile(index int) {
|
|||||||
p.selectedFileIndex = index
|
p.selectedFileIndex = index
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Volume ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) CreateVolumePercentItem() error {
|
func (p *AudioOptions) CreateVolumePercentItem() error {
|
||||||
if p.volumePercent.ID != 0 {
|
if p.volumePercent.ID != 0 {
|
||||||
gl.DeleteTextures(1, &p.volumePercent.ID)
|
gl.DeleteTextures(1, &p.volumePercent.ID)
|
||||||
@@ -347,8 +334,8 @@ func (p *AudioOptions) UpdateVolume(level int) {
|
|||||||
}
|
}
|
||||||
config.SavedVolume = newVol
|
config.SavedVolume = newVol
|
||||||
p.Context.DbPtr.Save(&config)
|
p.Context.DbPtr.Save(&config)
|
||||||
if err := mixer.SetAllSinkVolumes(newVol); err != nil {
|
if err := mixer.SetVolume(newVol); err != nil {
|
||||||
logger.Error("Error setting allSinkVolumes: %v", err)
|
logger.Error("Error setting volume: %v", err)
|
||||||
}
|
}
|
||||||
if err := p.CreateVolumePercentItem(); err != nil {
|
if err := p.CreateVolumePercentItem(); err != nil {
|
||||||
logger.Error("failed to refresh volume percent item: %v", err)
|
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
|
p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) layout() {
|
func (p *AudioOptions) layout() {
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
config, err := db.GetConfig(p.Context.DbPtr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title
|
|
||||||
p.title.X = p.ScreenCenterX - p.title.W/2
|
p.title.X = p.ScreenCenterX - p.title.W/2
|
||||||
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
||||||
|
|
||||||
// Prompt
|
|
||||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
||||||
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
|
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
|
||||||
|
|
||||||
// Volume row
|
|
||||||
barH := int32(float32(config.StandardFontSize) * p.ItemScale)
|
barH := int32(float32(config.StandardFontSize) * p.ItemScale)
|
||||||
barW := int32(float32(p.ScreenWidth) * 0.30)
|
barW := int32(float32(p.ScreenWidth) * 0.30)
|
||||||
barX := p.ScreenCenterX - barW/2
|
barX := p.ScreenCenterX - barW/2
|
||||||
@@ -392,25 +374,64 @@ func (p *AudioOptions) layout() {
|
|||||||
p.volumePercent.X = barX + barW + gap
|
p.volumePercent.X = barX + barW + gap
|
||||||
p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2
|
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.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
|
||||||
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
|
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
|
||||||
|
|
||||||
// Audio file list — each item centred and stacked below the toggle
|
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2
|
||||||
nextY := p.AudioStatusToggle.Y - p.verticalMargin
|
p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H
|
||||||
for i := range p.audioFileTextures {
|
|
||||||
item := &p.audioFileTextures[i]
|
p.listItemHeight = p.audioFileTextures[0].H
|
||||||
item.X = p.ScreenCenterX - item.W/2
|
listTop := p.sectionHeader.Y - p.listItemMargin
|
||||||
item.Y = nextY - item.H
|
listBottomMargin := int32(12)
|
||||||
nextY = item.Y - p.verticalMargin
|
|
||||||
|
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()
|
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() {
|
func (p *AudioOptions) updateCursorPosition() {
|
||||||
cursorGap := int32(12)
|
cursorGap := int32(12)
|
||||||
|
|
||||||
@@ -428,8 +449,6 @@ func (p *AudioOptions) updateCursorPosition() {
|
|||||||
p.triCenterY = float32(item.Y) + float32(item.H)/2
|
p.triCenterY = float32(item.Y) + float32(item.H)/2
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toggle ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) ToggleAudio() {
|
func (p *AudioOptions) ToggleAudio() {
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
config, err := db.GetConfig(p.Context.DbPtr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -442,32 +461,38 @@ func (p *AudioOptions) ToggleAudio() {
|
|||||||
p.layout()
|
p.layout()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Events ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
||||||
e, ok := (*event).(*sdl.KeyboardEvent)
|
e, ok := (*event).(*sdl.KeyboardEvent)
|
||||||
if !ok || e.Type != sdl.KEYDOWN {
|
if !ok || e.Type != sdl.KEYDOWN {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
n := len(p.audioFileTextures)
|
||||||
|
|
||||||
switch e.Keysym.Sym {
|
switch e.Keysym.Sym {
|
||||||
case sdl.K_RETURN:
|
case sdl.K_RETURN:
|
||||||
if p.focusState == focusToggle {
|
if p.focusState == focusToggle {
|
||||||
p.ToggleAudio()
|
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.selectAudioFile(p.focusState)
|
||||||
p.updateCursorPosition()
|
p.updateCursorPosition()
|
||||||
}
|
}
|
||||||
|
|
||||||
case sdl.K_DOWN:
|
case sdl.K_DOWN:
|
||||||
if p.focusState == focusToggle {
|
if p.focusState == focusToggle {
|
||||||
if len(p.audioFileTextures) > 0 {
|
if n > 0 {
|
||||||
p.focusState = 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()
|
p.updateCursorPosition()
|
||||||
}
|
}
|
||||||
} else if p.focusState < len(p.audioFileTextures)-1 {
|
|
||||||
p.focusState++
|
|
||||||
p.updateCursorPosition()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case sdl.K_UP:
|
case sdl.K_UP:
|
||||||
@@ -476,7 +501,35 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
|||||||
p.updateCursorPosition()
|
p.updateCursorPosition()
|
||||||
} else if p.focusState > 0 {
|
} else if p.focusState > 0 {
|
||||||
p.focusState--
|
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:
|
case sdl.K_RIGHT:
|
||||||
@@ -492,8 +545,6 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Update ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) Update(dt float32) error {
|
func (p *AudioOptions) Update(dt float32) error {
|
||||||
p.spinElapsed += dt
|
p.spinElapsed += dt
|
||||||
if p.spinElapsed >= 1.0 {
|
if p.spinElapsed >= 1.0 {
|
||||||
@@ -502,8 +553,6 @@ func (p *AudioOptions) Update(dt float32) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) renderVolumeBar() error {
|
func (p *AudioOptions) renderVolumeBar() error {
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
config, err := db.GetConfig(p.Context.DbPtr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -543,7 +592,6 @@ func (p *AudioOptions) renderVolumeBar() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderCursor draws the spinning triangle cursor next to the focused row.
|
|
||||||
func (p *AudioOptions) renderCursor() error {
|
func (p *AudioOptions) renderCursor() error {
|
||||||
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
|
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
|
||||||
|
|
||||||
@@ -570,7 +618,33 @@ func (p *AudioOptions) renderCursor() error {
|
|||||||
return nil
|
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 {
|
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 {
|
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)
|
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 {
|
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)
|
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)
|
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 {
|
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)
|
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 {
|
for i := range p.audioFileTextures {
|
||||||
item := &p.audioFileTextures[i]
|
w := p.audioFileTextures[i].W
|
||||||
color := colors.White
|
if w > maxItemW {
|
||||||
if i == p.selectedFileIndex {
|
maxItemW = w
|
||||||
color = colors.Green
|
|
||||||
}
|
}
|
||||||
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)
|
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 {
|
if err := p.renderCursor(); err != nil {
|
||||||
return fmt.Errorf("render cursor: %w", err)
|
return fmt.Errorf("render cursor: %w", err)
|
||||||
}
|
}
|
||||||
@@ -616,14 +720,13 @@ func (p *AudioOptions) Render() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Destroy ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) Destroy() error {
|
func (p *AudioOptions) Destroy() error {
|
||||||
gl.DeleteTextures(1, &p.title.ID)
|
gl.DeleteTextures(1, &p.title.ID)
|
||||||
gl.DeleteTextures(1, &p.prompt.ID)
|
gl.DeleteTextures(1, &p.prompt.ID)
|
||||||
gl.DeleteTextures(1, &p.VolumeLabel.ID)
|
gl.DeleteTextures(1, &p.VolumeLabel.ID)
|
||||||
gl.DeleteTextures(1, &p.volumePercent.ID)
|
gl.DeleteTextures(1, &p.volumePercent.ID)
|
||||||
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
|
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
|
||||||
|
gl.DeleteTextures(1, &p.sectionHeader.ID)
|
||||||
for i := range p.audioFileTextures {
|
for i := range p.audioFileTextures {
|
||||||
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
|
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
)
|
)
|
||||||
|
|
||||||
var GitVersion = "07112026-1710"
|
var GitVersion = "07292026_1618"
|
||||||
|
|
||||||
type Home struct {
|
type Home struct {
|
||||||
Base
|
Base
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type ScrollerPage struct {
|
|||||||
|
|
||||||
audioRetryTimer float32
|
audioRetryTimer float32
|
||||||
audioStarted bool
|
audioStarted bool
|
||||||
|
originalAudioStarted bool
|
||||||
dynamicAudio bool
|
dynamicAudio bool
|
||||||
|
|
||||||
lines *LineDeque
|
lines *LineDeque
|
||||||
@@ -284,34 +285,32 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
|||||||
logger.Info("DynamicAudio mode: %v", p.dynamicAudio)
|
logger.Info("DynamicAudio mode: %v", p.dynamicAudio)
|
||||||
|
|
||||||
// Start audio for the first proclamation in the stream.
|
// Start audio for the first proclamation in the stream.
|
||||||
started := false
|
if len(p.streams) > 0 && p.dynamicAudio {
|
||||||
if len(p.streams) > 0 {
|
p.switchAudioForFrequency(p.streams[0].frequencyValue)
|
||||||
if p.dynamicAudio {
|
p.audioStarted = true
|
||||||
started = p.switchAudioForFrequency(p.streams[0].frequencyValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !started {
|
if !p.audioStarted && !p.dynamicAudio {
|
||||||
// Non-dynamic mode or frequency-specific file not yet available.
|
// Static track selected in Audio Options. Play whatever track is
|
||||||
// Play whatever track is marked Selected in the audio_files table.
|
// marked Selected in the audio_tracks table.
|
||||||
var selectedAudio db.AudioFile
|
var selectedTrack db.AudioTrack
|
||||||
if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil {
|
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selectedTrack).Error; err != nil {
|
||||||
selectedAudio = db.AudioFile{
|
logger.Warning("No selected audio track found, audio will start when a track is chosen")
|
||||||
Name: "OriginalAudio",
|
} else {
|
||||||
Path: filepath.Join(audioDir(), "OriginalAudio.wav"),
|
path := filepath.Join(audioDir(), selectedTrack.Filename)
|
||||||
Selected: true,
|
if _, err := os.Stat(path); err == nil {
|
||||||
Order: 0,
|
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()
|
currentDevice := mixer.CurrentDevice()
|
||||||
if currentDevice == "" {
|
if currentDevice == "" {
|
||||||
currentDevice = "default"
|
currentDevice = "default"
|
||||||
@@ -438,36 +437,55 @@ func audioDir() string {
|
|||||||
return filepath.Join(home, ".radiantwave", "audio")
|
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.
|
// 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
|
// Returns true if audio is now playing (either frequency-specific or fallback).
|
||||||
// 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).
|
|
||||||
func (p *ScrollerPage) switchAudioForFrequency(freq int) bool {
|
func (p *ScrollerPage) switchAudioForFrequency(freq int) bool {
|
||||||
if freq == 0 {
|
if freq != 0 && freq == p.currentAudioFreq {
|
||||||
return false
|
|
||||||
}
|
|
||||||
if freq == p.currentAudioFreq {
|
|
||||||
return true
|
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 {
|
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
|
return false
|
||||||
}
|
}
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
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)
|
logger.Error("Mixer returned an error: %v", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
p.currentAudioFreq = freq
|
p.originalAudioStarted = true
|
||||||
|
p.currentAudioFreq = 0
|
||||||
return true
|
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.
|
// nextLineWords gets words for the current line and advances proclamation stream when needed.
|
||||||
func (p *ScrollerPage) nextLineWords() []string {
|
func (p *ScrollerPage) nextLineWords() []string {
|
||||||
if len(p.streams) == 0 {
|
if len(p.streams) == 0 {
|
||||||
@@ -645,20 +670,29 @@ func (p *ScrollerPage) HandleEvent(event *sdl.Event) error {
|
|||||||
func (p *ScrollerPage) Update(dt float32) error {
|
func (p *ScrollerPage) Update(dt float32) error {
|
||||||
p.updateAutoVelocity(dt)
|
p.updateAutoVelocity(dt)
|
||||||
|
|
||||||
// In dynamic mode, retry audio switch every 5 seconds until the
|
// Periodically try to switch to the frequency-specific file in case
|
||||||
// frequency-specific file is found. In non-dynamic mode the selected
|
// it was just downloaded (only when the stream's frequency differs from
|
||||||
// file plays continuously — nothing to retry.
|
// whatever is currently playing).
|
||||||
if p.dynamicAudio && !p.audioStarted {
|
if p.dynamicAudio && len(p.streams) > 0 {
|
||||||
p.audioRetryTimer += dt
|
p.audioRetryTimer += dt
|
||||||
if p.audioRetryTimer >= 5.0 {
|
if p.audioRetryTimer >= 5.0 {
|
||||||
p.audioRetryTimer = 0
|
p.audioRetryTimer = 0
|
||||||
if len(p.streams) > 0 {
|
freq := p.streams[p.streamIndex].frequencyValue
|
||||||
freq := p.streams[p.streamIndex].frequencyValue
|
if freq != 0 && freq != p.currentAudioFreq {
|
||||||
p.audioStarted = p.switchAudioForFrequency(freq) || freq == 0
|
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
|
p.scrollOffset += dt * p.velocity
|
||||||
move := int32(p.scrollOffset)
|
move := int32(p.scrollOffset)
|
||||||
if move == 0 {
|
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