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.
This commit is contained in:
NeilClack
2026-07-11 13:43:05 -05:00
parent 2cc5071731
commit 3f8144a708
7 changed files with 252 additions and 26 deletions
+7
View File
@@ -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) - 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) - 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) - 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 ### Known Issues
- Active device highlight incorrect when using default device - Active device highlight incorrect when using default device
@@ -19,8 +21,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
### Added ### Added
- Human-readable audio device names (Built-in Speakers, HDMI/TV/Monitor) - Human-readable audio device names (Built-in Speakers, HDMI/TV/Monitor)
- Unit tests for mixer package (parseSinks, parseSinkLine, ConvertVolume) - 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) ### Changed (Major Refactors)
- Mixer package: inject audio backend dependencies for testability - Mixer package: inject audio backend dependencies for testability
- Mixer package: extract wpctl calls behind an interface - Mixer package: extract wpctl calls behind an interface
- SwitchDevice now takes *db.AppConfig to access saved volume - 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
+26 -9
View File
@@ -53,6 +53,8 @@ type Application struct {
DB *gorm.DB DB *gorm.DB
PageRouter *page.Router PageRouter *page.Router
state State state State
downloadsComplete bool
downloadFailed bool
} }
var lastNetworkCheckTime time.Time var lastNetworkCheckTime time.Time
@@ -153,12 +155,14 @@ func Run() {
app.initializeAudio() app.initializeAudio()
// Create a buffered channel so log writes don't slow down downloads. // Create a buffered channel so log writes don't slow down downloads.
downloadProgressChan := make(chan download.DownloadStatus, 1000) downloadProgressChan := make(chan download.DownloadStatus, 1000)
downloadDone := make(chan struct{})
app.PageRouter = page.NewRouter(page.PageCtx{ app.PageRouter = page.NewRouter(page.PageCtx{
DbPtr: app.DB, DbPtr: app.DB,
W: w, W: w,
H: h, H: h,
MessageChannel: downloadProgressChan, MessageChannel: downloadProgressChan,
DownloadDone: downloadDone,
}) })
// Sync the proclamations from the API to the Database // Sync the proclamations from the API to the Database
@@ -183,16 +187,11 @@ func Run() {
// "PinealGland.wav", // "PinealGland.wav",
// } // }
config, err := db.GetConfig(app.DB) audioDir := filepath.Join(homeDir, ".radiantwave", "audio")
if err != nil {
panic(err)
}
audioDir := filepath.Join(config.AssetsDir, "audio")
_, err = os.Stat(audioDir) _, err = os.Stat(audioDir)
if os.IsNotExist(err) { 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) 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 // 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() { func (app *Application) checkState() {
// app.state is READY so do nothing. // app.state is READY so do nothing.
if app.state == READY { if app.state == READY {
return 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 // Check if there is an internet connection, and if not, force the WiFi page
if !networkIsUp { if !networkIsUp {
if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second { if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second {
@@ -264,6 +272,15 @@ func (app *Application) checkState() {
return 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.state = READY
app.PageRouter.NavigateTo(page.HOME) app.PageRouter.NavigateTo(page.HOME)
} }
@@ -494,7 +511,7 @@ func (app *Application) initKeybinds() {
}) })
keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() { keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() {
if app.state != READY { if app.state != READY && app.state != DOWNLOADING_ASSETS {
return return
} }
app.PageRouter.NavigateTo(page.HOME) app.PageRouter.NavigateTo(page.HOME)
+186
View File
@@ -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
}
+8 -1
View File
@@ -13,7 +13,7 @@ import (
"rhemawavetech.com/rhemawave/internal/shaderManager" "rhemawavetech.com/rhemawave/internal/shaderManager"
) )
var GitVersion = "Audio-Bug-Fix 0.5" var GitVersion = "Audio-Bug-Fix 0.6"
type Home struct { type Home struct {
Base Base
@@ -31,6 +31,7 @@ type Home struct {
hasLicenseInfo bool hasLicenseInfo bool
baseFontSize int baseFontSize int
isDownloadComplete bool isDownloadComplete bool
downloadFailed bool
} }
// Init performs basic page initialization and creation of static assets. // 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: case progressUpdate, ok = <-p.Context.MessageChannel:
gotUpdate = true gotUpdate = true
if !ok { if !ok {
if p.downloadFailed {
p.setMessage("Download failed — restart to retry", colors.Red)
} else {
p.setMessage("Status: READY", colors.Green) p.setMessage("Status: READY", colors.Green)
p.isDownloadComplete = true p.isDownloadComplete = true
close(p.Context.DownloadDone)
}
return nil return nil
} }
default: default:
@@ -187,6 +193,7 @@ drained:
} }
if progressUpdate.Err != nil { if progressUpdate.Err != nil {
p.downloadFailed = true
p.setMessage(fmt.Sprintf("Error: %s", progressUpdate.Err.Error()), colors.Red) p.setMessage(fmt.Sprintf("Error: %s", progressUpdate.Err.Error()), colors.Red)
return nil return nil
} }
+1
View File
@@ -25,6 +25,7 @@ type PageCtx struct {
DbPtr *gorm.DB DbPtr *gorm.DB
W, H int32 W, H int32
MessageChannel chan download.DownloadStatus MessageChannel chan download.DownloadStatus
DownloadDone chan struct{} // closed when downloads finish (success or failure)
NavigateTo func(pageType) NavigateTo func(pageType)
Msg string // Message to be displayed by MessagePage Msg string // Message to be displayed by MessagePage
Inst string // Instructions to be displayed on MessagePage Inst string // Instructions to be displayed on MessagePage
+11 -6
View File
@@ -291,7 +291,7 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil { if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil {
selectedAudio = db.AudioFile{ selectedAudio = db.AudioFile{
Name: "OriginalAudio", Name: "OriginalAudio",
Path: filepath.Join(config.AssetsDir, "audio", "OriginalAudio.wav"), Path: filepath.Join(audioDir(), "OriginalAudio.wav"),
Selected: true, Selected: true,
Order: 0, Order: 0,
} }
@@ -420,14 +420,19 @@ func (p *ScrollerPage) loadProclamations() error {
return nil return nil
} }
// audioPathForFrequency builds the on-disk path for a solfeggio frequency's // audioDir returns the path to the audio files directory (~/.radiantwave/audio).
// audio file, e.g. 396 -> "<AssetsDir>/audio/396Hz.wav". func audioDir() string {
func (p *ScrollerPage) audioPathForFrequency(freq int) string { home, err := os.UserHomeDir()
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil { if err != nil {
panic(err) 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. // switchAudioForFrequency swaps the currently playing track to match freq.
+3
View File
@@ -82,6 +82,7 @@ in
# ── Data directory ──────────────────────────────────────────────────────── # ── Data directory ────────────────────────────────────────────────────────
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /home/kiosk/.local/share/rhemawave 0770 kiosk kiosk -" "d /home/kiosk/.local/share/rhemawave 0770 kiosk kiosk -"
"d /home/kiosk/.radiantwave/audio 0755 kiosk kiosk -"
]; ];
# ── RadiantWave service (via loginShellInit, same as production) ────────── # ── RadiantWave service (via loginShellInit, same as production) ──────────
@@ -108,6 +109,8 @@ in
# Asset sync + start app (same as production) # Asset sync + start app (same as production)
mkdir -p /home/kiosk/.local/share/rhemawave mkdir -p /home/kiosk/.local/share/rhemawave
${pkgs.rsync}/bin/rsync -ru ${rhemaApp}/share/rhemawave/ /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 cage -- ${rhemaApp}/bin/rhemawave -d -s > /tmp/kiosk.log 2>&1
fi fi