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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user