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.
549 lines
14 KiB
Go
549 lines
14 KiB
Go
package application
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/go-gl/gl/v3.3-core/gl"
|
|
"github.com/veandco/go-sdl2/sdl"
|
|
"github.com/veandco/go-sdl2/ttf"
|
|
"gorm.io/gorm"
|
|
"rhemawavetech.com/rhemawave/internal/db"
|
|
"rhemawavetech.com/rhemawave/internal/download"
|
|
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
"rhemawavetech.com/rhemawave/internal/graphics"
|
|
"rhemawavetech.com/rhemawave/internal/keybinds"
|
|
"rhemawavetech.com/rhemawave/internal/license"
|
|
"rhemawavetech.com/rhemawave/internal/logger"
|
|
"rhemawavetech.com/rhemawave/internal/mixer"
|
|
"rhemawavetech.com/rhemawave/internal/network"
|
|
"rhemawavetech.com/rhemawave/internal/page"
|
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
"rhemawavetech.com/rhemawave/internal/sync"
|
|
)
|
|
|
|
const (
|
|
targetFPS = 60.0
|
|
openGLMajorVersion = 3
|
|
openGLMinorVersion = 3
|
|
// volumeStepSize = 16
|
|
// apiURL = "https://portal.rhemawavetech.com/api/v1"
|
|
)
|
|
|
|
type State int
|
|
|
|
const (
|
|
FIRST_RUN State = iota
|
|
NEEDS_INTERNET
|
|
NEEDS_LICENSE_KEY
|
|
NEEDS_EMAIL_ADDRESS
|
|
DOWNLOADING_ASSETS
|
|
READY
|
|
)
|
|
|
|
type Application struct {
|
|
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
|
|
var networkIsUp bool
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Main Entry Point
|
|
// ----------------------------------------------------------------------------
|
|
|
|
func Run() {
|
|
var err error
|
|
var homeDir string
|
|
var dataDir string
|
|
var DB *gorm.DB
|
|
|
|
networkIsUp = false
|
|
|
|
logger.Info("Starting Application")
|
|
|
|
homeDir, err = os.UserHomeDir()
|
|
if err != nil {
|
|
logger.Error("unable to fetch user $HOME directory: %v", err)
|
|
return
|
|
}
|
|
|
|
dataDir = filepath.Join(homeDir, ".local", "share", "rhemawave")
|
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
|
logger.Error("unable to create data directory %s %v", dataDir, err)
|
|
return
|
|
}
|
|
|
|
testFile := filepath.Join(dataDir, ".access_test")
|
|
f, err := os.Create(testFile)
|
|
if err != nil {
|
|
logger.Error("data %s is not writeable: %v", dataDir, err)
|
|
return
|
|
}
|
|
f.Close()
|
|
os.Remove(testFile)
|
|
|
|
logger.AddFile(filepath.Join(dataDir, "log.txt"))
|
|
logger.SetLevel = logger.LevelInfo
|
|
|
|
DB, err = db.Connect(filepath.Join(dataDir, "database.sqlite"))
|
|
if err != nil {
|
|
logger.Error("application failed to connect to the database: %v", err)
|
|
return
|
|
}
|
|
|
|
if err = db.Migrate(DB); err != nil {
|
|
logger.Error("application failed to perform database migrations: %v", err)
|
|
return
|
|
}
|
|
|
|
firstRun := false
|
|
|
|
// Populate the initial Config cache OR create defaults
|
|
cfg, err := db.GetConfig(DB)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
firstRun = true
|
|
cfg = &db.AppConfig{
|
|
Model: gorm.Model{ID: 1},
|
|
DataDir: dataDir,
|
|
AssetsDir: dataDir,
|
|
SystemLevel: "BASIC",
|
|
SelectedAudioDevice: "default",
|
|
InitialFontSize: 128,
|
|
StandardFontSize: 32,
|
|
LinePattern: "fibonacci",
|
|
AudioEnabled: true,
|
|
SavedVolume: 1.0,
|
|
}
|
|
|
|
if err := DB.Create(cfg).Error; err != nil {
|
|
logger.Error("failed to create config: %v", err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.AddDatabase(DB)
|
|
|
|
app := &Application{
|
|
DB: DB,
|
|
}
|
|
|
|
logger.Debug("==== Debug Statement ====")
|
|
|
|
if firstRun {
|
|
app.state = FIRST_RUN
|
|
}
|
|
|
|
w, h := app.initializeSDLAndOpenGL()
|
|
defer app.cleanup()
|
|
|
|
app.initializeManagers()
|
|
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
|
|
sync.SyncProclamations(DB)
|
|
sync.SyncCategories(DB)
|
|
sync.SyncFrequencies(DB)
|
|
|
|
// audioFileNames := []string{
|
|
// "OriginalAudio.wav",
|
|
// "174Hz.wav",
|
|
// "396Hz.wav",
|
|
// "417Hz.wav",
|
|
// "432Hz.wav",
|
|
// "639Hz.wav",
|
|
// "741Hz.wav",
|
|
// "852Hz.wav",
|
|
// "963Hz.wav",
|
|
// "1111Hz.wav",
|
|
// "14Hz_Binaural.wav",
|
|
// "40Hz_Binaural.wav",
|
|
// "AlphaWaves.wav",
|
|
// "PinealGland.wav",
|
|
// }
|
|
|
|
audioDir := filepath.Join(homeDir, ".radiantwave", "audio")
|
|
|
|
_, err = os.Stat(audioDir)
|
|
if os.IsNotExist(err) {
|
|
if err = os.MkdirAll(audioDir, 0755); err != nil {
|
|
logger.Error("unable to create audio directory: %v", err)
|
|
}
|
|
}
|
|
|
|
go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, app.DB, audioDir, "https://files.radiantwavetech.com/audio/")
|
|
|
|
app.runEventLoop()
|
|
}
|
|
|
|
// computeState determines what state the application should be in based on
|
|
// current conditions, without performing any navigation. It returns the first
|
|
// unmet prerequisite in priority order: network > email > license > downloads > ready.
|
|
func (app *Application) computeState() State {
|
|
if app.state == READY {
|
|
return READY
|
|
}
|
|
|
|
// 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
|
|
if !networkIsUp {
|
|
if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second {
|
|
lastNetworkCheckTime = time.Now()
|
|
if !network.HasInternet() {
|
|
return NEEDS_INTERNET
|
|
}
|
|
networkIsUp = true // internet confirmed, don't check again
|
|
} else {
|
|
return app.state // throttled, can't determine yet
|
|
}
|
|
}
|
|
|
|
// Check for email address
|
|
config, err := db.GetConfig(app.DB)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// A rather annoying, insecure, but sometimes necessary "print the config" statement
|
|
// data, err := json.MarshalIndent(config, "", " ")
|
|
// if err != nil {
|
|
// logger.Error("failed to marshal config: %v", err)
|
|
// } else {
|
|
// logger.Debug("AppConfig: %s", data)
|
|
// }
|
|
|
|
if config.EmailAddress == "" {
|
|
return NEEDS_EMAIL_ADDRESS
|
|
}
|
|
|
|
// Check for license key
|
|
config, err = db.GetConfig(app.DB)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if config.LicenseKey == "" {
|
|
return NEEDS_LICENSE_KEY
|
|
}
|
|
|
|
// All config prerequisites met -- now wait for downloads to finish
|
|
if !app.downloadsComplete {
|
|
return DOWNLOADING_ASSETS
|
|
}
|
|
|
|
return READY
|
|
}
|
|
|
|
// checkState keeps watch of the application state and sets pages
|
|
// accordingly. This function runs once per frame.
|
|
func (app *Application) checkState() {
|
|
newState := app.computeState()
|
|
|
|
if newState == app.state {
|
|
return
|
|
}
|
|
|
|
oldState := app.state
|
|
app.state = newState
|
|
|
|
// Both DOWNLOADING_ASSETS and READY map to the HOME page.
|
|
// Skip re-navigation when transitioning between them to avoid
|
|
// destroying and recreating the Home page (which would close
|
|
// the already-closed DownloadDone channel).
|
|
oldIsHome := oldState == DOWNLOADING_ASSETS || oldState == READY
|
|
newIsHome := newState == DOWNLOADING_ASSETS || newState == READY
|
|
if oldIsHome && newIsHome {
|
|
return
|
|
}
|
|
|
|
switch newState {
|
|
case NEEDS_INTERNET:
|
|
app.PageRouter.NavigateTo(page.WIFI)
|
|
case NEEDS_EMAIL_ADDRESS:
|
|
logger.Warning("No email address detected")
|
|
app.PageRouter.NavigateTo(page.EMAIL_ADDRESS)
|
|
case NEEDS_LICENSE_KEY:
|
|
app.PageRouter.NavigateTo(page.LICENSE_KEY)
|
|
case DOWNLOADING_ASSETS, READY:
|
|
app.PageRouter.NavigateTo(page.HOME)
|
|
}
|
|
}
|
|
|
|
func (app *Application) initializeSDLAndOpenGL() (W, H int32) {
|
|
logger.Info("Initializing SDL")
|
|
if err := sdl.Init(sdl.INIT_VIDEO | sdl.INIT_AUDIO); err != nil {
|
|
logger.Fatal("SDL init failed: %v", err)
|
|
}
|
|
|
|
logger.Info("Initializing TTF")
|
|
if err := ttf.Init(); err != nil {
|
|
logger.Fatal("TTF init failed: %v", err)
|
|
}
|
|
|
|
sdl.GLSetAttribute(sdl.GL_CONTEXT_MAJOR_VERSION, openGLMajorVersion)
|
|
sdl.GLSetAttribute(sdl.GL_CONTEXT_MINOR_VERSION, openGLMinorVersion)
|
|
sdl.GLSetAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_CORE)
|
|
sdl.ShowCursor(sdl.DISABLE)
|
|
sdl.SetRelativeMouseMode(true)
|
|
|
|
logger.Info("Querying display mode")
|
|
displayMode, err := sdl.GetDisplayMode(0, 0)
|
|
if err != nil {
|
|
logger.Fatal("Display mode query failed: %v", err)
|
|
}
|
|
if displayMode.W == 0 || displayMode.H == 0 {
|
|
logger.Fatal("Invalid display dimensions: %dx%d", displayMode.W, displayMode.H)
|
|
}
|
|
logger.Info("Display: %dx%d @ %dHz", displayMode.W, displayMode.H, displayMode.RefreshRate)
|
|
|
|
logger.Info("Creating window")
|
|
window, err := sdl.CreateWindow(
|
|
"Rhema Wave",
|
|
sdl.WINDOWPOS_UNDEFINED,
|
|
sdl.WINDOWPOS_UNDEFINED,
|
|
displayMode.W,
|
|
displayMode.H,
|
|
sdl.WINDOW_OPENGL|sdl.WINDOW_FULLSCREEN_DESKTOP|sdl.WINDOW_SHOWN,
|
|
)
|
|
if err != nil {
|
|
logger.Fatal("Window creation failed: %v", err)
|
|
}
|
|
app.Window = window
|
|
|
|
logger.Info("Creating OpenGL context")
|
|
if _, err := app.Window.GLCreateContext(); err != nil {
|
|
logger.Fatal("OpenGL context creation failed: %v", err)
|
|
}
|
|
|
|
logger.Info("Initializing go-gl")
|
|
if err := gl.InitWithProcAddrFunc(sdl.GLGetProcAddress); err != nil {
|
|
logger.Fatal("go-gl initialization failed: %v", err)
|
|
}
|
|
|
|
logger.Info("OpenGL version: %s", gl.GoStr(gl.GetString(gl.VERSION)))
|
|
logger.Info("OpenGL renderer: %s", gl.GoStr(gl.GetString(gl.RENDERER)))
|
|
|
|
if err := sdl.GLSetSwapInterval(1); err != nil {
|
|
logger.Warning("Failed to enable vsync: %v", err)
|
|
}
|
|
|
|
w, h := app.Window.GLGetDrawableSize()
|
|
logger.Info("Drawable size: %dx%d", w, h)
|
|
|
|
logger.Info("Initializing virtual framebuffer (%dx%d -> %dx%d)",
|
|
graphics.VirtualWidth, graphics.VirtualHeight, w, h)
|
|
if err := graphics.InitVirtualFramebuffer(w, h); err != nil {
|
|
logger.Fatal("Virtual framebuffer init failed: %v", err)
|
|
}
|
|
|
|
app.initKeybinds()
|
|
logger.Info("SDL and OpenGL initialization complete")
|
|
return graphics.VirtualWidth, graphics.VirtualHeight
|
|
}
|
|
|
|
func (app *Application) initializeManagers() {
|
|
logger.Info("Initializing ShaderManager")
|
|
config, err := db.GetConfig(app.DB)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
shaderDir := filepath.Join(config.AssetsDir, "shaders")
|
|
logger.Info("Loading shaders from %s", shaderDir)
|
|
if err := shaderManager.InitShaderManager(shaderDir); err != nil {
|
|
logger.Fatal("ShaderManager initialization failed: %v", err)
|
|
}
|
|
|
|
logger.Info("Initializing FontManager")
|
|
if err := fontManager.InitFontManager(config); err != nil {
|
|
logger.Fatal("FontManager initialization failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func (app *Application) initializeAudio() {
|
|
logger.Info("Initializing audio")
|
|
config, err := db.GetConfig(app.DB)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if err := mixer.Init(config, app.refreshCache); err != nil {
|
|
logger.Error("Unable to initialize audio device '%s': %v", config.SelectedAudioDevice, err)
|
|
logger.Error("Audio will be unavailable until a device is configured")
|
|
} else {
|
|
logger.Info("Audio initialized with device: '%s'", config.SelectedAudioDevice)
|
|
}
|
|
}
|
|
|
|
func (app *Application) refreshCache() {}
|
|
|
|
func (app *Application) cleanup() {
|
|
logger.Info("Cleaning up")
|
|
|
|
if app.PageRouter != nil && app.PageRouter.ActivePage != nil {
|
|
app.PageRouter.ActivePage.Destroy()
|
|
}
|
|
|
|
graphics.DestroyVirtualFramebuffer()
|
|
|
|
if app.Window != nil {
|
|
app.Window.Destroy()
|
|
}
|
|
|
|
ttf.Quit()
|
|
sdl.Quit()
|
|
logger.Info("Cleanup complete")
|
|
logger.Close()
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Event Loop
|
|
// ----------------------------------------------------------------------------
|
|
|
|
func (app *Application) runEventLoop() {
|
|
if app.PageRouter == nil || app.PageRouter.ActivePage == nil {
|
|
logger.Fatal("No initial page loaded, Router is empty")
|
|
}
|
|
const frameDelay = time.Second / targetFPS
|
|
app.running = true
|
|
lastTime := time.Now()
|
|
logger.Info("Entering event loop")
|
|
|
|
for app.running {
|
|
frameStart := time.Now()
|
|
|
|
app.checkState()
|
|
app.handleEvents()
|
|
if !app.running {
|
|
break
|
|
}
|
|
|
|
deltaTime := float32(time.Since(lastTime).Seconds())
|
|
lastTime = time.Now()
|
|
|
|
app.updateCurrentPage(deltaTime)
|
|
app.renderFrame()
|
|
|
|
frameTime := time.Since(frameStart)
|
|
if frameTime < frameDelay {
|
|
time.Sleep(frameDelay - frameTime)
|
|
}
|
|
}
|
|
|
|
logger.Info("Event loop exited")
|
|
}
|
|
|
|
func (app *Application) handleEvents() {
|
|
for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() {
|
|
switch e := event.(type) {
|
|
case *sdl.QuitEvent:
|
|
app.running = false
|
|
return
|
|
|
|
case *sdl.KeyboardEvent:
|
|
if e.Type == sdl.KEYDOWN && e.Repeat == 0 {
|
|
isCtrlPressed := (e.Keysym.Mod&sdl.KMOD_CTRL) != 0 || (e.Keysym.Mod&sdl.KMOD_GUI) != 0
|
|
if e.Keysym.Sym == sdl.K_c && isCtrlPressed {
|
|
continue
|
|
}
|
|
keybinds.PerformAction(e)
|
|
}
|
|
}
|
|
|
|
if currentPage := app.PageRouter.ActivePage; currentPage != nil {
|
|
if err := currentPage.HandleEvent(&event); err != nil {
|
|
logger.Error("Page event handler error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (app *Application) updateCurrentPage(deltaTime float32) {
|
|
if currentPage := app.PageRouter.ActivePage; currentPage != nil {
|
|
currentPage.Update(deltaTime)
|
|
}
|
|
}
|
|
|
|
func (app *Application) renderFrame() {
|
|
graphics.BeginVirtualRender()
|
|
gl.ClearColor(0.0, 0.0, 0.0, 1.0)
|
|
gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
|
|
|
|
if currentPage := app.PageRouter.ActivePage; currentPage != nil {
|
|
currentPage.Render()
|
|
}
|
|
|
|
graphics.BlitToScreen()
|
|
app.Window.GLSwap()
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Keybind Registration
|
|
// ----------------------------------------------------------------------------
|
|
|
|
func (app *Application) initKeybinds() {
|
|
keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() {
|
|
if app.state != READY && app.state != DOWNLOADING_ASSETS {
|
|
return
|
|
}
|
|
app.PageRouter.NavigateTo(page.SETTINGS)
|
|
})
|
|
|
|
keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() {
|
|
if app.state != READY && app.state != DOWNLOADING_ASSETS {
|
|
return
|
|
}
|
|
app.PageRouter.NavigateTo(page.SCROLLER)
|
|
})
|
|
|
|
keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() {
|
|
if app.state != READY && app.state != DOWNLOADING_ASSETS {
|
|
return
|
|
}
|
|
app.PageRouter.NavigateTo(page.HOME)
|
|
|
|
})
|
|
|
|
keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() {
|
|
app.Stop()
|
|
})
|
|
}
|
|
|
|
func (app *Application) Stop() {
|
|
app.running = false
|
|
}
|
|
|
|
func (app *Application) Quit() {
|
|
logger.Info("User has quit the application")
|
|
app.running = false
|
|
}
|