From 3f8144a708bb49ac3b418955d3691927ced8b38f Mon Sep 17 00:00:00 2001 From: NeilClack Date: Sat, 11 Jul 2026 13:43:05 -0500 Subject: [PATCH] Gate READY on download completion; move audio to ~/.radiantwave/audio State machine now uses DOWNLOADING_ASSETS before READY. Downloads closing the DownloadDone channel signals checkState() to proceed. Download failure sets downloadFailed flag which prevents READY. Audio files moved from ~/.local/share/rhemawave/audio to ~/.radiantwave/audio/. Regression tests in application_test.go (linux build-tagged due to network package D-Bus init). Version 0.6. --- changelog.txt | 7 + internal/application/application.go | 51 ++++--- internal/application/application_test.go | 186 +++++++++++++++++++++++ internal/page/home.go | 13 +- internal/page/router.go | 1 + internal/page/scrollerPage.go | 17 ++- nixos/vm.nix | 3 + 7 files changed, 252 insertions(+), 26 deletions(-) create mode 100644 internal/application/application_test.go diff --git a/changelog.txt b/changelog.txt index 118bfe8..e5da6ab 100644 --- a/changelog.txt +++ b/changelog.txt @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - SwitchDevice not persisted to database across restarts (caller now saves to AppConfig) - Device restore path passes invalid device name "system default" to SDL (now passes "" for default) - Stop() no-op when audio toggled off mid-playback (removed AudioEnabled guard from Stop) +- App shows "READY" before audio files finish downloading (state machine now gates READY on download completion via DOWNLOADING_ASSETS state) +- Download failure no longer silently allows READY (downloadFailed flag prevents premature READY) ### Known Issues - Active device highlight incorrect when using default device @@ -19,8 +21,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added - Human-readable audio device names (Built-in Speakers, HDMI/TV/Monitor) - Unit tests for mixer package (parseSinks, parseSinkLine, ConvertVolume) +- Regression tests for state machine (download gating, failure handling) +- Regression tests for mixer (Stop with AudioEnabled=false, SwitchDevice edge cases) ### Changed (Major Refactors) - Mixer package: inject audio backend dependencies for testability - Mixer package: extract wpctl calls behind an interface - SwitchDevice now takes *db.AppConfig to access saved volume +- Audio files moved from ~/.local/share/rhemawave/audio to ~/.radiantwave/audio +- State machine uses DOWNLOADING_ASSETS state before READY +- CI now runs go test before nix build diff --git a/internal/application/application.go b/internal/application/application.go index 2454d48..da6a78b 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -45,14 +45,16 @@ const ( ) type Application struct { - Window *sdl.Window - License *license.Response - pageStack []page.Page - pendingAction func() - running bool - DB *gorm.DB - PageRouter *page.Router - state State + Window *sdl.Window + License *license.Response + pageStack []page.Page + pendingAction func() + running bool + DB *gorm.DB + PageRouter *page.Router + state State + downloadsComplete bool + downloadFailed bool } var lastNetworkCheckTime time.Time @@ -153,12 +155,14 @@ func Run() { app.initializeAudio() // Create a buffered channel so log writes don't slow down downloads. downloadProgressChan := make(chan download.DownloadStatus, 1000) + downloadDone := make(chan struct{}) app.PageRouter = page.NewRouter(page.PageCtx{ DbPtr: app.DB, W: w, H: h, MessageChannel: downloadProgressChan, + DownloadDone: downloadDone, }) // Sync the proclamations from the API to the Database @@ -183,16 +187,11 @@ func Run() { // "PinealGland.wav", // } - config, err := db.GetConfig(app.DB) - if err != nil { - panic(err) - } - - audioDir := filepath.Join(config.AssetsDir, "audio") + audioDir := filepath.Join(homeDir, ".radiantwave", "audio") _, err = os.Stat(audioDir) if os.IsNotExist(err) { - if err = os.MkdirAll(audioDir, 0740); err != nil { + if err = os.MkdirAll(audioDir, 0755); err != nil { logger.Error("unable to create audio directory: %v", err) } } @@ -203,13 +202,22 @@ func Run() { } // checkState keeps watch of the application state and sets pages -// accordingly. This function runs once per +// accordingly. This function runs once per frame. func (app *Application) checkState() { // app.state is READY so do nothing. if app.state == READY { return } + // Check if downloads have finished (non-blocking) + if !app.downloadsComplete { + select { + case <-app.PageRouter.Context.DownloadDone: + app.downloadsComplete = true + default: + } + } + // Check if there is an internet connection, and if not, force the WiFi page if !networkIsUp { if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second { @@ -264,6 +272,15 @@ func (app *Application) checkState() { return } + // All config prerequisites met — now wait for downloads to finish + if !app.downloadsComplete { + if app.state != DOWNLOADING_ASSETS { + app.state = DOWNLOADING_ASSETS + app.PageRouter.NavigateTo(page.HOME) + } + return + } + app.state = READY app.PageRouter.NavigateTo(page.HOME) } @@ -494,7 +511,7 @@ func (app *Application) initKeybinds() { }) keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() { - if app.state != READY { + if app.state != READY && app.state != DOWNLOADING_ASSETS { return } app.PageRouter.NavigateTo(page.HOME) diff --git a/internal/application/application_test.go b/internal/application/application_test.go new file mode 100644 index 0000000..75765b5 --- /dev/null +++ b/internal/application/application_test.go @@ -0,0 +1,186 @@ +//go:build linux + +package application + +import ( + "testing" + + "gorm.io/gorm" + "rhemawavetech.com/rhemawave/internal/db" + "rhemawavetech.com/rhemawave/internal/page" +) + +// Regression: The app used to reach READY before audio downloads completed, +// showing "Status: READY" while "0 / 14 Files Downloaded" was still in progress. +// The fix gates READY on downloadsComplete being true. + +// TestStateTransitionsToDownloadingAssets verifies that when all config +// prerequisites are met but downloads are not complete, the state transitions +// to DOWNLOADING_ASSETS instead of READY. +func TestStateTransitionsToDownloadingAssets(t *testing.T) { + // Set up minimal application state + app := &Application{ + state: FIRST_RUN, + DB: setupTestDB(t), + running: true, + } + + // Simulate: network is up, email and license are set + networkIsUp = true + config, err := db.GetConfig(app.DB) + if err != nil { + t.Fatal(err) + } + config.EmailAddress = "test@example.com" + config.LicenseKey = "test-key-123" + app.DB.Save(config) + + // Downloads are NOT complete + app.downloadsComplete = false + + // Create a minimal router with channels + downloadDone := make(chan struct{}) + app.PageRouter = &page.Router{ + Context: page.PageCtx{ + DbPtr: app.DB, + DownloadDone: downloadDone, + }, + } + + // Run checkState + app.checkState() + + // Should be in DOWNLOADING_ASSETS, not READY + if app.state != DOWNLOADING_ASSETS { + t.Errorf("state = %v, want DOWNLOADING_ASSETS (downloads not complete)", app.state) + } +} + +// TestStateTransitionsToReadyWhenDownloadsComplete verifies that the state +// transitions to READY only after downloads are complete. +func TestStateTransitionsToReadyWhenDownloadsComplete(t *testing.T) { + app := &Application{ + state: FIRST_RUN, + DB: setupTestDB(t), + running: true, + } + + networkIsUp = true + config, err := db.GetConfig(app.DB) + if err != nil { + t.Fatal(err) + } + config.EmailAddress = "test@example.com" + config.LicenseKey = "test-key-123" + app.DB.Save(config) + + // Downloads ARE complete + app.downloadsComplete = true + + downloadDone := make(chan struct{}) + app.PageRouter = &page.Router{ + Context: page.PageCtx{ + DbPtr: app.DB, + DownloadDone: downloadDone, + }, + } + + app.checkState() + + if app.state != READY { + t.Errorf("state = %v, want READY", app.state) + } +} + +// Regression: Download failure should prevent the app from reaching READY. +// If downloads fail, the user sees an error and must restart to retry. +func TestDownloadFailurePreventsReady(t *testing.T) { + app := &Application{ + state: FIRST_RUN, + DB: setupTestDB(t), + running: true, + } + + networkIsUp = true + config, err := db.GetConfig(app.DB) + if err != nil { + t.Fatal(err) + } + config.EmailAddress = "test@example.com" + config.LicenseKey = "test-key-123" + app.DB.Save(config) + + // Downloads completed but failed + app.downloadsComplete = false + app.downloadFailed = true + + downloadDone := make(chan struct{}) + app.PageRouter = &page.Router{ + Context: page.PageCtx{ + DbPtr: app.DB, + DownloadDone: downloadDone, + }, + } + + app.checkState() + + // Should NOT be READY — download failed + if app.state == READY { + t.Error("state should not be READY when download has failed") + } +} + +// TestStateStaysReadyOnceSet verifies that once the state reaches READY, +// checkState() is a no-op (doesn't reset or re-navigate). +func TestStateStaysReadyOnceSet(t *testing.T) { + app := &Application{ + state: READY, + DB: setupTestDB(t), + running: true, + } + + downloadDone := make(chan struct{}) + app.PageRouter = &page.Router{ + Context: page.PageCtx{ + DbPtr: app.DB, + DownloadDone: downloadDone, + }, + } + + app.checkState() + + if app.state != READY { + t.Errorf("state = %v, want READY (should not change once set)", app.state) + } +} + +// setupTestDB creates a temporary in-memory SQLite database for testing. +func setupTestDB(t *testing.T) *gorm.DB { + t.Helper() + d, err := db.Connect(":memory:") + if err != nil { + t.Fatal(err) + } + if err := db.Migrate(d); err != nil { + t.Fatal(err) + } + // Create default config + cfg := &db.AppConfig{ + Model: gorm.Model{ID: 1}, + DataDir: t.TempDir(), + AssetsDir: t.TempDir(), + SystemLevel: "BASIC", + SelectedAudioDevice: "default", + InitialFontSize: 128, + StandardFontSize: 32, + LinePattern: "fibonacci", + AudioEnabled: true, + SavedVolume: 1.0, + EmailAddress: "", + LicenseKey: "", + } + if err := d.Create(cfg).Error; err != nil { + t.Fatal(err) + } + return d +} diff --git a/internal/page/home.go b/internal/page/home.go index c7896b1..5e10bca 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -13,7 +13,7 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) -var GitVersion = "Audio-Bug-Fix 0.5" +var GitVersion = "Audio-Bug-Fix 0.6" type Home struct { Base @@ -31,6 +31,7 @@ type Home struct { hasLicenseInfo bool baseFontSize int isDownloadComplete bool + downloadFailed bool } // Init performs basic page initialization and creation of static assets. @@ -172,8 +173,13 @@ func (p *Home) Update(dt float32) error { case progressUpdate, ok = <-p.Context.MessageChannel: gotUpdate = true if !ok { - p.setMessage("Status: READY", colors.Green) - p.isDownloadComplete = true + if p.downloadFailed { + p.setMessage("Download failed — restart to retry", colors.Red) + } else { + p.setMessage("Status: READY", colors.Green) + p.isDownloadComplete = true + close(p.Context.DownloadDone) + } return nil } default: @@ -187,6 +193,7 @@ drained: } if progressUpdate.Err != nil { + p.downloadFailed = true p.setMessage(fmt.Sprintf("Error: %s", progressUpdate.Err.Error()), colors.Red) return nil } diff --git a/internal/page/router.go b/internal/page/router.go index a2737b7..340b667 100644 --- a/internal/page/router.go +++ b/internal/page/router.go @@ -25,6 +25,7 @@ type PageCtx struct { DbPtr *gorm.DB W, H int32 MessageChannel chan download.DownloadStatus + DownloadDone chan struct{} // closed when downloads finish (success or failure) NavigateTo func(pageType) Msg string // Message to be displayed by MessagePage Inst string // Instructions to be displayed on MessagePage diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index 157ce5b..406f79b 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -291,7 +291,7 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error { if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil { selectedAudio = db.AudioFile{ Name: "OriginalAudio", - Path: filepath.Join(config.AssetsDir, "audio", "OriginalAudio.wav"), + Path: filepath.Join(audioDir(), "OriginalAudio.wav"), Selected: true, Order: 0, } @@ -420,14 +420,19 @@ func (p *ScrollerPage) loadProclamations() error { return nil } -// audioPathForFrequency builds the on-disk path for a solfeggio frequency's -// audio file, e.g. 396 -> "/audio/396Hz.wav". -func (p *ScrollerPage) audioPathForFrequency(freq int) string { - config, err := db.GetConfig(p.Context.DbPtr) +// audioDir returns the path to the audio files directory (~/.radiantwave/audio). +func audioDir() string { + home, err := os.UserHomeDir() if err != nil { panic(err) } - return filepath.Join(config.AssetsDir, "audio", fmt.Sprintf("%dHz.wav", freq)) + 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. diff --git a/nixos/vm.nix b/nixos/vm.nix index db2334b..968760e 100644 --- a/nixos/vm.nix +++ b/nixos/vm.nix @@ -82,6 +82,7 @@ in # ── Data directory ──────────────────────────────────────────────────────── systemd.tmpfiles.rules = [ "d /home/kiosk/.local/share/rhemawave 0770 kiosk kiosk -" + "d /home/kiosk/.radiantwave/audio 0755 kiosk kiosk -" ]; # ── RadiantWave service (via loginShellInit, same as production) ────────── @@ -108,6 +109,8 @@ in # Asset sync + start app (same as production) mkdir -p /home/kiosk/.local/share/rhemawave ${pkgs.rsync}/bin/rsync -ru ${rhemaApp}/share/rhemawave/ /home/kiosk/.local/share/rhemawave/ + mkdir -p /home/kiosk/.radiantwave/audio + ${pkgs.rsync}/bin/rsync -ru ${rhemaApp}/share/rhemawave/audio/ /home/kiosk/.radiantwave/audio/ cage -- ${rhemaApp}/bin/rhemawave -d -s > /tmp/kiosk.log 2>&1 fi