From 91711eef93280ae490c163e31b9d63786616f055 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 29 Jul 2026 16:19:15 -0500 Subject: [PATCH] Replace AudioFile with AudioTrack, fix download+orphan+MD5 sync, always play original audio fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/application/application.go | 2 +- internal/db/DB.go | 2 +- internal/db/Schema.go | 14 ++- internal/download/Download.go | 100 +++++++++++----- internal/download/download_test.go | 180 ++++++++++++++++++++++++++++ internal/page/AudioOptions.go | 114 ++++++++---------- internal/page/home.go | 2 +- internal/page/scrollerPage.go | 140 ++++++++++++++-------- 8 files changed, 402 insertions(+), 152 deletions(-) create mode 100644 internal/download/download_test.go diff --git a/internal/application/application.go b/internal/application/application.go index 3610781..0b751fc 100644 --- a/internal/application/application.go +++ b/internal/application/application.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() } diff --git a/internal/db/DB.go b/internal/db/DB.go index 0e852a5..2c2676f 100644 --- a/internal/db/DB.go +++ b/internal/db/DB.go @@ -107,6 +107,6 @@ func Migrate(db *gorm.DB) error { &Proclamation{}, &ProclamationCategory{}, &SolfeggioFrequency{}, - &AudioFile{}, + &AudioTrack{}, ) } diff --git a/internal/db/Schema.go b/internal/db/Schema.go index fb19b50..241dac8 100644 --- a/internal/db/Schema.go +++ b/internal/db/Schema.go @@ -50,10 +50,12 @@ type SolfeggioFrequency struct { Value int } -type AudioFile struct { - ID int `gorm:"primaryKey"` - Name string - Path string - Selected bool - Order int +type AudioTrack struct { + ID int `gorm:"primaryKey"` + Filename string + Name string + AssignedFrequency int + MD5 string + Selected bool + Order int } diff --git a/internal/download/Download.go b/internal/download/Download.go index 60c2bd0..363b8f1 100644 --- a/internal/download/Download.go +++ b/internal/download/Download.go @@ -10,6 +10,8 @@ import ( "path/filepath" "strings" + "gorm.io/gorm" + "rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/logger" ) @@ -25,36 +27,36 @@ type DownloadStatus struct { // manifest maps filename → expected MD5 hex string type manifest map[string]string -// ─── Server script to generate checksums.md5 ───────────────────────────────── -// -// Run this on your file server whenever you add or replace audio files: -// -// cd /path/to/audio && md5sum *.mp3 > checksums.md5 -// -// That's it. The app will pick up changes automatically on next run. -// ───────────────────────────────────────────────────────────────────────────── - const manifestFilename = "checksums.md5" -// DownloadAudioFiles downloads audio files, skipping any that already exist -// locally with a matching MD5. Files with a wrong checksum are deleted and -// re-downloaded. Progress is sent to progressChan, which is closed on return. -func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlBase string) { +// DownloadAudioFiles downloads audio files using the remote checksums.md5 as +// the source of truth. It: +// - Downloads any file in the manifest that is missing locally. +// - Re-downloads files whose local MD5 differs from the manifest. +// - Removes local files not listed in the manifest (orphans). +// - Removes DB AudioTrack records not listed in the manifest. +// - Updates AudioTrack.MD5 in the database after every successful download. +// +// Progress is sent to progressChan, which is closed on return. +func DownloadAudioFiles(progressChan chan<- DownloadStatus, dbHandle *gorm.DB, destDir string, urlBase string) { defer close(progressChan) - send := func(cur, max int64, msg string, err error) { - progressChan <- DownloadStatus{Cur: cur, Max: max, Msg: msg, Err: err} + var totalDownloaded int64 + var filesDownloaded int + + send := func(cur int64, msg string, err error) { + progressChan <- DownloadStatus{Cur: cur, Max: 0, Msg: msg, Err: err} } - send(0, 0, "Fetching file manifest...", nil) + send(0, "Fetching file manifest...", nil) m, err := fetchManifest(urlBase + manifestFilename) if err != nil { - send(0, 0, "Failed to fetch manifest", err) + send(0, "Failed to fetch manifest", err) return } logger.Info("download: manifest loaded, %d files listed", len(m)) - send(0, 0, "Comparing checksums...", nil) + send(0, "Comparing checksums...", nil) type work struct { filename string @@ -82,19 +84,47 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB } } + // ── Remove local files not in manifest (orphans) ───────────────────── + entries, err := os.ReadDir(destDir) + if err == nil { + for _, entry := range entries { + if entry.IsDir() { + continue + } + if _, ok := m[entry.Name()]; !ok { + orphanPath := filepath.Join(destDir, entry.Name()) + logger.Info("download: removing orphan file %s", orphanPath) + if err := os.Remove(orphanPath); err != nil { + logger.Warning("download: failed to remove orphan %s: %v", orphanPath, err) + } + } + } + } else { + logger.Warning("download: could not read destDir for orphan cleanup: %v", err) + } + + // ── Remove DB records not in manifest ──────────────────────────────── + if dbHandle != nil { + var allTracks []db.AudioTrack + dbHandle.Find(&allTracks) + for _, track := range allTracks { + if _, ok := m[track.Filename]; !ok { + logger.Info("download: removing orphan DB record %s (filename=%s)", track.Name, track.Filename) + dbHandle.Delete(&track) + } + } + } + if len(queue) == 0 { - send(0, 0, "All files are up to date.", nil) + send(0, "All files are up to date.", nil) logger.Info("download: nothing to do") return } total := len(queue) - send(0, 0, fmt.Sprintf("0 / %d Files Downloaded", total), nil) + send(0, fmt.Sprintf("0 / %d Files Downloaded", total), nil) logger.Info("download: %d file(s) to download", total) - totalDownloaded := int64(0) - filesDownloaded := 0 - for _, w := range queue { url := urlBase + w.filename destPath := filepath.Join(destDir, w.filename) @@ -103,12 +133,12 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB downloaded, err := downloadFile(url, destPath, func(n int64) { totalDownloaded += n - send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), nil) + send(totalDownloaded, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), nil) }) if err != nil { logger.Error("download: failed to download %s: %v", w.filename, err) - send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), err) + send(totalDownloaded, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), err) return } @@ -116,24 +146,35 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB if err != nil { logger.Error("download: could not hash %s after download: %v", w.filename, err) os.Remove(destPath) - send(totalDownloaded, 0, "Checksum verification failed", err) + send(totalDownloaded, "Checksum verification failed", err) return } if gotMD5 != w.expectedMD5 { err := fmt.Errorf("checksum mismatch after download: got %s, want %s", gotMD5, w.expectedMD5) logger.Error("download: %s %v", w.filename, err) os.Remove(destPath) - send(totalDownloaded, 0, "Checksum verification failed — file removed", err) + send(totalDownloaded, "Checksum verification failed — file removed", err) return } + // Update MD5 in database + if dbHandle != nil { + var track db.AudioTrack + result := dbHandle.Where("filename = ?", w.filename).First(&track) + if result.Error == nil { + track.MD5 = gotMD5 + dbHandle.Save(&track) + logger.Debug("download: updated MD5 for %s", w.filename) + } + } + filesDownloaded++ statusMsg := fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total) logger.Info("download: %s (%d bytes)", statusMsg, downloaded) - send(totalDownloaded, 0, statusMsg, nil) + send(totalDownloaded, statusMsg, nil) } - send(totalDownloaded, totalDownloaded, "All downloads complete!", nil) + send(totalDownloaded, "All downloads complete!", nil) logger.Info("download: all files downloaded and verified") } @@ -167,6 +208,7 @@ func fetchManifest(url string) (manifest, error) { } hash := strings.TrimSpace(parts[0]) name := strings.TrimSpace(parts[1]) + name = strings.TrimPrefix(name, "./") m[name] = hash } diff --git a/internal/download/download_test.go b/internal/download/download_test.go new file mode 100644 index 0000000..c98aa58 --- /dev/null +++ b/internal/download/download_test.go @@ -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) + } +} diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go index edd1721..ff7005d 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -17,25 +17,25 @@ import ( 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) +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 { Base font *fontManager.FontEntry @@ -51,6 +51,7 @@ type AudioOptions struct { AudioStatusToggle StringItem audioFileTextures []StringItem + audioTracks []db.AudioTrack selectedFileIndex int VolumeLabel StringItem @@ -68,10 +69,8 @@ type AudioOptions struct { focusState int - // Section header sectionHeader StringItem - // Scrolling scrollOffset int maxVisible int listItemMargin int32 @@ -83,10 +82,8 @@ type AudioOptions struct { scrollTrackY int32 scrollTrackH int32 - // Selection dot dotSize float32 - // List left-alignment X (same for all items) listLeftX int32 } @@ -158,8 +155,8 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") } - if err = p.seedAudioFiles(); err != nil { - logger.Error("failed to seed audio files: %v", err) + if err = p.seedAudioTracks(); err != nil { + logger.Error("failed to seed audio tracks: %v", err) } p.buildAudioFileList() @@ -195,6 +192,10 @@ func (p *AudioOptions) buildAudioFileList() { } 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) if err != nil { 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) } - for _, af := range AudioFileList { - item, err := NewStringItem(af.Name, p.font, colors.White) + for _, track := range tracks { + item, err := NewStringItem(track.Name, p.font, colors.White) if err != nil { - logger.Error("failed to create audio file string item for %s: %v", af.Name, err) + logger.Error("failed to create audio track string item for %s: %v", track.Name, err) continue } item.Scale(p.ItemScale) @@ -228,14 +229,14 @@ func (p *AudioOptions) updateSelectedIndex() { return } - var selected db.AudioFile + var selected db.AudioTrack if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil { p.selectedFileIndex = 0 return } - for i, af := range AudioFileList { - if af.ID == selected.ID { + for i, track := range p.audioTracks { + if track.ID == selected.ID { p.selectedFileIndex = i + 1 return } @@ -244,13 +245,13 @@ func (p *AudioOptions) updateSelectedIndex() { p.selectedFileIndex = 0 } -func (p *AudioOptions) seedAudioFiles() error { - var existing []db.AudioFile +func (p *AudioOptions) seedAudioTracks() error { + var existing []db.AudioTrack if err := p.Context.DbPtr.Find(&existing).Error; err != nil { return err } if len(existing) == 0 { - return p.Context.DbPtr.Save(&AudioFileList).Error + return p.Context.DbPtr.Save(&audioTrackSeed).Error } return nil } @@ -284,13 +285,13 @@ func (p *AudioOptions) selectAudioFile(index int) { if index == 0 { 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 { config.DynamicAudio = false - fileIndex := index - 1 - if fileIndex >= 0 && fileIndex < len(AudioFileList) { - p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false) - p.Context.DbPtr.Model(&db.AudioFile{}).Where("id = ?", AudioFileList[fileIndex].ID).Update("selected", true) + trackIndex := index - 1 + if trackIndex >= 0 && trackIndex < len(p.audioTracks) { + p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false) + p.Context.DbPtr.Model(&db.AudioTrack{}).Where("id = ?", p.audioTracks[trackIndex].ID).Update("selected", true) } } @@ -350,15 +351,12 @@ func (p *AudioOptions) layout() { panic(err) } - // Title p.title.X = p.ScreenCenterX - p.title.W/2 p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2 - // Prompt p.prompt.X = p.ScreenCenterX - p.prompt.W/2 p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H - // Volume row barH := int32(float32(config.StandardFontSize) * p.ItemScale) barW := int32(float32(p.ScreenWidth) * 0.30) barX := p.ScreenCenterX - barW/2 @@ -376,20 +374,16 @@ func (p *AudioOptions) layout() { p.volumePercent.X = barX + barW + gap p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2 - // Audio toggle p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 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.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H - // Scrollable list area boundaries p.listItemHeight = p.audioFileTextures[0].H listTop := p.sectionHeader.Y - p.listItemMargin listBottomMargin := int32(12) - // Available height for the list listH := listTop - listBottomMargin if listH < p.listItemHeight { listH = p.listItemHeight @@ -398,7 +392,6 @@ func (p *AudioOptions) layout() { p.listAreaTop = listTop p.listAreaBottom = listBottomMargin - // How many items fit itemStep := p.listItemHeight + p.listItemMargin n := len(p.audioFileTextures) p.maxVisible = int(listH / itemStep) @@ -409,16 +402,15 @@ func (p *AudioOptions) layout() { p.maxVisible = n } - // Compute left alignment: find the widest item to center the block var maxItemW int32 for i := range p.audioFileTextures { - if p.audioFileTextures[i].W > maxItemW { - maxItemW = p.audioFileTextures[i].W + w := p.audioFileTextures[i].W + if w > maxItemW { + maxItemW = w } } p.listLeftX = p.ScreenCenterX - maxItemW/2 - // Position each item — scrollOffset shifts the whole list up/down firstVisibleY := p.listAreaTop - p.listItemHeight for i := range p.audioFileTextures { item := &p.audioFileTextures[i] @@ -426,7 +418,6 @@ func (p *AudioOptions) layout() { item.Y = firstVisibleY - int32(i-p.scrollOffset)*itemStep } - // Clamp scroll offset so we never show empty space maxScroll := n - p.maxVisible if maxScroll < 0 { maxScroll = 0 @@ -435,7 +426,6 @@ func (p *AudioOptions) layout() { p.scrollOffset = maxScroll } - // Scrollbar track position p.scrollTrackY = 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) } - // Section header p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color) - // List background - listBgColor := sdl.Color{R: 255, G: 255, B: 255, A: 12} + // List background — centered width matching widest item + padding + var maxItemW int32 + for i := range p.audioFileTextures { + w := p.audioFileTextures[i].W + if w > maxItemW { + maxItemW = w + } + } bgPad := int32(16) - bgLeft := float32(p.listLeftX - bgPad) + bgW := maxItemW + 2*bgPad + bgLeft := float32(p.ScreenCenterX) - float32(bgW)/2 bgTop := float32(p.listAreaBottom) - bgRight := float32(p.scrollTrackX) 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{bgRight - bgLeft, bgBottom - bgTop}, + mgl32.Vec2{float32(bgW), bgBottom - bgTop}, listBgColor) // 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) } - // Selection dot — small filled circle left of the item name if i == p.selectedFileIndex { dotX := float32(item.X) - p.dotSize - 6 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}, colors.Green) } - } - // Scrollbar p.renderScrollbar() - // Spinning triangle cursor — always on top if err := p.renderCursor(); err != nil { return fmt.Errorf("render cursor: %w", err) } diff --git a/internal/page/home.go b/internal/page/home.go index b8d30d8..8ed6e0a 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -13,7 +13,7 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) -var GitVersion = "07292026_1445" +var GitVersion = "07292026_1618" type Home struct { Base diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index fa80ea5..d287efb 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -38,6 +38,7 @@ type ScrollerPage struct { audioRetryTimer float32 audioStarted bool + originalAudioStarted bool dynamicAudio bool lines *LineDeque @@ -284,34 +285,32 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error { logger.Info("DynamicAudio mode: %v", p.dynamicAudio) // Start audio for the first proclamation in the stream. - started := false - if len(p.streams) > 0 { - if p.dynamicAudio { - started = p.switchAudioForFrequency(p.streams[0].frequencyValue) - } + if len(p.streams) > 0 && p.dynamicAudio { + p.switchAudioForFrequency(p.streams[0].frequencyValue) + p.audioStarted = true } - if !started { - // Non-dynamic mode or frequency-specific file not yet available. - // Play whatever track is marked Selected in the audio_files table. - var selectedAudio db.AudioFile - if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil { - selectedAudio = db.AudioFile{ - Name: "OriginalAudio", - Path: filepath.Join(audioDir(), "OriginalAudio.wav"), - Selected: true, - Order: 0, + if !p.audioStarted && !p.dynamicAudio { + // Static track selected in Audio Options. Play whatever track is + // marked Selected in the audio_tracks table. + var selectedTrack db.AudioTrack + if err := p.Context.DbPtr.Where("selected = ?", true).First(&selectedTrack).Error; err != nil { + logger.Warning("No selected audio track found, audio will start when a track is chosen") + } else { + path := filepath.Join(audioDir(), selectedTrack.Filename) + if _, err := os.Stat(path); err == nil { + logger.Info("Playing %s", path) + if err := mixer.Play(path, true, config); err != nil { + logger.Error("Mixer returned an error: %v", err) + } else { + p.audioStarted = true + } + } else { + logger.Warning("Selected audio file %s not found yet (download may still be in progress)", path) } } - - logger.Info("Playing %s", selectedAudio.Path) - if err := mixer.Play(selectedAudio.Path, true, config); err != nil { - logger.Error("Mixer returned an error: %v", err) - } } - p.audioStarted = started - currentDevice := mixer.CurrentDevice() if currentDevice == "" { currentDevice = "default" @@ -438,36 +437,55 @@ func audioDir() string { return filepath.Join(home, ".radiantwave", "audio") } -// audioPathForFrequency builds the on-disk path for a solfeggio frequency's -// audio file, e.g. 396 -> "~/.radiantwave/audio/396Hz.wav". -func (p *ScrollerPage) audioPathForFrequency(freq int) string { - return filepath.Join(audioDir(), fmt.Sprintf("%dHz.wav", freq)) -} - // switchAudioForFrequency swaps the currently playing track to match freq. +// If freq is 0 or no audio track exists for it, falls back to the original +// / selected audio track (so audio never goes silent in Ready state). // -// - freq == 0 ("no specific frequency", i.e. the default proclamation) is -// always a no-op — whatever is playing keeps playing. -// - If freq already matches what's playing, nothing happens. -// - If the matching file doesn't exist on disk yet, the current track -// keeps playing untouched (so missing solfeggio audio never causes -// silence — and starts working automatically once the file is added). -// - Otherwise the current track is stopped immediately and freq's track -// starts right away (hard cut, no fade). -// -// Returns true if freq's track is now playing (either just switched, or -// already was). +// Returns true if audio is now playing (either frequency-specific or fallback). func (p *ScrollerPage) switchAudioForFrequency(freq int) bool { - if freq == 0 { - return false - } - if freq == p.currentAudioFreq { + if freq != 0 && freq == p.currentAudioFreq { return true } - path := p.audioPathForFrequency(freq) + if freq != 0 { + if track, err := p.audioTrackForFrequency(freq); err == nil { + path := filepath.Join(audioDir(), track.Filename) + if _, err := os.Stat(path); err == nil { + config, err := db.GetConfig(p.Context.DbPtr) + if err != nil { + panic(err) + } + mixer.Stop(3000, config) + if err := mixer.Play(path, true, config); err != nil { + logger.Error("Mixer returned an error: %v", err) + return false + } + p.currentAudioFreq = freq + return true + } + } + } + + return p.playOriginalAudio() +} + +// playOriginalAudio plays the selected AudioTrack, or the OriginalAudio track +// if nothing is selected. Returns true if audio started successfully. +func (p *ScrollerPage) playOriginalAudio() bool { + if p.originalAudioStarted { + return true + } + + var track db.AudioTrack + if err := p.Context.DbPtr.Where("selected = ?", true).First(&track).Error; err != nil { + if err := p.Context.DbPtr.Where("assigned_frequency = ?", 0).First(&track).Error; err != nil { + logger.Warning("No audio tracks available") + return false + } + } + path := filepath.Join(audioDir(), track.Filename) if _, err := os.Stat(path); err != nil { - logger.Warning("No audio file for %dHz (%s), keeping current track", freq, path) + logger.Warning("Original audio file not found: %s", path) return false } config, err := db.GetConfig(p.Context.DbPtr) @@ -479,10 +497,17 @@ func (p *ScrollerPage) switchAudioForFrequency(freq int) bool { logger.Error("Mixer returned an error: %v", err) return false } - p.currentAudioFreq = freq + p.originalAudioStarted = true + p.currentAudioFreq = 0 return true } +func (p *ScrollerPage) audioTrackForFrequency(freq int) (db.AudioTrack, error) { + var track db.AudioTrack + err := p.Context.DbPtr.Where("assigned_frequency = ?", freq).First(&track).Error + return track, err +} + // nextLineWords gets words for the current line and advances proclamation stream when needed. func (p *ScrollerPage) nextLineWords() []string { if len(p.streams) == 0 { @@ -645,20 +670,29 @@ func (p *ScrollerPage) HandleEvent(event *sdl.Event) error { func (p *ScrollerPage) Update(dt float32) error { p.updateAutoVelocity(dt) - // In dynamic mode, retry audio switch every 5 seconds until the - // frequency-specific file is found. In non-dynamic mode the selected - // file plays continuously — nothing to retry. - if p.dynamicAudio && !p.audioStarted { + // Periodically try to switch to the frequency-specific file in case + // it was just downloaded (only when the stream's frequency differs from + // whatever is currently playing). + if p.dynamicAudio && len(p.streams) > 0 { p.audioRetryTimer += dt if p.audioRetryTimer >= 5.0 { p.audioRetryTimer = 0 - if len(p.streams) > 0 { - freq := p.streams[p.streamIndex].frequencyValue - p.audioStarted = p.switchAudioForFrequency(freq) || freq == 0 + freq := p.streams[p.streamIndex].frequencyValue + if freq != 0 && freq != p.currentAudioFreq { + p.switchAudioForFrequency(freq) } } } + // Non-dynamic mode: retry selected track in case download just finished. + if !p.dynamicAudio && !p.audioStarted { + p.audioRetryTimer += dt + if p.audioRetryTimer >= 5.0 { + p.audioRetryTimer = 0 + p.audioStarted = p.playOriginalAudio() + } + } + p.scrollOffset += dt * p.velocity move := int32(p.scrollOffset) if move == 0 {