Replace AudioFile with AudioTrack, fix download+orphan+MD5 sync, always play original audio fallback
AudioTrack table replaces AudioFile (Filename, Name, AssignedFrequency,
MD5, Selected, Order) — fixes relative-path bug in scrollerPage.go that
caused "file not found" for single-track selection.
Download goroutine now:
- Accepts *gorm.DB and updates AudioTrack.MD5 after each download.
- Treats checksums.md5 as source of truth: removes local files and DB
records not in the manifest (orphans).
- Strips "./" prefix from manifest filenames (GNU md5sum format).
AudioOptions.go seeds 14 AudioTrack records matching server checksums.md5.
List background quad width matches the widest item (+ padding), centered.
scrollerPage.go switchAudioForFrequency falls back to original/selected
audio when a frequency has no matching track or file. playOriginalAudio
is a no-op on repeat calls to avoid 5-second audio restart loop.
Fixes progress display: passes Max=0 during downloads so Home page shows
"x / X Files Downloaded" text instead of bogus "100.0%" percentage.
Downstream audio switching (DynamicAudio) verifies an AudioTrack exists
for the frequency before assigning it to a stream — frequencies without
a track (e.g. 528 Hz) gracefully use original audio instead.
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,25 +17,25 @@ import (
|
|||||||
|
|
||||||
const focusToggle = -1
|
const focusToggle = -1
|
||||||
|
|
||||||
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 {
|
||||||
Base
|
Base
|
||||||
font *fontManager.FontEntry
|
font *fontManager.FontEntry
|
||||||
@@ -51,6 +51,7 @@ type AudioOptions struct {
|
|||||||
AudioStatusToggle StringItem
|
AudioStatusToggle StringItem
|
||||||
|
|
||||||
audioFileTextures []StringItem
|
audioFileTextures []StringItem
|
||||||
|
audioTracks []db.AudioTrack
|
||||||
selectedFileIndex int
|
selectedFileIndex int
|
||||||
|
|
||||||
VolumeLabel StringItem
|
VolumeLabel StringItem
|
||||||
@@ -68,10 +69,8 @@ type AudioOptions struct {
|
|||||||
|
|
||||||
focusState int
|
focusState int
|
||||||
|
|
||||||
// Section header
|
|
||||||
sectionHeader StringItem
|
sectionHeader StringItem
|
||||||
|
|
||||||
// Scrolling
|
|
||||||
scrollOffset int
|
scrollOffset int
|
||||||
maxVisible int
|
maxVisible int
|
||||||
listItemMargin int32
|
listItemMargin int32
|
||||||
@@ -83,10 +82,8 @@ type AudioOptions struct {
|
|||||||
scrollTrackY int32
|
scrollTrackY int32
|
||||||
scrollTrackH int32
|
scrollTrackH int32
|
||||||
|
|
||||||
// Selection dot
|
|
||||||
dotSize float32
|
dotSize float32
|
||||||
|
|
||||||
// List left-alignment X (same for all items)
|
|
||||||
listLeftX int32
|
listLeftX int32
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,8 +155,8 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
|
|||||||
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
|
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = p.seedAudioFiles(); err != nil {
|
if err = p.seedAudioTracks(); err != nil {
|
||||||
logger.Error("failed to seed audio files: %v", err)
|
logger.Error("failed to seed audio tracks: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
p.buildAudioFileList()
|
p.buildAudioFileList()
|
||||||
@@ -195,6 +192,10 @@ func (p *AudioOptions) buildAudioFileList() {
|
|||||||
}
|
}
|
||||||
p.audioFileTextures = nil
|
p.audioFileTextures = nil
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -203,10 +204,10 @@ func (p *AudioOptions) buildAudioFileList() {
|
|||||||
p.audioFileTextures = append(p.audioFileTextures, dynamicItem)
|
p.audioFileTextures = append(p.audioFileTextures, dynamicItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, af := range AudioFileList {
|
for _, track := range tracks {
|
||||||
item, err := NewStringItem(af.Name, p.font, colors.White)
|
item, err := NewStringItem(track.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)
|
||||||
@@ -228,14 +229,14 @@ func (p *AudioOptions) updateSelectedIndex() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected db.AudioFile
|
var selected db.AudioTrack
|
||||||
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 {
|
||||||
p.selectedFileIndex = 0
|
p.selectedFileIndex = 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, af := range AudioFileList {
|
for i, track := range p.audioTracks {
|
||||||
if af.ID == selected.ID {
|
if track.ID == selected.ID {
|
||||||
p.selectedFileIndex = i + 1
|
p.selectedFileIndex = i + 1
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -244,13 +245,13 @@ func (p *AudioOptions) updateSelectedIndex() {
|
|||||||
p.selectedFileIndex = 0
|
p.selectedFileIndex = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AudioOptions) seedAudioFiles() error {
|
func (p *AudioOptions) seedAudioTracks() error {
|
||||||
var existing []db.AudioFile
|
var existing []db.AudioTrack
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -284,13 +285,13 @@ func (p *AudioOptions) selectAudioFile(index int) {
|
|||||||
|
|
||||||
if index == 0 {
|
if index == 0 {
|
||||||
config.DynamicAudio = true
|
config.DynamicAudio = true
|
||||||
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 {
|
} else {
|
||||||
config.DynamicAudio = false
|
config.DynamicAudio = false
|
||||||
fileIndex := index - 1
|
trackIndex := index - 1
|
||||||
if fileIndex >= 0 && fileIndex < len(AudioFileList) {
|
if trackIndex >= 0 && trackIndex < len(p.audioTracks) {
|
||||||
p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false)
|
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false)
|
||||||
p.Context.DbPtr.Model(&db.AudioFile{}).Where("id = ?", AudioFileList[fileIndex].ID).Update("selected", true)
|
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("id = ?", p.audioTracks[trackIndex].ID).Update("selected", true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,15 +351,12 @@ func (p *AudioOptions) layout() {
|
|||||||
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
|
||||||
@@ -376,20 +374,16 @@ 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
|
|
||||||
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
|
||||||
|
|
||||||
// Section header — sits above the toggle, marks the start of the audio track list
|
|
||||||
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2
|
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2
|
||||||
p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H
|
p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H
|
||||||
|
|
||||||
// Scrollable list area boundaries
|
|
||||||
p.listItemHeight = p.audioFileTextures[0].H
|
p.listItemHeight = p.audioFileTextures[0].H
|
||||||
listTop := p.sectionHeader.Y - p.listItemMargin
|
listTop := p.sectionHeader.Y - p.listItemMargin
|
||||||
listBottomMargin := int32(12)
|
listBottomMargin := int32(12)
|
||||||
|
|
||||||
// Available height for the list
|
|
||||||
listH := listTop - listBottomMargin
|
listH := listTop - listBottomMargin
|
||||||
if listH < p.listItemHeight {
|
if listH < p.listItemHeight {
|
||||||
listH = p.listItemHeight
|
listH = p.listItemHeight
|
||||||
@@ -398,7 +392,6 @@ func (p *AudioOptions) layout() {
|
|||||||
p.listAreaTop = listTop
|
p.listAreaTop = listTop
|
||||||
p.listAreaBottom = listBottomMargin
|
p.listAreaBottom = listBottomMargin
|
||||||
|
|
||||||
// How many items fit
|
|
||||||
itemStep := p.listItemHeight + p.listItemMargin
|
itemStep := p.listItemHeight + p.listItemMargin
|
||||||
n := len(p.audioFileTextures)
|
n := len(p.audioFileTextures)
|
||||||
p.maxVisible = int(listH / itemStep)
|
p.maxVisible = int(listH / itemStep)
|
||||||
@@ -409,16 +402,15 @@ func (p *AudioOptions) layout() {
|
|||||||
p.maxVisible = n
|
p.maxVisible = n
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute left alignment: find the widest item to center the block
|
|
||||||
var maxItemW int32
|
var maxItemW int32
|
||||||
for i := range p.audioFileTextures {
|
for i := range p.audioFileTextures {
|
||||||
if p.audioFileTextures[i].W > maxItemW {
|
w := p.audioFileTextures[i].W
|
||||||
maxItemW = p.audioFileTextures[i].W
|
if w > maxItemW {
|
||||||
|
maxItemW = w
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.listLeftX = p.ScreenCenterX - maxItemW/2
|
p.listLeftX = p.ScreenCenterX - maxItemW/2
|
||||||
|
|
||||||
// Position each item — scrollOffset shifts the whole list up/down
|
|
||||||
firstVisibleY := p.listAreaTop - p.listItemHeight
|
firstVisibleY := p.listAreaTop - p.listItemHeight
|
||||||
for i := range p.audioFileTextures {
|
for i := range p.audioFileTextures {
|
||||||
item := &p.audioFileTextures[i]
|
item := &p.audioFileTextures[i]
|
||||||
@@ -426,7 +418,6 @@ func (p *AudioOptions) layout() {
|
|||||||
item.Y = firstVisibleY - int32(i-p.scrollOffset)*itemStep
|
item.Y = firstVisibleY - int32(i-p.scrollOffset)*itemStep
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp scroll offset so we never show empty space
|
|
||||||
maxScroll := n - p.maxVisible
|
maxScroll := n - p.maxVisible
|
||||||
if maxScroll < 0 {
|
if maxScroll < 0 {
|
||||||
maxScroll = 0
|
maxScroll = 0
|
||||||
@@ -435,7 +426,6 @@ func (p *AudioOptions) layout() {
|
|||||||
p.scrollOffset = maxScroll
|
p.scrollOffset = maxScroll
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scrollbar track position
|
|
||||||
p.scrollTrackY = p.listAreaBottom
|
p.scrollTrackY = p.listAreaBottom
|
||||||
p.scrollTrackH = p.listAreaTop - p.listAreaBottom
|
p.scrollTrackH = p.listAreaTop - p.listAreaBottom
|
||||||
|
|
||||||
@@ -678,19 +668,25 @@ func (p *AudioOptions) Render() error {
|
|||||||
return fmt.Errorf("render audio toggle: %w", err)
|
return fmt.Errorf("render audio toggle: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section header
|
|
||||||
p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color)
|
p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color)
|
||||||
|
|
||||||
// List background
|
// List background — centered width matching widest item + padding
|
||||||
listBgColor := sdl.Color{R: 255, G: 255, B: 255, A: 12}
|
var maxItemW int32
|
||||||
|
for i := range p.audioFileTextures {
|
||||||
|
w := p.audioFileTextures[i].W
|
||||||
|
if w > maxItemW {
|
||||||
|
maxItemW = w
|
||||||
|
}
|
||||||
|
}
|
||||||
bgPad := int32(16)
|
bgPad := int32(16)
|
||||||
bgLeft := float32(p.listLeftX - bgPad)
|
bgW := maxItemW + 2*bgPad
|
||||||
|
bgLeft := float32(p.ScreenCenterX) - float32(bgW)/2
|
||||||
bgTop := float32(p.listAreaBottom)
|
bgTop := float32(p.listAreaBottom)
|
||||||
bgRight := float32(p.scrollTrackX)
|
|
||||||
bgBottom := float32(p.listAreaTop)
|
bgBottom := float32(p.listAreaTop)
|
||||||
|
listBgColor := sdl.Color{R: 255, G: 255, B: 255, A: 12}
|
||||||
p.RenderSolidColorQuad(p.solidShader,
|
p.RenderSolidColorQuad(p.solidShader,
|
||||||
mgl32.Vec2{bgLeft, bgTop},
|
mgl32.Vec2{bgLeft, bgTop},
|
||||||
mgl32.Vec2{bgRight - bgLeft, bgBottom - bgTop},
|
mgl32.Vec2{float32(bgW), bgBottom - bgTop},
|
||||||
listBgColor)
|
listBgColor)
|
||||||
|
|
||||||
// Audio file list (visible window only)
|
// Audio file list (visible window only)
|
||||||
@@ -705,7 +701,6 @@ func (p *AudioOptions) Render() error {
|
|||||||
return fmt.Errorf("render audio file %d: %w", i, err)
|
return fmt.Errorf("render audio file %d: %w", i, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selection dot — small filled circle left of the item name
|
|
||||||
if i == p.selectedFileIndex {
|
if i == p.selectedFileIndex {
|
||||||
dotX := float32(item.X) - p.dotSize - 6
|
dotX := float32(item.X) - p.dotSize - 6
|
||||||
dotY := float32(item.Y) + float32(item.H)/2 - p.dotSize/2
|
dotY := float32(item.Y) + float32(item.H)/2 - p.dotSize/2
|
||||||
@@ -714,13 +709,10 @@ func (p *AudioOptions) Render() error {
|
|||||||
mgl32.Vec2{p.dotSize, p.dotSize},
|
mgl32.Vec2{p.dotSize, p.dotSize},
|
||||||
colors.Green)
|
colors.Green)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scrollbar
|
|
||||||
p.renderScrollbar()
|
p.renderScrollbar()
|
||||||
|
|
||||||
// Spinning triangle cursor — always on top
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
)
|
)
|
||||||
|
|
||||||
var GitVersion = "07292026_1445"
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user