From 4b7d2c13d5ceb018d49f89cbb38fdff46e3c7c00 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 11 Jun 2026 19:44:20 -0500 Subject: [PATCH 01/18] Rename audio devices to audio options --- .../page/{audioDevices.go => AudioOptions.go} | 26 +++++++++---------- internal/page/router.go | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) rename internal/page/{audioDevices.go => AudioOptions.go} (93%) diff --git a/internal/page/audioDevices.go b/internal/page/AudioOptions.go similarity index 93% rename from internal/page/audioDevices.go rename to internal/page/AudioOptions.go index e1ef054..f649653 100644 --- a/internal/page/audioDevices.go +++ b/internal/page/AudioOptions.go @@ -14,7 +14,7 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) -type AudioDevices struct { +type AudioOptions struct { Base font *fontManager.FontEntry textShader *shaderManager.Shader @@ -34,7 +34,7 @@ type AudioDevices struct { volumeBarH int32 } -func (p *AudioDevices) Init(ctx *PageCtx) error { +func (p *AudioOptions) Init(ctx *PageCtx) error { var err error p.verticalMargin = int32(20) @@ -84,11 +84,11 @@ func (p *AudioDevices) Init(ctx *PageCtx) error { p.prompt.Scale(p.ItemScale) if err = p.CreateAudioStatusItem(); err != nil { - logger.Error("failed to create AudioStatusItem in AudioDevices.Init()") + logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") } if err = p.CreateVolumePercentItem(); err != nil { - logger.Error("failed to create VolumePercentItem in AudioDevices.Init()") + logger.Error("failed to create VolumePercentItem in AudioOptions.Init()") } p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White) @@ -101,7 +101,7 @@ func (p *AudioDevices) Init(ctx *PageCtx) error { return nil } -func (p *AudioDevices) CreateAudioStatusItem() error { +func (p *AudioOptions) CreateAudioStatusItem() error { on := p.Context.CfgPtr.AudioEnabled statusString := "Turn Audio Off" // The audio is ON color := colors.Green @@ -120,7 +120,7 @@ func (p *AudioDevices) CreateAudioStatusItem() error { } // CreateVolumePercentItem creates (or recreates) the percentage label beside the volume bar. -func (p *AudioDevices) CreateVolumePercentItem() error { +func (p *AudioOptions) CreateVolumePercentItem() error { // Free the old texture if one exists if p.volumePercent.ID != 0 { gl.DeleteTextures(1, &p.volumePercent.ID) @@ -138,7 +138,7 @@ func (p *AudioDevices) CreateVolumePercentItem() error { return nil } -func (p *AudioDevices) layout() { +func (p *AudioOptions) layout() { // Position title at the top p.title.X = p.ScreenCenterX - p.title.W/2 p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2 @@ -172,7 +172,7 @@ func (p *AudioDevices) layout() { p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2 } -func (p *AudioDevices) ToggleAudio() { +func (p *AudioOptions) ToggleAudio() { // Toggle current setting p.Context.CfgPtr.AudioEnabled = !p.Context.CfgPtr.AudioEnabled @@ -188,7 +188,7 @@ func (p *AudioDevices) ToggleAudio() { } // UpdateVolume increments or decrements system volume by level (percent change) -func (p *AudioDevices) UpdateVolume(level int) { +func (p *AudioOptions) UpdateVolume(level int) { // Calculate the new volume newVol := mixer.ConvertVolume(level) + p.Context.CfgPtr.SavedVolume // Check that it's within bounds @@ -220,7 +220,7 @@ func (p *AudioDevices) UpdateVolume(level int) { p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2 } -func (p *AudioDevices) HandleEvent(event *sdl.Event) error { +func (p *AudioOptions) HandleEvent(event *sdl.Event) error { e, ok := (*event).(*sdl.KeyboardEvent) if !ok || e.Type != sdl.KEYDOWN { return nil @@ -241,7 +241,7 @@ func (p *AudioDevices) HandleEvent(event *sdl.Event) error { } // renderVolumeBar draws the green outline and the white filled portion. -func (p *AudioDevices) renderVolumeBar() error { +func (p *AudioOptions) renderVolumeBar() error { vol := p.Context.CfgPtr.SavedVolume // --- White fill (clipped to current volume fraction) --- @@ -281,7 +281,7 @@ func (p *AudioDevices) renderVolumeBar() error { return nil } -func (p *AudioDevices) Render() error { +func (p *AudioOptions) Render() error { if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil { logger.Error("unable to render title texture: %v", err) return fmt.Errorf("unable to render title texture: %w", err) @@ -315,7 +315,7 @@ func (p *AudioDevices) Render() error { return nil } -func (p *AudioDevices) Destroy() error { +func (p *AudioOptions) Destroy() error { gl.DeleteTextures(1, &p.title.ID) gl.DeleteTextures(1, &p.prompt.ID) gl.DeleteTextures(1, &p.volumePercent.ID) diff --git a/internal/page/router.go b/internal/page/router.go index fb5d4bf..bff8fa8 100644 --- a/internal/page/router.go +++ b/internal/page/router.go @@ -60,7 +60,7 @@ func (r *Router) NavigateTo(p pageType) { case WIFI: r.ActivePage = &WiFiSetupPage{} case AUDIO: - r.ActivePage = &AudioDevices{} + r.ActivePage = &AudioOptions{} case PROCLAMATIONS: r.ActivePage = &ProclamationOptions{} case LICENSE_KEY: From 74874f97c52d26eec5132cda094157a577537d38 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 17 Jun 2026 00:46:56 -0500 Subject: [PATCH 02/18] audio selection works --- internal/application/application.go | 1 - internal/db/newDb.go | 2 +- internal/db/schema.go | 9 +- internal/netreq/netReq.go | 29 ++ internal/page/AudioOptions.go | 668 ++++++++++++++++++++++------ internal/page/scrollerPage.go | 32 +- 6 files changed, 598 insertions(+), 143 deletions(-) diff --git a/internal/application/application.go b/internal/application/application.go index 424bd4f..d4447f4 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -116,7 +116,6 @@ func Run() { AssetsDir: dataDir, SystemLevel: "BASIC", SelectedAudioDevice: "default", - SelectedAudio: "audio.wav", InitialFontSize: 128, StandardFontSize: 32, LinePattern: "fibonacci", diff --git a/internal/db/newDb.go b/internal/db/newDb.go index ddfc273..fd77866 100644 --- a/internal/db/newDb.go +++ b/internal/db/newDb.go @@ -42,7 +42,7 @@ func Connect(dbpath string) (*gorm.DB, error) { // Migrate the database schema func Migrate(db *gorm.DB) error { // Pass all models to be migrated to this function call. - if err := db.AutoMigrate(&AppConfig{}, &Log{}, &Proclamation{}, &ProclamationCategory{}, &SolfeggioFrequency{}); err != nil { + if err := db.AutoMigrate(&AppConfig{}, &Log{}, &Proclamation{}, &ProclamationCategory{}, &SolfeggioFrequency{}, &AudioFile{}); err != nil { return err } return nil diff --git a/internal/db/schema.go b/internal/db/schema.go index 1b5437a..1a1147b 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -10,7 +10,6 @@ type AppConfig struct { EmailAddress string LicenseKey string SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL - SelectedAudio string // Audio filepath SelectedAudioDevice string // The pipewire device name InitialFontSize int // How large to create the font atlas StandardFontSize int // The font size used for most text rendering @@ -48,3 +47,11 @@ type SolfeggioFrequency struct { Name string Value int } + +type AudioFile struct { + ID int `gorm:"primaryKey"` + Name string + Path string + Selected bool + Order int +} diff --git a/internal/netreq/netReq.go b/internal/netreq/netReq.go index 17e0331..f9c4b4c 100644 --- a/internal/netreq/netReq.go +++ b/internal/netreq/netReq.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" ) // Makes a GET request to the specified URL and returns the JSON response @@ -52,3 +54,30 @@ func GetJSON[T any](url string) ([]T, error) { return result, nil } + +// Download downloads a specified object at a given url and places it in +// the provided dest directory. +func Download(name string, path string, url string) error { + fullFilePath := filepath.Join(path, name) + if err := os.MkdirAll(path, os.ModeDir); err != nil { + return err + } + file, err := os.Create(fullFilePath) + if err != nil { + return err + } + defer file.Close() + + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return err + } + + return nil +} diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go index f649653..018a793 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -2,6 +2,8 @@ package page import ( "fmt" + "math" + "strings" "github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/mathgl/mgl32" @@ -14,31 +16,99 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) +var AudioFileList = []db.AudioFile{ + {ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false}, + {ID: 2, Name: "396 Hz", Path: "396Hz.wav", Selected: false}, + {ID: 3, Name: "417 Hz", Path: "417Hz.wav", Selected: false}, + {ID: 4, Name: "432 Hz", Path: "432Hz.wav", Selected: false}, + {ID: 5, Name: "639 Hz", Path: "639Hz.wav", Selected: false}, + {ID: 6, Name: "741 Hz", Path: "741Hz.wav", Selected: false}, + {ID: 7, Name: "852 Hz", Path: "852Hz.wav", Selected: false}, + {ID: 8, Name: "963 Hz", Path: "963Hz.wav", Selected: false}, + {ID: 9, Name: "1111 Hz", Path: "1111Hz.wav", Selected: false}, + {ID: 10, Name: "14 Hz Binaural Beat", Path: "14Hz_Binaural.wav", Selected: false}, + {ID: 11, Name: "40 Hz Binaural Beat", Path: "40Hz_Binaural.wav", Selected: false}, + {ID: 12, Name: "Alpha Waves", Path: "AlphaWaves.wav", Selected: false}, + {ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false}, + {ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false}, +} + +const ( + triangleSize = float32(16) + visibleCount = 9 + listShiftDown = int32(50) + scrollbarWidth = int32(4) + scrollbarGap = int32(16) // gap between right edge of list and scrollbar +) + type AudioOptions struct { Base - font *fontManager.FontEntry - textShader *shaderManager.Shader - solidShader *shaderManager.Shader - title StringItem - prompt StringItem + font *fontManager.FontEntry + textShader *shaderManager.Shader + solidShader *shaderManager.Shader + title StringItem + prompt StringItem + verticalMargin int32 + ItemScale float32 + CfgPtr *db.AppConfig + + // Audio toggle AudioStatusToggle StringItem - volumePercent StringItem - verticalMargin int32 - ItemScale float32 - CfgPtr *db.AppConfig - VolumeLabel StringItem - // Volume bar geometry (computed once in layout, reused in render) - volumeBarX int32 - volumeBarY int32 - volumeBarW int32 - volumeBarH int32 + + // Volume row (excluded from focus) + VolumeLabel StringItem + volumePercent StringItem + volumeBarX int32 + volumeBarY int32 + volumeBarW int32 + volumeBarH int32 + + // Separator + track section + separatorY int32 + tracksHeading StringItem + trackItems []StringItem + + // Selected track (-1 = none) + selectedTrack int + + // Navigation + // focusedIndex: 0 = AudioStatusToggle, 1..N = trackItems[focusedIndex-1] + // Volume is intentionally excluded from focus. + focusedIndex int + + // Scrolling window + windowStart int + listTopY int32 + listLeftX int32 + itemHeight int32 + trackSpacing int32 + + // Scrollbar geometry (computed once in layout, thumb Y updated on scroll) + scrollbarX int32 + scrollbarTrackY int32 // bottom of the scrollbar track + scrollbarTrackH int32 // total height of the track + scrollbarThumbH int32 // height of the thumb + scrollbarThumbY int32 // current bottom Y of the thumb (updated on scroll) + + // Triangle cursor + triVAO uint32 + triVBO uint32 + triX float32 + triCenterY float32 + spinElapsed float32 } +// ── Init ────────────────────────────────────────────────────────────────────── + func (p *AudioOptions) Init(ctx *PageCtx) error { var err error p.verticalMargin = int32(20) + p.focusedIndex = 0 + p.selectedTrack = -1 + p.windowStart = 0 + p.trackSpacing = p.verticalMargin / 2 - // Get font + // Font fm := fontManager.Get() font, ok := fm.GetFont("Roboto-Regular") if !ok { @@ -46,7 +116,7 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { } p.font = font - // Get shaders + // Shaders sm := shaderManager.Get() p.textShader, ok = sm.Get("text") if !ok { @@ -57,79 +127,180 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError()) } - // Initialize base - if err := p.Base.Init(ctx); err != nil { + if err = p.Base.Init(ctx); err != nil { return fmt.Errorf("base init failed: %w", err) } - // Get font size from database baseFontSize := p.Context.CfgPtr.StandardFontSize - - // Calculate scales titleScale := float32(64) / float32(baseFontSize) p.ItemScale = float32(32) / float32(baseFontSize) - // Create title - p.title, err = NewStringItem("Toggle Audio", p.font, colors.White) + // Title + p.title, err = NewStringItem("Audio Options", p.font, colors.White) if err != nil { return fmt.Errorf("creating title: %w", err) } p.title.Scale(titleScale) - // Create prompt - p.prompt, err = NewStringItem("Switch On/Off with Enter. Change volume with Left/Right (arrow keys). Setting saves immediately.", p.font, colors.White) + // Prompt + p.prompt, err = NewStringItem("Up/Down to navigate -- Enter to select -- Left/Right to change volume -- Backspace to go back", p.font, colors.White) if err != nil { return fmt.Errorf("creating prompt: %w", err) } p.prompt.Scale(p.ItemScale) - if err = p.CreateAudioStatusItem(); err != nil { - logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") - } - - if err = p.CreateVolumePercentItem(); err != nil { - logger.Error("failed to create VolumePercentItem in AudioOptions.Init()") - } - + // Volume row p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White) if err != nil { return fmt.Errorf("creating volume label: %w", err) } p.VolumeLabel.Scale(p.ItemScale) + if err = p.CreateVolumePercentItem(); err != nil { + logger.Error("failed to create VolumePercentItem in AudioOptions.Init()") + } + + // Audio toggle + if err = p.CreateAudioStatusItem(); err != nil { + logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") + } + + // "Available Tracks" heading + headingScale := float32(40) / float32(baseFontSize) + p.tracksHeading, err = NewStringItem("Available Tracks", p.font, colors.White) + if err != nil { + return fmt.Errorf("creating tracks heading: %w", err) + } + p.tracksHeading.Scale(headingScale) + + // Track items + if err = p.buildTrackItems(); err != nil { + return fmt.Errorf("building track items: %w", err) + } + + // Triangle cursor VAO/VBO — stride is vec2 only, no tex coords + triVerts := []float32{ + 0.5, 0.0, + -0.5, 0.5, + -0.5, -0.5, + } + gl.GenVertexArrays(1, &p.triVAO) + gl.GenBuffers(1, &p.triVBO) + gl.BindVertexArray(p.triVAO) + gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO) + gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW) + gl.EnableVertexAttribArray(0) + gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0) + gl.BindBuffer(gl.ARRAY_BUFFER, 0) + gl.BindVertexArray(0) p.layout() return nil } -func (p *AudioOptions) CreateAudioStatusItem() error { - on := p.Context.CfgPtr.AudioEnabled - statusString := "Turn Audio Off" // The audio is ON - color := colors.Green - if !on { - statusString = "Turn Audio on" - color = colors.Red +// ── Track items ─────────────────────────────────────────────────────────────── + +func (p *AudioOptions) buildTrackItems() error { + var audioFileList = []db.AudioFile{} + if err := p.Context.DbPtr.Find(&audioFileList).Error; err != nil { + return err } - audioStatusStringItem, err := NewStringItem(statusString, p.font, color) - if err != nil { - return fmt.Errorf("failed to create '%s' string item: %v", statusString, err) + + if len(audioFileList) == 0 { + audioFileList = AudioFileList + } + + for i := range p.trackItems { + if p.trackItems[i].ID != 0 { + gl.DeleteTextures(1, &p.trackItems[i].ID) + } + } + p.trackItems = make([]StringItem, len(AudioFileList)) + + for i, entry := range AudioFileList { + color := colors.White + if i == p.selectedTrack { + color = colors.Green + } + item, err := NewStringItem(entry.Name, p.font, color) + if err != nil { + return fmt.Errorf("creating track item %d: %w", i, err) + } + item.Scale(p.ItemScale) + p.trackItems[i] = item } - fmt.Printf("AudioStatusToggle (%d)", audioStatusStringItem.ID) - audioStatusStringItem.Scale(p.ItemScale) - p.AudioStatusToggle = audioStatusStringItem return nil } -// CreateVolumePercentItem creates (or recreates) the percentage label beside the volume bar. +// positionTrackItems places the visible window of items and parks the rest off-screen. +func (p *AudioOptions) positionTrackItems() { + peekCount := visibleCount + 1 + + for i := range p.trackItems { + slot := i - p.windowStart + + if slot < 0 || slot >= peekCount { + p.trackItems[i].X = p.listLeftX + p.trackItems[i].Y = -9999 + continue + } + + itemY := p.listTopY - int32(slot)*(p.itemHeight+p.trackSpacing) + p.trackItems[i].X = p.listLeftX + p.trackItems[i].Y = itemY + } +} + +// ── Scrollbar ───────────────────────────────────────────────────────────────── + +// updateScrollbarThumb recalculates the thumb Y position based on windowStart. +// Called whenever windowStart changes. +func (p *AudioOptions) updateScrollbarThumb() { + total := len(AudioFileList) + if total <= visibleCount { + // Entire list fits — thumb fills the track, nothing to scroll + p.scrollbarThumbY = p.scrollbarTrackY + return + } + + // How far through the scrollable range are we? + // maxWindowStart is the furthest windowStart can go + maxWindowStart := total - visibleCount + fraction := float32(p.windowStart) / float32(maxWindowStart) + + // Thumb travels from the top of the track down to (trackTop - thumbH) + // "top" in our Y-up projection means the larger Y value + trackTop := p.scrollbarTrackY + p.scrollbarTrackH + travelRange := p.scrollbarTrackH - p.scrollbarThumbH + p.scrollbarThumbY = trackTop - p.scrollbarThumbH - int32(fraction*float32(travelRange)) +} + +// ── Audio status toggle ─────────────────────────────────────────────────────── + +func (p *AudioOptions) CreateAudioStatusItem() error { + on := p.Context.CfgPtr.AudioEnabled + statusString := "Turn Audio On" + color := colors.Green + if on { + statusString = "Turn Audio Off" + color = colors.Red + } + item, err := NewStringItem(statusString, p.font, color) + if err != nil { + return fmt.Errorf("failed to create '%s' string item: %v", statusString, err) + } + item.Scale(p.ItemScale) + p.AudioStatusToggle = item + return nil +} + +// ── Volume ──────────────────────────────────────────────────────────────────── + func (p *AudioOptions) CreateVolumePercentItem() error { - // Free the old texture if one exists if p.volumePercent.ID != 0 { gl.DeleteTextures(1, &p.volumePercent.ID) } - pct := int(p.Context.CfgPtr.SavedVolume * 100) - label := fmt.Sprintf("%d%%", pct) - - item, err := NewStringItem(label, p.font, colors.White) + item, err := NewStringItem(fmt.Sprintf("%d%%", pct), p.font, colors.White) if err != nil { return fmt.Errorf("failed to create volume percent item: %v", err) } @@ -138,88 +309,189 @@ func (p *AudioOptions) CreateVolumePercentItem() error { return nil } +func (p *AudioOptions) UpdateVolume(level int) { + newVol := mixer.ConvertVolume(level) + p.Context.CfgPtr.SavedVolume + if newVol < 0.0 || newVol > 1.0 { + return + } + if newVol > .99 { + newVol = 1.0 + } + p.Context.CfgPtr.SavedVolume = newVol + p.Context.DbPtr.Save(&p.Context.CfgPtr) + if err := mixer.SetAllSinkVolumes(newVol); err != nil { + logger.Error("Error setting allSinkVolumes: %v", err) + } + if err := p.CreateVolumePercentItem(); err != nil { + logger.Error("failed to refresh volume percent item: %v", err) + } + gap := int32(12) + p.volumePercent.X = p.volumeBarX + p.volumeBarW + gap + p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2 +} + +// ── Layout ──────────────────────────────────────────────────────────────────── + func (p *AudioOptions) layout() { - // Position title at the top + // Title p.title.X = p.ScreenCenterX - p.title.W/2 p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2 - // Position prompt below the title + // Prompt p.prompt.X = p.ScreenCenterX - p.prompt.W/2 - p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H + p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H - // Position the audio toggle below the prompt, centred - p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 - p.AudioStatusToggle.Y = p.prompt.Y - p.verticalMargin*2 - p.AudioStatusToggle.H - - // Volume bar: narrower (30% of screen width) so the right edge stays visible - barH := p.AudioStatusToggle.H + // Volume row (not focusable) + barH := int32(float32(p.Context.CfgPtr.StandardFontSize) * p.ItemScale) barW := int32(float32(p.ScreenWidth) * 0.30) barX := p.ScreenCenterX - barW/2 - barY := p.AudioStatusToggle.Y - p.verticalMargin*2 - barH + barY := p.prompt.Y - p.verticalMargin*2 - barH p.volumeBarX = barX p.volumeBarY = barY p.volumeBarW = barW p.volumeBarH = barH - // "Volume" label: to the LEFT of the bar, vertically centred on it p.VolumeLabel.X = barX - p.VolumeLabel.W - 20 p.VolumeLabel.Y = barY + (barH-p.VolumeLabel.H)/2 - // Percentage label: vertically centred on the bar, small gap to the right gap := int32(12) p.volumePercent.X = barX + barW + gap p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2 + + // "Available Tracks" heading — centred, left edge becomes the list anchor + p.tracksHeading.X = p.ScreenCenterX - p.tracksHeading.W/2 + + // Toggle — left-aligned to the heading + p.AudioStatusToggle.X = p.tracksHeading.X + p.AudioStatusToggle.Y = barY - p.verticalMargin*2 - p.AudioStatusToggle.H + + // Separator + p.separatorY = p.AudioStatusToggle.Y - p.verticalMargin*3 + + // Heading Y — below separator + p.tracksHeading.Y = p.separatorY - p.verticalMargin - p.tracksHeading.H + + // Track list geometry — shifted down by listShiftDown + if len(p.trackItems) > 0 { + p.itemHeight = p.trackItems[0].H + } + + p.listLeftX = p.tracksHeading.X + p.listTopY = p.tracksHeading.Y - p.verticalMargin - listShiftDown + + // ── Scrollbar geometry ──────────────────────────────────────────────────── + // The scrollbar track spans the full visible window height. + // It sits to the right of the list with a small gap. + windowH := int32(visibleCount)*(p.itemHeight+p.trackSpacing) - p.trackSpacing + scrollbarBottom := p.listTopY - windowH + p.scrollbarTrackH = windowH + p.scrollbarTrackY = scrollbarBottom // bottom Y of the track (Y-up coords) + + // Right edge of the list: use the widest track name as an approximation. + // Since items are left-aligned and widths vary, we use a fixed width based + // on the heading width which is a reasonable proxy for the list column width. + listRightX := p.listLeftX + p.tracksHeading.W + p.scrollbarX = listRightX + scrollbarGap + + // Thumb height is proportional: visibleCount / total items + total := len(AudioFileList) + if total > 0 { + thumbFraction := float32(visibleCount) / float32(total) + p.scrollbarThumbH = int32(thumbFraction * float32(p.scrollbarTrackH)) + if p.scrollbarThumbH < 8 { + p.scrollbarThumbH = 8 // minimum thumb size so it's always clickable + } + } + + // Triangle cursor X — just outside the left edge of the list + cursorGap := int32(12) + p.triX = float32(p.listLeftX) - float32(triangleSize) - float32(cursorGap) + + p.positionTrackItems() + p.updateScrollbarThumb() + p.repositionCursor() } +// repositionCursor vertically centres the triangle on the focused item. +func (p *AudioOptions) repositionCursor() { + var targetY, targetH int32 + + if p.focusedIndex == 0 { + targetY = p.AudioStatusToggle.Y + targetH = p.AudioStatusToggle.H + } else { + trackIdx := p.focusedIndex - 1 + slot := trackIdx - p.windowStart + targetY = p.listTopY - int32(slot)*(p.itemHeight+p.trackSpacing) + targetH = p.itemHeight + } + + p.triCenterY = float32(targetY) + float32(targetH)/2 +} + +// ── Scroll ──────────────────────────────────────────────────────────────────── + +func (p *AudioOptions) scrollToFocus() { + if p.focusedIndex == 0 { + p.windowStart = 0 + p.positionTrackItems() + p.updateScrollbarThumb() + p.repositionCursor() + return + } + + trackIdx := p.focusedIndex - 1 + + if trackIdx >= p.windowStart+visibleCount { + p.windowStart = trackIdx - visibleCount + 1 + } + if trackIdx < p.windowStart { + p.windowStart = trackIdx + } + + p.positionTrackItems() + p.updateScrollbarThumb() + p.repositionCursor() +} + +// ── Toggle ──────────────────────────────────────────────────────────────────── + func (p *AudioOptions) ToggleAudio() { - // Toggle current setting p.Context.CfgPtr.AudioEnabled = !p.Context.CfgPtr.AudioEnabled - - // Update the database p.Context.DbPtr.Save(p.Context.CfgPtr) - - // Update the configuration p.Context.RefreshConfig() - - // Update AudioEnabled UI element p.CreateAudioStatusItem() p.layout() } -// UpdateVolume increments or decrements system volume by level (percent change) -func (p *AudioOptions) UpdateVolume(level int) { - // Calculate the new volume - newVol := mixer.ConvertVolume(level) + p.Context.CfgPtr.SavedVolume - // Check that it's within bounds - if newVol < 0.0 || newVol > 1.0 { +// ── Track selection ─────────────────────────────────────────────────────────── + +func (p *AudioOptions) onTrackSelected(index int, entry db.AudioFile) { + p.selectedTrack = index + if err := p.buildTrackItems(); err != nil { + logger.Error("onTrackSelected: failed to rebuild track items: %v", err) return } - // Force 1.0 rather than 0.99999999999999989 - if newVol > .99 { - newVol = 1.0 - } - // Update the config - p.Context.CfgPtr.SavedVolume = newVol - fmt.Printf("New Volume: %f", newVol) - // Update the database - p.Context.DbPtr.Save(&p.Context.CfgPtr) - // Set the new volume - err := mixer.SetAllSinkVolumes(newVol) - if err != nil { - fmt.Printf("Error setting allSinkVolumes: %v", err) - } + p.layout() - // Refresh volume label - if err := p.CreateVolumePercentItem(); err != nil { - logger.Error("failed to refresh volume percent item: %v", err) + // Create file names and paths + // Set Selected + entry.Selected = true + // Set path + if !strings.Contains(entry.Path, ".local/") { + entry.Path = p.Context.CfgPtr.AssetsDir + "audio" + entry.Path } - // Re-position the label (bar geometry is unchanged, but label width may differ) - gap := int32(12) - p.volumePercent.X = p.volumeBarX + p.volumeBarW + gap - p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2 + // Save it to the database + if err := p.Context.DbPtr.Save(&entry).Error; err != nil { + logger.Error("Error saving to database: %v", err) + return + } + logger.Info("Updated audio selection: %s", entry.Name) } +// ── Events ──────────────────────────────────────────────────────────────────── + func (p *AudioOptions) HandleEvent(event *sdl.Event) error { e, ok := (*event).(*sdl.KeyboardEvent) if !ok || e.Type != sdl.KEYDOWN { @@ -227,12 +499,32 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error { } switch e.Keysym.Sym { + case sdl.K_UP: + if p.focusedIndex > 0 { + p.focusedIndex-- + p.scrollToFocus() + } + + case sdl.K_DOWN: + if p.focusedIndex < len(AudioFileList) { + p.focusedIndex++ + p.scrollToFocus() + } + case sdl.K_RETURN: - p.ToggleAudio() - case sdl.K_RIGHT: // Right increase + if p.focusedIndex == 0 { + p.ToggleAudio() + } else { + trackIdx := p.focusedIndex - 1 + p.onTrackSelected(trackIdx, AudioFileList[trackIdx]) + } + + case sdl.K_RIGHT: p.UpdateVolume(10) - case sdl.K_LEFT: // Left decrease + + case sdl.K_LEFT: p.UpdateVolume(-10) + case sdl.K_BACKSPACE: p.Context.NavigateTo(SETTINGS) } @@ -240,21 +532,44 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error { return nil } -// renderVolumeBar draws the green outline and the white filled portion. +// ── Update ──────────────────────────────────────────────────────────────────── + +func (p *AudioOptions) Update(dt float32) error { + p.spinElapsed += dt + if p.spinElapsed >= 1.0 { + p.spinElapsed -= 1.0 + } + return nil +} + +// ── Render helpers ──────────────────────────────────────────────────────────── + +func (p *AudioOptions) renderSeparator() error { + lineW := float32(p.ScreenWidth) * 0.80 + lineX := float32(p.ScreenCenterX) - lineW/2 + return p.RenderSolidColorQuad( + p.solidShader, + mgl32.Vec2{lineX, float32(p.separatorY)}, + mgl32.Vec2{lineW, 2}, + colors.White, + ) +} + func (p *AudioOptions) renderVolumeBar() error { vol := p.Context.CfgPtr.SavedVolume - // --- White fill (clipped to current volume fraction) --- fillW := float32(p.volumeBarW) * float32(vol) if fillW > 0 { - fillPos := mgl32.Vec2{float32(p.volumeBarX), float32(p.volumeBarY)} - fillDim := mgl32.Vec2{fillW, float32(p.volumeBarH)} - if err := p.RenderSolidColorQuad(p.solidShader, fillPos, fillDim, colors.White); err != nil { + if err := p.RenderSolidColorQuad( + p.solidShader, + mgl32.Vec2{float32(p.volumeBarX), float32(p.volumeBarY)}, + mgl32.Vec2{fillW, float32(p.volumeBarH)}, + colors.White, + ); err != nil { return fmt.Errorf("rendering volume fill: %w", err) } } - // --- Green outline (4 x thin quads: top, bottom, left, right) --- thickness := float32(2) bx := float32(p.volumeBarX) by := float32(p.volumeBarY) @@ -262,63 +577,150 @@ func (p *AudioOptions) renderVolumeBar() error { bh := float32(p.volumeBarH) outlineRects := [4][2]mgl32.Vec2{ - // bottom edge {{bx, by}, {bw, thickness}}, - // top edge {{bx, by + bh - thickness}, {bw, thickness}}, - // left edge {{bx, by}, {thickness, bh}}, - // right edge {{bx + bw - thickness}, {thickness, bh}}, } - for _, r := range outlineRects { if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil { - return fmt.Errorf("rendering volume outline edge: %w", err) + return fmt.Errorf("rendering volume outline: %w", err) } } + return nil +} + +// renderScrollbar draws the scrollbar track (dim) and thumb (white). +// Only rendered when the list is longer than the visible window. +func (p *AudioOptions) renderScrollbar() error { + if len(AudioFileList) <= visibleCount { + return nil + } + + // Track — a thin dim line the full height of the window + trackColor := sdl.Color{R: 80, G: 80, B: 80, A: 255} + if err := p.RenderSolidColorQuad( + p.solidShader, + mgl32.Vec2{float32(p.scrollbarX), float32(p.scrollbarTrackY)}, + mgl32.Vec2{float32(scrollbarWidth), float32(p.scrollbarTrackH)}, + trackColor, + ); err != nil { + return fmt.Errorf("rendering scrollbar track: %w", err) + } + + // Thumb — white, positioned proportionally within the track + if err := p.RenderSolidColorQuad( + p.solidShader, + mgl32.Vec2{float32(p.scrollbarX), float32(p.scrollbarThumbY)}, + mgl32.Vec2{float32(scrollbarWidth), float32(p.scrollbarThumbH)}, + colors.White, + ); err != nil { + return fmt.Errorf("rendering scrollbar thumb: %w", err) + } return nil } +// renderCursor draws the spinning triangle cursor. +func (p *AudioOptions) renderCursor() error { + scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed))) + + model := mgl32.Ident4() + model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0)) + model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1)) + mvp := p.Projection.Mul4(model) + + gl.UseProgram(p.solidShader.ProgramID) + gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0]) + gl.Uniform4f( + p.solidShader.LocColor, + float32(colors.White.R)/255.0, + float32(colors.White.G)/255.0, + float32(colors.White.B)/255.0, + float32(colors.White.A)/255.0, + ) + + gl.BindVertexArray(p.triVAO) + gl.DrawArrays(gl.TRIANGLES, 0, 3) + gl.BindVertexArray(0) + gl.UseProgram(0) + + return nil +} + +// ── Render ──────────────────────────────────────────────────────────────────── + func (p *AudioOptions) Render() error { if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil { - logger.Error("unable to render title texture: %v", err) - return fmt.Errorf("unable to render title texture: %w", err) + return fmt.Errorf("render title: %w", err) } - if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil { - logger.Error("unable to render prompt texture: %v", err) - return fmt.Errorf("unable to render prompt texture: %w", err) - } - - if err := p.RenderTexture(p.textShader, p.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil { - logger.Error("unable to render audioStatusToggle texture: %v", err) - return fmt.Errorf("unable to render audioStatusToggle texture: %w", err) + return fmt.Errorf("render prompt: %w", err) } + // Volume row if err := p.RenderTexture(p.textShader, p.VolumeLabel.ID, p.VolumeLabel.Position(), p.VolumeLabel.RawDimensions(), p.VolumeLabel.Color); err != nil { - logger.Error("unable to render volume label texture: %v", err) - return fmt.Errorf("unable to render volume label texture: %w", err) + return fmt.Errorf("render volume label: %w", err) } - if err := p.renderVolumeBar(); err != nil { - logger.Error("unable to render volume bar: %v", err) - return fmt.Errorf("unable to render volume bar: %w", err) + return fmt.Errorf("render volume bar: %w", err) + } + if err := p.RenderTexture(p.textShader, p.volumePercent.ID, p.volumePercent.Position(), p.volumePercent.RawDimensions(), p.volumePercent.Color); err != nil { + return fmt.Errorf("render volume percent: %w", err) } - if err := p.RenderTexture(p.textShader, p.volumePercent.ID, p.volumePercent.Position(), p.volumePercent.RawDimensions(), p.volumePercent.Color); err != nil { - logger.Error("unable to render volume percent texture: %v", err) - return fmt.Errorf("unable to render volume percent texture: %w", err) + // Toggle + if err := p.RenderTexture(p.textShader, p.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil { + return fmt.Errorf("render audio toggle: %w", err) + } + + // Separator + if err := p.renderSeparator(); err != nil { + return fmt.Errorf("render separator: %w", err) + } + + // "Available Tracks" heading — fixed, never moves + if err := p.RenderTexture(p.textShader, p.tracksHeading.ID, p.tracksHeading.Position(), p.tracksHeading.RawDimensions(), p.tracksHeading.Color); err != nil { + return fmt.Errorf("render tracks heading: %w", err) + } + + // Track list + for i := range p.trackItems { + item := &p.trackItems[i] + if item.Y < 0 { + continue + } + if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color); err != nil { + return fmt.Errorf("render track item %d: %w", i, err) + } + } + + // Scrollbar + if err := p.renderScrollbar(); err != nil { + return fmt.Errorf("render scrollbar: %w", err) + } + + // Spinning triangle cursor — always on top + if err := p.renderCursor(); err != nil { + return fmt.Errorf("render cursor: %w", err) } return nil } +// ── Destroy ─────────────────────────────────────────────────────────────────── + func (p *AudioOptions) Destroy() error { gl.DeleteTextures(1, &p.title.ID) gl.DeleteTextures(1, &p.prompt.ID) - gl.DeleteTextures(1, &p.volumePercent.ID) gl.DeleteTextures(1, &p.VolumeLabel.ID) + gl.DeleteTextures(1, &p.volumePercent.ID) + gl.DeleteTextures(1, &p.AudioStatusToggle.ID) + gl.DeleteTextures(1, &p.tracksHeading.ID) + for i := range p.trackItems { + gl.DeleteTextures(1, &p.trackItems[i].ID) + } + gl.DeleteBuffers(1, &p.triVBO) + gl.DeleteVertexArrays(1, &p.triVAO) return nil } diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index 80dfb74..5f19b3d 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -3,7 +3,6 @@ package page import ( "fmt" "math" - "path/filepath" "strings" "github.com/go-gl/gl/v3.3-core/gl" @@ -261,12 +260,31 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error { } } - assetsDir := p.Context.CfgPtr.AssetsDir - audioFile := filepath.Join(assetsDir, "audio/audio.wav") - logger.Info("Attempting to play %s", audioFile) - if err := mixer.Play(audioFile, true, p.Context.CfgPtr); err != nil { - logger.Error("Mixer returned an error: %v", err) - } + audioFilePath := p.Context.DbPtr.Find + + // Download if it doesn't exist + fmt.Printf("%s", audioFilePath) + + // // Check if the track has previously been downloaded + // // TODO: Download audio via a goroutine + // // this is a path + // // figure out how to get the filename, not the path, from the path. + // _, err = os.Stat(audioFilePath) + // if err != nil { + // filename := filepath.Base(audioFilePath) + // url := "https://files.radiantwavetech.com/audio/" + filename + // logger.Info("%s not present. Downloading...", entry.DisplayName) + // // Download the track if not, display loading bar + // if err = netreq.Download(filename, filepath.Join(p.Base.Context.CfgPtr.AssetsDir, "audio"), url); err != nil { + // logger.Error("Failed to download file: %v", err) + // return + // } + // logger.Info("Downloaded %s", entry.DisplayName) + // } + + // if err := mixer.Play(audioFile, true, p.Context.CfgPtr); err != nil { + // logger.Error("Mixer returned an error: %v", err) + // } currentDevice := mixer.CurrentDevice() if currentDevice == "" { From 6b4867953756cd851935efe7c10d0cd5a2a0162d Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 08:02:21 -0500 Subject: [PATCH 03/18] Add database logic --- internal/page/AudioOptions.go | 51 ++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go index 018a793..e6c218c 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -1,13 +1,16 @@ package page import ( + "errors" "fmt" "math" + "path/filepath" "strings" "github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/mathgl/mgl32" "github.com/veandco/go-sdl2/sdl" + "gorm.io/gorm" "rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/fontManager" @@ -17,20 +20,20 @@ import ( ) var AudioFileList = []db.AudioFile{ - {ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false}, - {ID: 2, Name: "396 Hz", Path: "396Hz.wav", Selected: false}, - {ID: 3, Name: "417 Hz", Path: "417Hz.wav", Selected: false}, - {ID: 4, Name: "432 Hz", Path: "432Hz.wav", Selected: false}, - {ID: 5, Name: "639 Hz", Path: "639Hz.wav", Selected: false}, - {ID: 6, Name: "741 Hz", Path: "741Hz.wav", Selected: false}, - {ID: 7, Name: "852 Hz", Path: "852Hz.wav", Selected: false}, - {ID: 8, Name: "963 Hz", Path: "963Hz.wav", Selected: false}, - {ID: 9, Name: "1111 Hz", Path: "1111Hz.wav", Selected: false}, - {ID: 10, Name: "14 Hz Binaural Beat", Path: "14Hz_Binaural.wav", Selected: false}, - {ID: 11, Name: "40 Hz Binaural Beat", Path: "40Hz_Binaural.wav", Selected: false}, - {ID: 12, Name: "Alpha Waves", Path: "AlphaWaves.wav", Selected: false}, - {ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false}, - {ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false}, + {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 ( @@ -207,6 +210,7 @@ func (p *AudioOptions) buildTrackItems() error { if len(audioFileList) == 0 { audioFileList = AudioFileList + p.Context.DbPtr.Save(&audioFileList) } for i := range p.trackItems { @@ -476,11 +480,26 @@ func (p *AudioOptions) onTrackSelected(index int, entry db.AudioFile) { p.layout() // Create file names and paths - // Set Selected + // Set newly selected entry as Selected entry.Selected = true // Set path if !strings.Contains(entry.Path, ".local/") { - entry.Path = p.Context.CfgPtr.AssetsDir + "audio" + entry.Path + entry.Path = filepath.Join(p.Context.CfgPtr.AssetsDir, "audio/", entry.Path) + } + // Clear previously selected. + var previouslySelectedAudio db.AudioFile + if err := p.Context.DbPtr.Where("Selected = ?", true).First(&previouslySelectedAudio).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + logger.Info("No audio previously selected") + } else { + logger.Error("Unable to fetch previously selected audio from database %s", err) + } + } else { + previouslySelectedAudio.Selected = false + if err = p.Context.DbPtr.Save(&previouslySelectedAudio).Error; err != nil { + logger.Error("Unable to update selected audio: %v", err) + return + } } // Save it to the database if err := p.Context.DbPtr.Save(&entry).Error; err != nil { From d121ffdaa87ceadac6e1a333f6ca00678019fd56 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 08:11:33 -0500 Subject: [PATCH 04/18] Ready to implement a loading bar --- internal/page/scrollerPage.go | 140 ++++++++++++++++++++++++++++------ 1 file changed, 118 insertions(+), 22 deletions(-) diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index 5f19b3d..6992935 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -2,7 +2,11 @@ package page import ( "fmt" + "io" "math" + "net/http" + "os" + "path/filepath" "strings" "github.com/go-gl/gl/v3.3-core/gl" @@ -23,6 +27,87 @@ const ( defaultProclamationPlayCount int = 1 ) +// ProgressUpdate is sent over a channel as bytes are read from the network +// during a download. Read/Total let a consumer compute percentage; Done +// signals a clean finish; Err signals failure. The channel carrying these +// is closed after the final update (whether that's a Done or an Err), so +// ranging over it is sufficient to know when the download has finished. +type ProgressUpdate struct { + Read int64 + Total int64 + Done bool + Err error +} + +// ProgressReader wraps an io.Reader and emits a ProgressUpdate on every +// Read() call. It never blocks on the send: if nobody is currently +// listening on updates (or the consumer is behind), the update is dropped +// rather than stalling the download itself. +type ProgressReader struct { + Reader io.Reader + Total int64 + read int64 + updates chan<- ProgressUpdate +} + +func (pr *ProgressReader) Read(p []byte) (int, error) { + n, err := pr.Reader.Read(p) + if n > 0 { + pr.read += int64(n) + select { + case pr.updates <- ProgressUpdate{Read: pr.read, Total: pr.Total}: + default: + } + } + return n, err +} + +// downloadFile starts the download in its own goroutine and returns +// immediately with a channel of progress updates. The caller ranges over +// the returned channel to observe progress; the channel is closed when the +// download finishes, successfully or not, so the loop ends naturally. +func downloadFile(url, destPath string) <-chan ProgressUpdate { + updates := make(chan ProgressUpdate, 16) + + go func() { + defer close(updates) + + resp, err := http.Get(url) + if err != nil { + updates <- ProgressUpdate{Err: err} + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + updates <- ProgressUpdate{Err: fmt.Errorf("bad status: %s", resp.Status)} + return + } + + out, err := os.Create(destPath) + if err != nil { + updates <- ProgressUpdate{Err: err} + return + } + defer out.Close() + + pr := &ProgressReader{ + Reader: resp.Body, + Total: resp.ContentLength, // -1 if server didn't send it + updates: updates, + } + + if _, err := io.Copy(out, pr); err != nil { + updates <- ProgressUpdate{Err: err} + return + } + + updates <- ProgressUpdate{Read: pr.read, Total: pr.Total, Done: true} + }() + + return updates +} + type ScrollerProclamation struct { db.Proclamation colorPattern.ColorPattern @@ -260,31 +345,42 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error { } } - audioFilePath := p.Context.DbPtr.Find + var selectedAudio db.AudioFile - // Download if it doesn't exist - fmt.Printf("%s", audioFilePath) + if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil { + logger.Error("error querying database for selected audio: %v", err) + } else { + _, err = os.Stat(selectedAudio.Path) + if err != nil { // If err == nil, then the file was most likely not found + url := "https://files.radiantwavetech.com/audio/" + filepath.Base(selectedAudio.Path) - // // Check if the track has previously been downloaded - // // TODO: Download audio via a goroutine - // // this is a path - // // figure out how to get the filename, not the path, from the path. - // _, err = os.Stat(audioFilePath) - // if err != nil { - // filename := filepath.Base(audioFilePath) - // url := "https://files.radiantwavetech.com/audio/" + filename - // logger.Info("%s not present. Downloading...", entry.DisplayName) - // // Download the track if not, display loading bar - // if err = netreq.Download(filename, filepath.Join(p.Base.Context.CfgPtr.AssetsDir, "audio"), url); err != nil { - // logger.Error("Failed to download file: %v", err) - // return - // } - // logger.Info("Downloaded %s", entry.DisplayName) - // } + updates := downloadFile(url, selectedAudio.Path) + var downloadErr error + for u := range updates { + if u.Err != nil { + downloadErr = u.Err + break + } + if u.Total > 0 { + pct := float64(u.Read) / float64(u.Total) * 100 + fmt.Printf("\rDownloaded %d / %d bytes (%.1f%%)", u.Read, u.Total, pct) + } else { + fmt.Printf("\rDownloaded %d bytes", u.Read) + } + if u.Done { + fmt.Println() + } + } + if downloadErr != nil { + return fmt.Errorf("failed to downoad audio file: %v", downloadErr) + } + } + } - // if err := mixer.Play(audioFile, true, p.Context.CfgPtr); err != nil { - // logger.Error("Mixer returned an error: %v", err) - // } + logger.Info("Playing %s", selectedAudio.Path) + if err := mixer.Play(selectedAudio.Path, true, p.Context.CfgPtr); err != nil { + logger.Error("Mixer returned an error: %v", err) + } currentDevice := mixer.CurrentDevice() if currentDevice == "" { From fe5543d50deac7a36299421d99acc2db058b422a Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 11:29:45 -0500 Subject: [PATCH 05/18] Audio downloads when necessary --- internal/page/scrollerPage.go | 93 ++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index 6992935..c209056 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -22,9 +22,9 @@ import ( ) const ( - defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d" - proclamationPlayCount int = 10 - defaultProclamationPlayCount int = 1 + defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d" + proclamationPlayCount int = 20 + // defaultProclamationPlayCount int = 1 ) // ProgressUpdate is sent over a channel as bytes are read from the network @@ -50,6 +50,47 @@ type ProgressReader struct { updates chan<- ProgressUpdate } +type ScrollerPage struct { + Base + + streams []proclamationStream + streamIndex int + + IsDownloadingAudio bool + + lines *LineDeque + lineHeight int32 + shader *shaderManager.Shader + activePattern linePattern.LinePattern + font *fontManager.FontEntry + spaceWidth int32 + lineCtx *linePattern.LineRenderContext + colorPattern *colorPattern.ColorPattern + scrollOffset float32 + scrollY float32 + fontScale float32 + + // Reuse one GL texture per unique word. + wordTextureCache map[string]StringItem + + increment float32 + velocity float32 + + // Time-based velocity control + velocityFloor float32 + velocityCeiling float32 + velocityStep float32 + velocityDelay float32 + velocityDirection int + velocityTimer float32 + velocityState int + autoVelocity bool + hold float32 + + // Color palette settings + useColorPalettes bool +} + func (pr *ProgressReader) Read(p []byte) (int, error) { n, err := pr.Reader.Read(p) if n > 0 { @@ -214,45 +255,6 @@ func (d *LineDeque) Clear() { d.head, d.tail, d.count = 0, 0, 0 } -type ScrollerPage struct { - Base - - streams []proclamationStream - streamIndex int - - lines *LineDeque - lineHeight int32 - shader *shaderManager.Shader - activePattern linePattern.LinePattern - font *fontManager.FontEntry - spaceWidth int32 - lineCtx *linePattern.LineRenderContext - colorPattern *colorPattern.ColorPattern - scrollOffset float32 - scrollY float32 - fontScale float32 - - // Reuse one GL texture per unique word. - wordTextureCache map[string]StringItem - - increment float32 - velocity float32 - - // Time-based velocity control - velocityFloor float32 - velocityCeiling float32 - velocityStep float32 - velocityDelay float32 - velocityDirection int - velocityTimer float32 - velocityState int - autoVelocity bool - hold float32 - - // Color palette settings - useColorPalettes bool -} - func (p *ScrollerPage) Init(ctx *PageCtx) error { p.increment = 100.0 p.velocity = 800.0 @@ -353,7 +355,7 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error { _, err = os.Stat(selectedAudio.Path) if err != nil { // If err == nil, then the file was most likely not found url := "https://files.radiantwavetech.com/audio/" + filepath.Base(selectedAudio.Path) - + p.IsDownloadingAudio = true updates := downloadFile(url, selectedAudio.Path) var downloadErr error for u := range updates { @@ -363,15 +365,16 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error { } if u.Total > 0 { pct := float64(u.Read) / float64(u.Total) * 100 - fmt.Printf("\rDownloaded %d / %d bytes (%.1f%%)", u.Read, u.Total, pct) + fmt.Printf("\rDownloaded %d / %d bytes (%.1f%%)\n", u.Read, u.Total, pct) } else { - fmt.Printf("\rDownloaded %d bytes", u.Read) + fmt.Printf("\rDownloaded %d bytes\n", u.Read) } if u.Done { fmt.Println() } } if downloadErr != nil { + p.IsDownloadingAudio = false return fmt.Errorf("failed to downoad audio file: %v", downloadErr) } } From 26e84a4d231b3274820752308a975b8199575df8 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 11:46:46 -0500 Subject: [PATCH 06/18] Refactored the Networking package. D0.1B --- internal/network/network.go | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/internal/network/network.go b/internal/network/network.go index 7227102..08a1c99 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -28,10 +28,6 @@ const ( nmDeviceTypeWifi uint32 = 2 nmDeviceStateActivated uint32 = 100 APFlagsPrivacy uint32 = 0x00000001 // Equivalent to NM_802_11_AP_FLAGS_PRIVACY - APSecKeyMgmtEAP uint32 = 0x00000020 - APSecKeyMgmtPSK uint32 = 0x00000100 - APSecKeyMgmtOWE uint32 = 0x00000200 - APSecKeyMgmtSAE uint32 = 0x00000400 ) // ErrScanAborted is returned when a scan is cancelled via the stop channel. @@ -88,8 +84,7 @@ type AccessPoint struct { Strength byte IsProtected bool IsActive bool - WpaFlags uint32 - RsnFlags uint32 + KeyMgmt string } // New creates a new network Manager and connects to the system D-Bus. @@ -277,13 +272,22 @@ func (m *Manager) scanAccessPoints(apPaths []dbus.ObjectPath) ([]AccessPoint, er isProtected := (flags&APFlagsPrivacy != 0) || (wpaFlags != 0) || (rsnFlags != 0) + // Determine the key management type + var keyMgmt string + if wpaFlags != 0 { + keyMgmt = "wpa-psk" + } else if rsnFlags != 0 { + keyMgmt = "wpa-psk" + } else { + keyMgmt = "none" + } + currentAP := AccessPoint{ ObjectPath: apPath, SSID: ssidStr, Strength: strength, IsProtected: isProtected, - WpaFlags: wpaFlags, - RsnFlags: rsnFlags, + KeyMgmt: keyMgmt, } if existingAP, ok := tempNetworkMap[ssidStr]; !ok || strength > existingAP.Strength { tempNetworkMap[ssidStr] = currentAP @@ -328,7 +332,7 @@ func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string) if ap.IsProtected { settings["802-11-wireless-security"] = map[string]dbus.Variant{ - "key-mgmt": dbus.MakeVariant("wpa-psk"), + "key-mgmt": dbus.MakeVariant(ap.KeyMgmt), "auth-alg": dbus.MakeVariant("open"), "psk": dbus.MakeVariant(passphrase), } @@ -350,6 +354,12 @@ func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string) } log.Printf("Connection created: %s, Active path: %s", connectionPath, activeConnectionPath) + + // Wait for the connection to be fully established + if _, err := m.WaitForInternet(30*time.Second, 5*time.Second); err != nil { + return fmt.Errorf("failed to establish internet connection: %w", err) + } + return nil } From a5f9780f410b28751c41b9f46432163b6ab2fbde Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 15:39:30 -0500 Subject: [PATCH 07/18] Add download package --- internal/download/Download.go | 160 ++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 internal/download/Download.go diff --git a/internal/download/Download.go b/internal/download/Download.go new file mode 100644 index 0000000..38da210 --- /dev/null +++ b/internal/download/Download.go @@ -0,0 +1,160 @@ +package download + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" +) + +// DownloadStatus holds information related to downloads and is +// the container for information returned via channels within this +// package. +type DownloadStatus struct { + Min int64 // Current file's bytes downloaded + Max int64 // Current file's total size + Cur int64 // Overall bytes downloaded + Msg string // Status message + Err error // Error, if any +} + +// DownloadAudioFiles downloads multiple audio files and sends progress updates +// to the provided channel. The channel is closed when all downloads are complete. +func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, destDir string) { + defer close(progressChan) + + urlBase := "https://files.rhemawavetech.com/audio/" + totalDownloaded := int64(0) + totalSize := int64(0) + + // First, get the total size of all files + for _, filename := range filenames { + url := urlBase + filename + resp, err := http.Head(url) + if err != nil { + progressChan <- DownloadStatus{ + Msg: fmt.Sprintf("Failed to get size for %s: %v", filename, err), + Err: err, + } + return + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + progressChan <- DownloadStatus{ + Msg: fmt.Sprintf("Failed to get size for %s: status %s", filename, resp.Status), + Err: fmt.Errorf("bad status: %s", resp.Status), + } + return + } + totalSize += resp.ContentLength + } + + // Send initial status + progressChan <- DownloadStatus{ + Min: 0, + Max: totalSize, + Cur: 0, + Msg: "Starting download...", + } + + // Download each file + for _, filename := range filenames { + url := urlBase + filename + destPath := filepath.Join(destDir, filename) + + // Create the destination file + out, err := os.Create(destPath) + if err != nil { + progressChan <- DownloadStatus{ + Msg: fmt.Sprintf("Failed to create file %s: %v", destPath, err), + Err: err, + } + return + } + + // Get the file + resp, err := http.Get(url) + if err != nil { + out.Close() + os.Remove(destPath) + progressChan <- DownloadStatus{ + Msg: fmt.Sprintf("Failed to download %s: %v", filename, err), + Err: err, + } + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + out.Close() + os.Remove(destPath) + progressChan <- DownloadStatus{ + Msg: fmt.Sprintf("Failed to download %s: status %s", filename, resp.Status), + Err: fmt.Errorf("bad status: %s", resp.Status), + } + return + } + + // Track progress for this file + fileSize := resp.ContentLength + downloaded := int64(0) + + // Use a buffered reader for efficiency + buf := make([]byte, 8192) + for { + n, err := resp.Body.Read(buf) + if err != nil && err != io.EOF { + out.Close() + os.Remove(destPath) + progressChan <- DownloadStatus{ + Msg: fmt.Sprintf("Failed to read %s: %v", filename, err), + Err: err, + } + return + } + if n == 0 { + break + } + + // Write to file + if _, err := out.Write(buf[:n]); err != nil { + out.Close() + os.Remove(destPath) + progressChan <- DownloadStatus{ + Msg: fmt.Sprintf("Failed to write %s: %v", filename, err), + Err: err, + } + return + } + + // Update progress + downloaded += int64(n) + totalDownloaded += int64(n) + progressChan <- DownloadStatus{ + Min: downloaded, + Max: fileSize, + Cur: totalDownloaded, + Msg: fmt.Sprintf("Downloading %s...", filename), + } + } + + out.Close() + + // Send completion for this file + progressChan <- DownloadStatus{ + Min: fileSize, + Max: fileSize, + Cur: totalDownloaded, + Msg: fmt.Sprintf("Finished downloading %s", filename), + } + } + + // Send final status + progressChan <- DownloadStatus{ + Min: totalSize, + Max: totalSize, + Cur: totalDownloaded, + Msg: "All downloads completed!", + } +} From 60815ba001c5efcc3d6a36e8cbca8378ed31e841 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 15:39:37 -0500 Subject: [PATCH 08/18] Track FirstRun --- internal/db/schema.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/db/schema.go b/internal/db/schema.go index 1a1147b..0d7f6bb 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -16,6 +16,7 @@ type AppConfig struct { LinePattern string // "fibonacci" AudioEnabled bool SavedVolume float64 // Last saved volume as a percentage + FirstRun bool // Is this the first time the application has been run? } type Log struct { From 1a67b9780400848d2f93b2d407636979721fb535 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 15:49:05 -0500 Subject: [PATCH 09/18] un-mask wifi password --- internal/page/wifiSetupPage.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/page/wifiSetupPage.go b/internal/page/wifiSetupPage.go index abd7ce3..92747c1 100644 --- a/internal/page/wifiSetupPage.go +++ b/internal/page/wifiSetupPage.go @@ -2,7 +2,6 @@ package page import ( "fmt" - "strings" "time" "github.com/go-gl/gl/v3.3-core/gl" @@ -95,7 +94,7 @@ func (p *WiFiSetupPage) Init(ctx *PageCtx) error { } for _, dev := range p.networkManager.Devices { if dev.Type == network.TypeWifi { - d := dev + d := dev p.wifiDevice = &d break } @@ -198,14 +197,13 @@ func (p *WiFiSetupPage) handlePasswordInput(sym sdl.Keycode, mod uint16) { switch sym { case sdl.K_RETURN: - p.exitPasswordMode() + p.exitPasswordMode() p.pendingSSID = p.selectedAP.SSID p.isConnecting = true p.connectionError = "" p.connectionAttemptTime = time.Now() - - + err := p.networkManager.Connect(*p.wifiDevice, *p.selectedAP, p.passwordInput) if err != nil { p.isConnecting = false @@ -589,7 +587,7 @@ func (p *WiFiSetupPage) updatePasswordTexture() { if p.passwordInputItem.ID != 0 { gl.DeleteTextures(1, &p.passwordInputItem.ID) } - maskedPassword := strings.Repeat("*", len(p.passwordInput)) + maskedPassword := p.passwordInput if maskedPassword == "" { maskedPassword = " " } From d8a9f6881aca8a6a23ef44f7cc60caebf489b419 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 15:49:20 -0500 Subject: [PATCH 10/18] create scaffold for loading screen --- internal/page/DownloadingAssets.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 internal/page/DownloadingAssets.go diff --git a/internal/page/DownloadingAssets.go b/internal/page/DownloadingAssets.go new file mode 100644 index 0000000..912a6a8 --- /dev/null +++ b/internal/page/DownloadingAssets.go @@ -0,0 +1,28 @@ +package page + +import ( + "github.com/veandco/go-sdl2/sdl" +) + +type DownloadingAssets struct { +} + +func (p *DownloadingAssets) Init(ctx *PageCtx) error { + return nil +} + +func (p *DownloadingAssets) HandleEvent(event *sdl.Event) error { + return nil +} + +func (p *DownloadingAssets) Update(dt float32) error { + return nil +} + +func (p *DownloadingAssets) Render() error { + return nil +} + +func (p *DownloadingAssets) Destroy() error { + return nil +} From b11afe22f09ed54e432d0cc24ac7ea9561051174 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 15:49:29 -0500 Subject: [PATCH 11/18] remove file downloads --- internal/page/scrollerPage.go | 112 ---------------------------------- 1 file changed, 112 deletions(-) diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index c209056..46ef508 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -2,11 +2,7 @@ package page import ( "fmt" - "io" "math" - "net/http" - "os" - "path/filepath" "strings" "github.com/go-gl/gl/v3.3-core/gl" @@ -27,29 +23,6 @@ const ( // defaultProclamationPlayCount int = 1 ) -// ProgressUpdate is sent over a channel as bytes are read from the network -// during a download. Read/Total let a consumer compute percentage; Done -// signals a clean finish; Err signals failure. The channel carrying these -// is closed after the final update (whether that's a Done or an Err), so -// ranging over it is sufficient to know when the download has finished. -type ProgressUpdate struct { - Read int64 - Total int64 - Done bool - Err error -} - -// ProgressReader wraps an io.Reader and emits a ProgressUpdate on every -// Read() call. It never blocks on the send: if nobody is currently -// listening on updates (or the consumer is behind), the update is dropped -// rather than stalling the download itself. -type ProgressReader struct { - Reader io.Reader - Total int64 - read int64 - updates chan<- ProgressUpdate -} - type ScrollerPage struct { Base @@ -91,64 +64,6 @@ type ScrollerPage struct { useColorPalettes bool } -func (pr *ProgressReader) Read(p []byte) (int, error) { - n, err := pr.Reader.Read(p) - if n > 0 { - pr.read += int64(n) - select { - case pr.updates <- ProgressUpdate{Read: pr.read, Total: pr.Total}: - default: - } - } - return n, err -} - -// downloadFile starts the download in its own goroutine and returns -// immediately with a channel of progress updates. The caller ranges over -// the returned channel to observe progress; the channel is closed when the -// download finishes, successfully or not, so the loop ends naturally. -func downloadFile(url, destPath string) <-chan ProgressUpdate { - updates := make(chan ProgressUpdate, 16) - - go func() { - defer close(updates) - - resp, err := http.Get(url) - if err != nil { - updates <- ProgressUpdate{Err: err} - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - updates <- ProgressUpdate{Err: fmt.Errorf("bad status: %s", resp.Status)} - return - } - - out, err := os.Create(destPath) - if err != nil { - updates <- ProgressUpdate{Err: err} - return - } - defer out.Close() - - pr := &ProgressReader{ - Reader: resp.Body, - Total: resp.ContentLength, // -1 if server didn't send it - updates: updates, - } - - if _, err := io.Copy(out, pr); err != nil { - updates <- ProgressUpdate{Err: err} - return - } - - updates <- ProgressUpdate{Read: pr.read, Total: pr.Total, Done: true} - }() - - return updates -} - type ScrollerProclamation struct { db.Proclamation colorPattern.ColorPattern @@ -351,33 +266,6 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error { if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil { logger.Error("error querying database for selected audio: %v", err) - } else { - _, err = os.Stat(selectedAudio.Path) - if err != nil { // If err == nil, then the file was most likely not found - url := "https://files.radiantwavetech.com/audio/" + filepath.Base(selectedAudio.Path) - p.IsDownloadingAudio = true - updates := downloadFile(url, selectedAudio.Path) - var downloadErr error - for u := range updates { - if u.Err != nil { - downloadErr = u.Err - break - } - if u.Total > 0 { - pct := float64(u.Read) / float64(u.Total) * 100 - fmt.Printf("\rDownloaded %d / %d bytes (%.1f%%)\n", u.Read, u.Total, pct) - } else { - fmt.Printf("\rDownloaded %d bytes\n", u.Read) - } - if u.Done { - fmt.Println() - } - } - if downloadErr != nil { - p.IsDownloadingAudio = false - return fmt.Errorf("failed to downoad audio file: %v", downloadErr) - } - } } logger.Info("Playing %s", selectedAudio.Path) From 397d4c9f19c8e345cad70a7e8f5fff6b9e74457c Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 16:12:24 -0500 Subject: [PATCH 12/18] Block skipping setup screens --- internal/application/application.go | 49 ++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/internal/application/application.go b/internal/application/application.go index d4447f4..273ae40 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -26,18 +26,20 @@ import ( const ( targetFPS = 60.0 - volumeStepSize = 16 openGLMajorVersion = 3 openGLMinorVersion = 3 - apiURL = "https://portal.rhemawavetech.com/api/v1" + // volumeStepSize = 16 + // apiURL = "https://portal.rhemawavetech.com/api/v1" ) type State int const ( - NEEDS_INTERNET State = iota + FIRST_RUN State = iota + NEEDS_INTERNET NEEDS_LICENSE_KEY NEEDS_EMAIL_ADDRESS + DOWNLOADING_ASSETS READY ) @@ -107,10 +109,12 @@ func Run() { } // Populate the initial Config cache OR create defaults + firstRun := false result = DB.First(&initialConfig) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { logger.Info("First run detected: Creating default configuration...") + firstRun = true initialConfig = db.AppConfig{ DataDir: dataDir, AssetsDir: dataDir, @@ -138,6 +142,10 @@ func Run() { DB: DB, } + if firstRun { + app.state = FIRST_RUN + } + app.refreshCache() w, h := app.initializeSDLAndOpenGL() @@ -161,13 +169,15 @@ func Run() { app.runEventLoop() } +// checkState keeps watch of the application state and sets pages +// accordingly. This function runs once per func (app *Application) checkState() { + // app.state is READY so do nothing. if app.state == READY { return } - // app.state = 0 means that there is no state set - // Check for an internet connection first + // Check if there is an internet connection, and if not, force the WiFi page if !networkIsUp { if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second { networkManager, err := network.New() @@ -189,12 +199,21 @@ func (app *Application) checkState() { } return } - networkIsUp = true // ← internet confirmed, don't check again + networkIsUp = true // internet confirmed, don't check again } else { return // throttled, and we don't yet know if network is up — can't proceed } } + // Then check for an email address + if app.Config.Load().EmailAddress == "" { + if app.state != NEEDS_EMAIL_ADDRESS { + app.PageRouter.NavigateTo(page.EMAIL_ADDRESS) + app.state = NEEDS_EMAIL_ADDRESS + } + return + } + // Then check for a license key if app.Config.Load().LicenseKey == "" { if app.state != NEEDS_LICENSE_KEY { @@ -204,15 +223,6 @@ func (app *Application) checkState() { return } - // Finally, check for an email address - if app.Config.Load().EmailAddress == "" { - if app.state != NEEDS_EMAIL_ADDRESS { - app.PageRouter.NavigateTo(page.EMAIL_ADDRESS) - app.state = NEEDS_EMAIL_ADDRESS - } - return - } - app.state = READY app.PageRouter.NavigateTo(page.HOME) } @@ -428,14 +438,23 @@ func (app *Application) renderFrame() { func (app *Application) initKeybinds() { keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() { + if app.state != READY { + return + } app.PageRouter.NavigateTo(page.SETTINGS) }) keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() { + if app.state != READY { + return + } app.PageRouter.NavigateTo(page.SCROLLER) }) keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() { + if app.state != READY { + return + } app.PageRouter.NavigateTo(page.HOME) }) From 7ecb90b63b6142090ada98ff2aac19d0c6f964c3 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 21:28:16 -0500 Subject: [PATCH 13/18] Downloads are working and the goroutine is reporting updates appropriately. --- internal/download/Download.go | 61 ++++++---- internal/page/Message.go | 29 +++++ internal/page/home.go | 68 +++++++++-- internal/page/message.go | 111 ------------------ internal/page/router.go | 6 +- internal/page/updateCheckPage.go | 188 ------------------------------- 6 files changed, 126 insertions(+), 337 deletions(-) create mode 100644 internal/page/Message.go delete mode 100644 internal/page/message.go delete mode 100644 internal/page/updateCheckPage.go diff --git a/internal/download/Download.go b/internal/download/Download.go index 38da210..3d4da08 100644 --- a/internal/download/Download.go +++ b/internal/download/Download.go @@ -6,15 +6,16 @@ import ( "net/http" "os" "path/filepath" + + "rhemawavetech.com/rhemawave/internal/logger" ) // DownloadStatus holds information related to downloads and is // the container for information returned via channels within this // package. type DownloadStatus struct { - Min int64 // Current file's bytes downloaded - Max int64 // Current file's total size - Cur int64 // Overall bytes downloaded + Max int64 // Total bytes to download + Cur int64 // Total bytes downloaded Msg string // Status message Err error // Error, if any } @@ -25,11 +26,14 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, defer close(progressChan) urlBase := "https://files.rhemawavetech.com/audio/" - totalDownloaded := int64(0) - totalSize := int64(0) - // First, get the total size of all files + // First, calculate the total size of files that need to be downloaded + totalSize := int64(0) + filesToDownload := []string{} + for _, filename := range filenames { + destPath := filepath.Join(destDir, filename) + // Check if the file already exists url := urlBase + filename resp, err := http.Head(url) if err != nil { @@ -39,7 +43,6 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, } return } - resp.Body.Close() if resp.StatusCode != http.StatusOK { progressChan <- DownloadStatus{ Msg: fmt.Sprintf("Failed to get size for %s: status %s", filename, resp.Status), @@ -47,19 +50,39 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, } return } - totalSize += resp.ContentLength + fileStat, err := os.Stat(destPath) + // If the file is incomplete, or does not exist + if os.IsNotExist(err) || fileStat.Size() != resp.ContentLength { + // Oh, and log a warning if the file is not complete + if !os.IsNotExist(err) { + logger.Warning("%s incomplete/corrupt. Adding to download queue.", filename) + } else { + logger.Info("%s not found. Adding to download queue.", filename) + } + + // Append it to the list to download + filesToDownload = append(filesToDownload, filename) + // And increase the total bytes to download + totalSize += resp.ContentLength + } else if err != nil { + logger.Error("unknown error: %v", err) + } else { + logger.Error("File %s exists, skipping...", filename) + } + resp.Body.Close() } // Send initial status progressChan <- DownloadStatus{ - Min: 0, Max: totalSize, Cur: 0, Msg: "Starting download...", } - // Download each file - for _, filename := range filenames { + totalDownloaded := int64(0) + + // Download each file that doesn't exist + for _, filename := range filesToDownload { url := urlBase + filename destPath := filepath.Join(destDir, filename) @@ -96,10 +119,6 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, return } - // Track progress for this file - fileSize := resp.ContentLength - downloaded := int64(0) - // Use a buffered reader for efficiency buf := make([]byte, 8192) for { @@ -129,13 +148,11 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, } // Update progress - downloaded += int64(n) totalDownloaded += int64(n) progressChan <- DownloadStatus{ - Min: downloaded, - Max: fileSize, + Max: totalSize, Cur: totalDownloaded, - Msg: fmt.Sprintf("Downloading %s...", filename), + Msg: fmt.Sprintf("%s", filename), } } @@ -143,8 +160,7 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, // Send completion for this file progressChan <- DownloadStatus{ - Min: fileSize, - Max: fileSize, + Max: totalSize, Cur: totalDownloaded, Msg: fmt.Sprintf("Finished downloading %s", filename), } @@ -152,9 +168,8 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, // Send final status progressChan <- DownloadStatus{ - Min: totalSize, Max: totalSize, Cur: totalDownloaded, - Msg: "All downloads completed!", + Msg: "All audio downloads completed!", } } diff --git a/internal/page/Message.go b/internal/page/Message.go new file mode 100644 index 0000000..c010f22 --- /dev/null +++ b/internal/page/Message.go @@ -0,0 +1,29 @@ +package page + +import ( + "github.com/veandco/go-sdl2/sdl" +) + +type MessagePage struct { + Msg string +} + +func (p *MessagePage) Init(ctx *PageCtx) error { + return nil +} + +func (p *MessagePage) HandleEvent(event *sdl.Event) error { + return nil +} + +func (p *MessagePage) Update(dt float32) error { + return nil +} + +func (p *MessagePage) Render() error { + return nil +} + +func (p *MessagePage) Destroy() error { + return nil +} diff --git a/internal/page/home.go b/internal/page/home.go index f55baab..4f03722 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -2,12 +2,14 @@ package page import ( "fmt" + "path/filepath" "time" "github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/mathgl/mgl32" "github.com/veandco/go-sdl2/sdl" "rhemawavetech.com/rhemawave/internal/colors" + "rhemawavetech.com/rhemawave/internal/download" "rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/shaderManager" ) @@ -16,16 +18,20 @@ var GitVersion = "1.0" type Home struct { Base - title StringItem - version StringItem - prompt StringItem - note StringItem - licenseInfoHeading StringItem - emailDisplay StringItem - licenseKeyDisplay StringItem - hasLicenseInfo bool - lastDisplayUpdate time.Time - baseFontSize int // cached from db at init + fm *fontManager.FontManager + font *fontManager.FontEntry + title StringItem + version StringItem + prompt StringItem + note StringItem + message StringItem + downloadProgressChan chan download.DownloadStatus + licenseInfoHeading StringItem + emailDisplay StringItem + licenseKeyDisplay StringItem + hasLicenseInfo bool + lastDisplayUpdate time.Time + baseFontSize int // cached from db at init } // Init performs basic page initialization and creation of static assets. @@ -40,11 +46,12 @@ func (p *Home) Init(ctx *PageCtx) error { } // Get font - fm := fontManager.Get() - font, ok := fm.GetFont("Roboto-Regular") + p.fm = fontManager.Get() + font, ok := p.fm.GetFont("Roboto-Regular") if !ok { return fmt.Errorf("unable to fetch font") } + p.font = font // Create title texture var err error @@ -72,6 +79,9 @@ func (p *Home) Init(ctx *PageCtx) error { return fmt.Errorf("unable to create note string texture: %w", err) } + // Create download channel and message + p.downloadProgressChan = make(chan download.DownloadStatus) + // Create license info textures if data exists if p.Context.CfgPtr.EmailAddress != "" || p.Context.CfgPtr.LicenseKey != "" { p.hasLicenseInfo = true @@ -123,6 +133,25 @@ func (p *Home) Init(ctx *PageCtx) error { } } + 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", + } + + go download.DownloadAudioFiles(p.downloadProgressChan, audioFileNames, filepath.Join(ctx.CfgPtr.AssetsDir, "audio")) + return nil } @@ -133,6 +162,21 @@ func (p *Home) HandleEvent(event *sdl.Event) error { // Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32. func (p *Home) Update(dt float32) error { + var err error + + select { + case progressUpdate := <-p.downloadProgressChan: + if progressUpdate.Err != nil { + p.message, err = NewStringItem(progressUpdate.Msg, p.font, colors.White) + if err != nil { + return err + } + } else { + p.message, err = NewStringItem(progressUpdate.Err.Error(), p.font, colors.Red) + } + default: + return nil + } return nil } diff --git a/internal/page/message.go b/internal/page/message.go deleted file mode 100644 index 43a81d0..0000000 --- a/internal/page/message.go +++ /dev/null @@ -1,111 +0,0 @@ -package page - -import ( - "fmt" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/go-gl/mathgl/mgl32" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/colors" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -type MessagePage struct { - Base - message StringItem - instructions StringItem - msgDim mgl32.Vec2 - instDim mgl32.Vec2 - textShader *shaderManager.Shader - msgPos mgl32.Vec2 - instPos mgl32.Vec2 -} - -func (p *MessagePage) Init(ctx *PageCtx) error { - var err error - var ok bool - - if ctx.Msg == "" { - ctx.Msg = "This is an example message because no message was passed" - } - - if ctx.Inst == "" { - ctx.Inst = "Press F1 to exit" - } - - if err = p.Base.Init(ctx); err != nil { - return fmt.Errorf("failed to init Base page: %w", err) - } - - // Fetch and cache shader - sm := shaderManager.Get() - p.textShader, ok = sm.Get("text") - if !ok { - return fmt.Errorf("failed to fetch text shader") - } - - fm := fontManager.Get() - font, ok := fm.GetFont("Roboto-Regular") - if !ok { - return fmt.Errorf("unable to fetch font") - } - - p.message, err = NewStringItem(ctx.Msg, font, colors.Orange) - if err != nil { - return fmt.Errorf("unable to create message string texture: %w", err) - } - - p.instructions, err = NewStringItem(ctx.Inst, font, colors.White) - if err != nil { - return fmt.Errorf("unable to create instructions string texture: %w", err) - } - - baseFontSize := float32(p.Context.CfgPtr.StandardFontSize) - p.message.Scale(float32(48) / baseFontSize) - p.instructions.Scale(float32(28) / baseFontSize) - - // Cache scaled dimensions — never recomputed unless textures are replaced - p.msgDim = p.message.RawDimensions() - p.instDim = p.instructions.RawDimensions() - - p.msgPos = mgl32.Vec2{ - float32(p.Base.ScreenCenterX) - p.msgDim[0]/2, - float32(p.Base.ScreenCenterY) - p.msgDim[1]/2, - } - p.instPos = mgl32.Vec2{ - float32(p.Base.ScreenCenterX) - p.instDim[0]/2, - float32(20), - } - - return nil -} - -func (p *MessagePage) HandleEvent(event *sdl.Event) error { - return nil -} - -func (p *MessagePage) Update(dt float32) error { - return nil -} - -func (p *MessagePage) Render() error { - p.Base.RenderTexture(p.textShader, p.message.ID, p.msgPos, p.msgDim, colors.Orange) - p.Base.RenderTexture(p.textShader, p.instructions.ID, p.instPos, p.instDim, colors.White) - - return nil -} - -func (p *MessagePage) Destroy() error { - gl.DeleteTextures(1, &p.message.ID) - gl.DeleteTextures(1, &p.instructions.ID) - return p.Base.Destroy() -} - -func (p *MessagePage) SetMessage(msg string) error { - return nil -} - -func (p *MessagePage) SetInstructions(inst string) error { - return nil -} diff --git a/internal/page/router.go b/internal/page/router.go index bff8fa8..24c5ebd 100644 --- a/internal/page/router.go +++ b/internal/page/router.go @@ -17,8 +17,8 @@ const ( LICENSE_KEY EMAIL_ADDRESS LOGS - CHECK_UPDATES MESSAGE + DOWNLOADING_ASSETS ) type PageCtx struct { @@ -69,10 +69,10 @@ func (r *Router) NavigateTo(p pageType) { r.ActivePage = &EmailAddressPage{} case LOGS: r.ActivePage = &LogViewerPage{} - case CHECK_UPDATES: - r.ActivePage = &UpdateCheckPage{} case MESSAGE: r.ActivePage = &MessagePage{} + case DOWNLOADING_ASSETS: + r.ActivePage = &DownloadingAssets{} } r.ActivePage.Init(&r.Context) diff --git a/internal/page/updateCheckPage.go b/internal/page/updateCheckPage.go deleted file mode 100644 index 53065d7..0000000 --- a/internal/page/updateCheckPage.go +++ /dev/null @@ -1,188 +0,0 @@ -package page - -import ( - "fmt" - "os/exec" - "strings" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/colors" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -type updateCheckState int - -const ( - updateCheckStateChecking updateCheckState = iota - updateCheckStateDone -) - -type UpdateCheckPage struct { - Base - textShader *shaderManager.Shader - - state updateCheckState - - // "Checking for Updates." / ".." / "..." - checkingTextures [3]StringItem - - // "No updates available" and "Press F3 to return" - doneMessage StringItem - doneHint StringItem - - // Error message if service failed to start - errorMessage StringItem - hasError bool - - pollTimer float32 - dotIndex int - dotTimer float32 -} - -func (p *UpdateCheckPage) Init(ctx *PageCtx) error { - var err error - if err := p.Base.Init(ctx); err != nil { - return fmt.Errorf("failed base page initialization: %w", err) - } - - fm := fontManager.Get() - sm := shaderManager.Get() - - font, ok := fm.GetFont("Roboto-Regular") - if !ok { - return fmt.Errorf("failed to get font") - } - - p.textShader, ok = sm.Get("text") - if !ok { - return fmt.Errorf("failed to get text shader") - } - - // Get scale - baseFontSize := ctx.CfgPtr.StandardFontSize - scale := float32(48) / float32(baseFontSize) - - // Create checking textures with animated dots - dots := []string{"Checking for Updates.", "Checking for Updates..", "Checking for Updates..."} - for i, text := range dots { - item, err := NewStringItem(text, font, colors.White) - if err != nil { - return fmt.Errorf("creating checking texture %d: %w", i, err) - } - item.Scale(scale) - p.checkingTextures[i] = item - } - - // Create done textures - hintScale := float32(32) / float32(baseFontSize) - - p.doneMessage, err = NewStringItem("No updates available", font, colors.White) - if err != nil { - return fmt.Errorf("creating done message: %w", err) - } - p.doneMessage.Scale(scale) - - p.doneHint, err = NewStringItem("Press F3 to return", font, colors.White) - if err != nil { - return fmt.Errorf("creating done hint: %w", err) - } - p.doneHint.Scale(hintScale) - - // Start the update service - p.state = updateCheckStateChecking - if err := exec.Command("systemctl", "start", "nixos-upgrade.service").Run(); err != nil { - p.errorMessage, _ = NewStringItem("Update service unavailable", font, colors.White) - p.errorMessage.Scale(scale) - p.hasError = true - p.state = updateCheckStateDone - } - - return nil -} - -func (p *UpdateCheckPage) Update(dt float32) error { - if p.state != updateCheckStateChecking { - return nil - } - - // Animate dots - p.dotTimer += dt - if p.dotTimer >= 0.5 { - p.dotTimer -= 0.5 - p.dotIndex = (p.dotIndex + 1) % 3 - } - - // Poll service status every 2 seconds - p.pollTimer += dt - if p.pollTimer >= 2.0 { - p.pollTimer -= 2.0 - - output, _ := exec.Command("systemctl", "is-active", "rhemawave-update.service").Output() - status := strings.TrimSpace(string(output)) - if status != "active" && status != "activating" { - p.state = updateCheckStateDone - } - } - - return nil -} - -func (p *UpdateCheckPage) HandleEvent(event *sdl.Event) error { - if p.state != updateCheckStateDone { - return nil - } - - switch e := (*event).(type) { - case *sdl.KeyboardEvent: - if e.Type != sdl.KEYDOWN { - return nil - } - switch e.Keysym.Sym { - case sdl.K_F3, sdl.K_BACKSPACE: - p.Context.NavigateTo(SETTINGS) - } - } - return nil -} - -func (p *UpdateCheckPage) Render() error { - switch p.state { - case updateCheckStateChecking: - item := p.checkingTextures[p.dotIndex] - item.X = p.ScreenCenterX - item.W/2 - item.Y = p.ScreenCenterY - item.H/2 - p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color) - - case updateCheckStateDone: - var msg *StringItem - if p.hasError { - msg = &p.errorMessage - } else { - msg = &p.doneMessage - } - - msg.X = p.ScreenCenterX - msg.W/2 - msg.Y = p.ScreenCenterY + msg.H/2 - p.RenderTexture(p.textShader, msg.ID, msg.Position(), msg.RawDimensions(), msg.Color) - - p.doneHint.X = p.ScreenCenterX - p.doneHint.W/2 - p.doneHint.Y = msg.Y - p.doneHint.H - 20 - p.RenderTexture(p.textShader, p.doneHint.ID, p.doneHint.Position(), p.doneHint.RawDimensions(), p.doneHint.Color) - } - - return nil -} - -func (p *UpdateCheckPage) Destroy() error { - for i := range p.checkingTextures { - gl.DeleteTextures(1, &p.checkingTextures[i].ID) - } - gl.DeleteTextures(1, &p.doneMessage.ID) - gl.DeleteTextures(1, &p.doneHint.ID) - if p.hasError { - gl.DeleteTextures(1, &p.errorMessage.ID) - } - return nil -} From 2af1aae88580142b6dda4dafd323b7b29449ba90 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 21:52:29 -0500 Subject: [PATCH 14/18] Audio files download on application start. Home screen shows status message --- internal/application/application.go | 32 +++++- internal/download/Download.go | 2 +- internal/mixer/mixer.go | 2 +- internal/page/home.go | 170 ++++++++++++++++++---------- internal/page/router.go | 16 +-- 5 files changed, 146 insertions(+), 76 deletions(-) diff --git a/internal/application/application.go b/internal/application/application.go index 273ae40..52e0475 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -12,6 +12,7 @@ import ( "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" @@ -153,18 +154,39 @@ func Run() { app.initializeManagers() app.initializeAudio() + downloadProgressChan := make(chan download.DownloadStatus) + app.PageRouter = page.NewRouter(page.PageCtx{ - CfgPtr: app.Config.Load(), - DbPtr: app.DB, - W: w, - H: h, - RefreshConfig: app.refreshCache, + CfgPtr: app.Config.Load(), + DbPtr: app.DB, + W: w, + H: h, + MessageChannel: downloadProgressChan, + RefreshConfig: app.refreshCache, }) // 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", + } + + go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, audioFileNames, filepath.Join(app.PageRouter.Context.CfgPtr.AssetsDir, "audio")) app.runEventLoop() } diff --git a/internal/download/Download.go b/internal/download/Download.go index 3d4da08..6a50944 100644 --- a/internal/download/Download.go +++ b/internal/download/Download.go @@ -162,7 +162,7 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, progressChan <- DownloadStatus{ Max: totalSize, Cur: totalDownloaded, - Msg: fmt.Sprintf("Finished downloading %s", filename), + Msg: "Finished Downloading", } } diff --git a/internal/mixer/mixer.go b/internal/mixer/mixer.go index 995dc0c..63932d2 100644 --- a/internal/mixer/mixer.go +++ b/internal/mixer/mixer.go @@ -70,7 +70,7 @@ func Init(cfg *db.AppConfig, onUpdate func()) error { return err } - SetAllSinkVolumes(1.0) + SetAllSinkVolumes(0.3) inited = true return nil diff --git a/internal/page/home.go b/internal/page/home.go index 4f03722..8b128bc 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -2,8 +2,6 @@ package page import ( "fmt" - "path/filepath" - "time" "github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/mathgl/mgl32" @@ -30,8 +28,8 @@ type Home struct { emailDisplay StringItem licenseKeyDisplay StringItem hasLicenseInfo bool - lastDisplayUpdate time.Time - baseFontSize int // cached from db at init + baseFontSize int + isDownloadComplete bool } // Init performs basic page initialization and creation of static assets. @@ -82,6 +80,12 @@ func (p *Home) Init(ctx *PageCtx) error { // Create download channel and message p.downloadProgressChan = make(chan download.DownloadStatus) + // Initialize message + p.message, err = NewStringItem("Status: Initializing...", font, colors.White) + if err != nil { + return fmt.Errorf("unable to create initial message texture: %w", err) + } + // Create license info textures if data exists if p.Context.CfgPtr.EmailAddress != "" || p.Context.CfgPtr.LicenseKey != "" { p.hasLicenseInfo = true @@ -113,44 +117,33 @@ func (p *Home) Init(ctx *PageCtx) error { // Get base font size from database baseFontSize := p.Context.CfgPtr.StandardFontSize - p.baseFontSize = baseFontSize // Scale the note text fontSize := int32(32) scale := float32(fontSize) / float32(baseFontSize) - p.note.Scale(scale * 0.75) + p.note.W = int32(float32(p.note.W) * scale * 0.75) + p.note.H = int32(float32(p.note.H) * scale * 0.75) // Scale license info texts if p.hasLicenseInfo { licenseInfoScale := float32(28) / float32(baseFontSize) - p.licenseInfoHeading.Scale(licenseInfoScale) + p.licenseInfoHeading.W = int32(float32(p.licenseInfoHeading.W) * licenseInfoScale) + p.licenseInfoHeading.H = int32(float32(p.licenseInfoHeading.H) * licenseInfoScale) if p.emailDisplay.ID != 0 { - p.emailDisplay.Scale(licenseInfoScale * 0.85) + p.emailDisplay.W = int32(float32(p.emailDisplay.W) * licenseInfoScale * 0.85) + p.emailDisplay.H = int32(float32(p.emailDisplay.H) * licenseInfoScale * 0.85) } if p.licenseKeyDisplay.ID != 0 { - p.licenseKeyDisplay.Scale(licenseInfoScale * 0.85) + p.licenseKeyDisplay.W = int32(float32(p.licenseKeyDisplay.W) * licenseInfoScale * 0.85) + p.licenseKeyDisplay.H = int32(float32(p.licenseKeyDisplay.H) * licenseInfoScale * 0.85) } } - 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", - } - - go download.DownloadAudioFiles(p.downloadProgressChan, audioFileNames, filepath.Join(ctx.CfgPtr.AssetsDir, "audio")) + // Scale message text + messageScale := float32(28) / float32(baseFontSize) + p.message.W = int32(float32(p.message.W) * messageScale) + p.message.H = int32(float32(p.message.H) * messageScale) return nil } @@ -162,20 +155,44 @@ func (p *Home) HandleEvent(event *sdl.Event) error { // Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32. func (p *Home) Update(dt float32) error { - var err error - select { - case progressUpdate := <-p.downloadProgressChan: - if progressUpdate.Err != nil { - p.message, err = NewStringItem(progressUpdate.Msg, p.font, colors.White) + case progressUpdate, ok := <-p.Context.MessageChannel: + if !ok { + // Channel is closed, set message to READY + newMsg, err := NewStringItem("Status: READY", p.font, colors.Green) if err != nil { return err } + messageScale := float32(28) / float32(p.baseFontSize) + newMsg.W = int32(float32(newMsg.W) * messageScale) + newMsg.H = int32(float32(newMsg.H) * messageScale) + p.message = newMsg + p.isDownloadComplete = true + } else if progressUpdate.Err != nil { + // Handle error + newMsg, err := NewStringItem(fmt.Sprintf("Status: %s", progressUpdate.Err.Error), p.font, colors.Red) + if err != nil { + return err + } + messageScale := float32(28) / float32(p.baseFontSize) + newMsg.W = int32(float32(newMsg.W) * messageScale) + newMsg.H = int32(float32(newMsg.H) * messageScale) + p.message = newMsg } else { - p.message, err = NewStringItem(progressUpdate.Err.Error(), p.font, colors.Red) + // Update progress message + percentComplete := float64(progressUpdate.Cur) / float64(progressUpdate.Max) * 100 + msg := fmt.Sprintf("Status: Downloading assets %.1f%% Complete", percentComplete) + newMsg, err := NewStringItem(msg, p.font, colors.White) + if err != nil { + return err + } + messageScale := float32(28) / float32(p.baseFontSize) + newMsg.W = int32(float32(newMsg.W) * messageScale) + newMsg.H = int32(float32(newMsg.H) * messageScale) + p.message = newMsg } default: - return nil + // No update, do nothing } return nil } @@ -184,6 +201,7 @@ func (p *Home) Render() error { baseFontSize := p.baseFontSize sm := shaderManager.Get() verticalMargin := int32(20) + licenseInfoMargin := int32(10) // 1) Calculate scales titleFontSize := int32(64) @@ -205,9 +223,26 @@ func (p *Home) Render() error { promptH := float32(p.prompt.H) * bodyScale promptDim := mgl32.Vec2{promptW, promptH} - // p.note already scaled in Init via p.note.Scale(scale*0.75) - noteDim := p.note.RawDimensions() - noteW, noteH := noteDim[0], noteDim[1] + // Note dimensions (already scaled in Init) + noteW := float32(p.note.W) + noteH := float32(p.note.H) + + // Message dimensions (already scaled in Init/Update) + messageW := float32(p.message.W) + messageH := float32(p.message.H) + + // License info dimensions (already scaled in Init) + licenseHeadingW := float32(p.licenseInfoHeading.W) + licenseHeadingH := float32(p.licenseInfoHeading.H) + var emailDisplayW, emailDisplayH, licenseKeyDisplayW, licenseKeyDisplayH float32 + if p.emailDisplay.ID != 0 { + emailDisplayW = float32(p.emailDisplay.W) + emailDisplayH = float32(p.emailDisplay.H) + } + if p.licenseKeyDisplay.ID != 0 { + licenseKeyDisplayW = float32(p.licenseKeyDisplay.W) + licenseKeyDisplayH = float32(p.licenseKeyDisplay.H) + } // 3) Calculate total height of the vertical stack totalH := int32(titleH) + verticalMargin + @@ -218,7 +253,7 @@ func (p *Home) Render() error { // 4) Y-up layout: start from the TOP of the centered stack and step DOWN top := p.Base.ScreenCenterY + totalH/2 - titleY := top - int32(titleH) // Title sits at the top + titleY := top - int32(titleH) versionY := titleY - verticalMargin - int32(versionH) p.prompt.Y = versionY - verticalMargin - int32(promptH) p.note.Y = p.prompt.Y - verticalMargin - int32(noteH) @@ -234,23 +269,8 @@ func (p *Home) Render() error { // 6) Position license info at bottom center if p.hasLicenseInfo { - licenseInfoMargin := int32(10) bottomMargin := int32(20) - // License info items are already scaled in Init - licenseHeadingDim := p.licenseInfoHeading.RawDimensions() - licenseHeadingW, licenseHeadingH := licenseHeadingDim[0], licenseHeadingDim[1] - - var emailDisplayH, licenseKeyDisplayH float32 - if p.emailDisplay.ID != 0 { - emailDisplayDim := p.emailDisplay.RawDimensions() - emailDisplayH = emailDisplayDim[1] - } - if p.licenseKeyDisplay.ID != 0 { - licenseKeyDisplayDim := p.licenseKeyDisplay.RawDimensions() - licenseKeyDisplayH = licenseKeyDisplayDim[1] - } - // Calculate total height of license info block licenseInfoTotalH := int32(licenseHeadingH) if p.emailDisplay.ID != 0 { @@ -265,8 +285,6 @@ func (p *Home) Render() error { // License key (bottom-most if it exists) if p.licenseKeyDisplay.ID != 0 { - licenseKeyDisplayDim := p.licenseKeyDisplay.RawDimensions() - licenseKeyDisplayW := licenseKeyDisplayDim[0] p.licenseKeyDisplay.X = p.Base.ScreenCenterX - int32(licenseKeyDisplayW/2) p.licenseKeyDisplay.Y = currentY currentY += int32(licenseKeyDisplayH) + licenseInfoMargin @@ -274,8 +292,6 @@ func (p *Home) Render() error { // Email if p.emailDisplay.ID != 0 { - emailDisplayDim := p.emailDisplay.RawDimensions() - emailDisplayW := emailDisplayDim[0] p.emailDisplay.X = p.Base.ScreenCenterX - int32(emailDisplayW/2) p.emailDisplay.Y = currentY currentY += int32(emailDisplayH) + licenseInfoMargin @@ -286,6 +302,26 @@ func (p *Home) Render() error { p.licenseInfoHeading.Y = currentY } + // 7) Position message between license info and note/prompt + if p.hasLicenseInfo { + // Calculate the top of the license info block + licenseInfoTopY := p.licenseInfoHeading.Y + int32(licenseHeadingH) + if p.emailDisplay.ID != 0 { + licenseInfoTopY += int32(emailDisplayH) + licenseInfoMargin + } + if p.licenseKeyDisplay.ID != 0 { + licenseInfoTopY += int32(licenseKeyDisplayH) + licenseInfoMargin + } + + // Center the message vertically between license info and note + availableSpace := p.note.Y - licenseInfoTopY - verticalMargin + p.message.Y = licenseInfoTopY + availableSpace/2 - int32(messageH/2) + } else { + // If no license info, place message below note + p.message.Y = p.note.Y - verticalMargin - int32(messageH) + } + p.message.X = p.Base.ScreenCenterX - int32(messageW/2) + // 8) Get shader and render textShader, ok := sm.Get("text") if !ok { @@ -295,17 +331,26 @@ func (p *Home) Render() error { p.Base.RenderTexture(textShader, p.title.ID, titlePos, titleDim, color) p.Base.RenderTexture(textShader, p.version.ID, versionPos, versionDim, color) - p.Base.RenderTexture(textShader, p.prompt.ID, p.prompt.Position(), promptDim, color) - p.Base.RenderTexture(textShader, p.note.ID, p.note.Position(), p.note.RawDimensions(), color) + p.Base.RenderTexture(textShader, p.prompt.ID, mgl32.Vec2{float32(p.prompt.X), float32(p.prompt.Y)}, promptDim, color) + p.Base.RenderTexture(textShader, p.note.ID, mgl32.Vec2{float32(p.note.X), float32(p.note.Y)}, mgl32.Vec2{noteW, noteH}, color) + + // Render message + if p.message.ID != 0 { + msgColor := colors.White + if p.isDownloadComplete { + msgColor = colors.Green + } + p.Base.RenderTexture(textShader, p.message.ID, mgl32.Vec2{float32(p.message.X), float32(p.message.Y)}, mgl32.Vec2{messageW, messageH}, msgColor) + } // Render license info if it exists if p.hasLicenseInfo { - p.Base.RenderTexture(textShader, p.licenseInfoHeading.ID, p.licenseInfoHeading.Position(), p.licenseInfoHeading.RawDimensions(), color) + p.Base.RenderTexture(textShader, p.licenseInfoHeading.ID, mgl32.Vec2{float32(p.licenseInfoHeading.X), float32(p.licenseInfoHeading.Y)}, mgl32.Vec2{licenseHeadingW, licenseHeadingH}, color) if p.emailDisplay.ID != 0 { - p.Base.RenderTexture(textShader, p.emailDisplay.ID, p.emailDisplay.Position(), p.emailDisplay.RawDimensions(), color) + p.Base.RenderTexture(textShader, p.emailDisplay.ID, mgl32.Vec2{float32(p.emailDisplay.X), float32(p.emailDisplay.Y)}, mgl32.Vec2{emailDisplayW, emailDisplayH}, color) } if p.licenseKeyDisplay.ID != 0 { - p.Base.RenderTexture(textShader, p.licenseKeyDisplay.ID, p.licenseKeyDisplay.Position(), p.licenseKeyDisplay.RawDimensions(), color) + p.Base.RenderTexture(textShader, p.licenseKeyDisplay.ID, mgl32.Vec2{float32(p.licenseKeyDisplay.X), float32(p.licenseKeyDisplay.Y)}, mgl32.Vec2{licenseKeyDisplayW, licenseKeyDisplayH}, color) } } @@ -318,6 +363,7 @@ func (p *Home) Destroy() error { gl.DeleteTextures(1, &p.version.ID) gl.DeleteTextures(1, &p.prompt.ID) gl.DeleteTextures(1, &p.note.ID) + gl.DeleteTextures(1, &p.message.ID) if p.hasLicenseInfo { gl.DeleteTextures(1, &p.licenseInfoHeading.ID) diff --git a/internal/page/router.go b/internal/page/router.go index 24c5ebd..eb8dda8 100644 --- a/internal/page/router.go +++ b/internal/page/router.go @@ -3,6 +3,7 @@ package page import ( "gorm.io/gorm" "rhemawavetech.com/rhemawave/internal/db" + "rhemawavetech.com/rhemawave/internal/download" ) type pageType int @@ -22,13 +23,14 @@ const ( ) type PageCtx struct { - CfgPtr *db.AppConfig - DbPtr *gorm.DB - W, H int32 - RefreshConfig func() - NavigateTo func(pageType) - Msg string // Message to be displayed by MessagePage - Inst string // Instructions to be displayed on MessagePage + CfgPtr *db.AppConfig + DbPtr *gorm.DB + W, H int32 + MessageChannel chan download.DownloadStatus + RefreshConfig func() + NavigateTo func(pageType) + Msg string // Message to be displayed by MessagePage + Inst string // Instructions to be displayed on MessagePage } type Router struct { From ac86864c7286bf2a887c9976c313dcd9dd1225c7 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 22:43:47 -0500 Subject: [PATCH 15/18] Add downloading message to home screen. Download files in the background and keep track of progress. --- internal/application/application.go | 3 +- internal/download/Download.go | 9 +-- internal/page/home.go | 108 ++++++++++++++++++---------- 3 files changed, 78 insertions(+), 42 deletions(-) diff --git a/internal/application/application.go b/internal/application/application.go index 52e0475..cee6e72 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -154,7 +154,8 @@ func Run() { app.initializeManagers() app.initializeAudio() - downloadProgressChan := make(chan download.DownloadStatus) + // Create a buffered channel so log writes don't slow down downloads. + downloadProgressChan := make(chan download.DownloadStatus, 1000) app.PageRouter = page.NewRouter(page.PageCtx{ CfgPtr: app.Config.Load(), diff --git a/internal/download/Download.go b/internal/download/Download.go index 6a50944..5e704c6 100644 --- a/internal/download/Download.go +++ b/internal/download/Download.go @@ -30,6 +30,7 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, // First, calculate the total size of files that need to be downloaded totalSize := int64(0) filesToDownload := []string{} + totalDownloaded := int64(0) for _, filename := range filenames { destPath := filepath.Join(destDir, filename) @@ -62,13 +63,15 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, // Append it to the list to download filesToDownload = append(filesToDownload, filename) - // And increase the total bytes to download - totalSize += resp.ContentLength } else if err != nil { logger.Error("unknown error: %v", err) } else { logger.Error("File %s exists, skipping...", filename) + totalDownloaded += fileStat.Size() } + // Calculate the total of ALL files to give the illusion of + // "picking up where you left off" to the user + totalSize += resp.ContentLength resp.Body.Close() } @@ -79,8 +82,6 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, Msg: "Starting download...", } - totalDownloaded := int64(0) - // Download each file that doesn't exist for _, filename := range filesToDownload { url := urlBase + filename diff --git a/internal/page/home.go b/internal/page/home.go index 8b128bc..131d1b3 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -153,47 +153,81 @@ func (p *Home) HandleEvent(event *sdl.Event) error { return nil } -// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32. func (p *Home) Update(dt float32) error { - select { - case progressUpdate, ok := <-p.Context.MessageChannel: - if !ok { - // Channel is closed, set message to READY - newMsg, err := NewStringItem("Status: READY", p.font, colors.Green) - if err != nil { - return err + var ( + progressUpdate download.DownloadStatus + ok bool + gotUpdate bool + ) + + // Drain the channel, keeping only the newest update. + for { + select { + case progressUpdate, ok = <-p.Context.MessageChannel: + gotUpdate = true + + if !ok { + // Channel was closed while draining. + newMsg, err := NewStringItem("Status: READY", p.font, colors.Green) + if err != nil { + return err + } + messageScale := float32(28) / float32(p.baseFontSize) + newMsg.W = int32(float32(newMsg.W) * messageScale) + newMsg.H = int32(float32(newMsg.H) * messageScale) + p.message = newMsg + p.isDownloadComplete = true + + return nil } - messageScale := float32(28) / float32(p.baseFontSize) - newMsg.W = int32(float32(newMsg.W) * messageScale) - newMsg.H = int32(float32(newMsg.H) * messageScale) - p.message = newMsg - p.isDownloadComplete = true - } else if progressUpdate.Err != nil { - // Handle error - newMsg, err := NewStringItem(fmt.Sprintf("Status: %s", progressUpdate.Err.Error), p.font, colors.Red) - if err != nil { - return err - } - messageScale := float32(28) / float32(p.baseFontSize) - newMsg.W = int32(float32(newMsg.W) * messageScale) - newMsg.H = int32(float32(newMsg.H) * messageScale) - p.message = newMsg - } else { - // Update progress message - percentComplete := float64(progressUpdate.Cur) / float64(progressUpdate.Max) * 100 - msg := fmt.Sprintf("Status: Downloading assets %.1f%% Complete", percentComplete) - newMsg, err := NewStringItem(msg, p.font, colors.White) - if err != nil { - return err - } - messageScale := float32(28) / float32(p.baseFontSize) - newMsg.W = int32(float32(newMsg.W) * messageScale) - newMsg.H = int32(float32(newMsg.H) * messageScale) - p.message = newMsg + + default: + goto drained } - default: - // No update, do nothing } + +drained: + + if !gotUpdate { + return nil + } + + if progressUpdate.Err != nil { + // Handle error + newMsg, err := NewStringItem( + fmt.Sprintf("Status: %s", progressUpdate.Err.Error()), + p.font, + colors.Red, + ) + if err != nil { + return err + } + + messageScale := float32(28) / float32(p.baseFontSize) + newMsg.W = int32(float32(newMsg.W) * messageScale) + newMsg.H = int32(float32(newMsg.H) * messageScale) + p.message = newMsg + + } else { + // Update progress message using ONLY the latest update. + percentComplete := float64(progressUpdate.Cur) / float64(progressUpdate.Max) * 100 + + msg := fmt.Sprintf( + "Status: Downloading assets %.1f%% Complete", + percentComplete, + ) + + newMsg, err := NewStringItem(msg, p.font, colors.White) + if err != nil { + return err + } + + messageScale := float32(28) / float32(p.baseFontSize) + newMsg.W = int32(float32(newMsg.W) * messageScale) + newMsg.H = int32(float32(newMsg.H) * messageScale) + p.message = newMsg + } + return nil } From 1c814f4abb6b5ed94149e9d201f9b47bc55c1484 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 22:51:17 -0500 Subject: [PATCH 16/18] Reset default volume to MAX --- internal/mixer/mixer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mixer/mixer.go b/internal/mixer/mixer.go index 63932d2..995dc0c 100644 --- a/internal/mixer/mixer.go +++ b/internal/mixer/mixer.go @@ -70,7 +70,7 @@ func Init(cfg *db.AppConfig, onUpdate func()) error { return err } - SetAllSinkVolumes(0.3) + SetAllSinkVolumes(1.0) inited = true return nil From 9ee7e8021afedd87118d0c53e75e54851a3ae305 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 18 Jun 2026 23:01:11 -0500 Subject: [PATCH 17/18] Remove track list from AudioOptions --- internal/page/AudioOptions.go | 375 +++------------------------------- 1 file changed, 25 insertions(+), 350 deletions(-) diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go index e6c218c..82acc6e 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -1,16 +1,12 @@ package page import ( - "errors" "fmt" "math" - "path/filepath" - "strings" "github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/mathgl/mgl32" "github.com/veandco/go-sdl2/sdl" - "gorm.io/gorm" "rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/fontManager" @@ -19,6 +15,9 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) +// AudioFileList is kept as the canonical seed data for the audio_files table. +// Track selection UI no longer lives on this page, but other parts of the +// app still read these rows from the database. 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}, @@ -36,13 +35,7 @@ var AudioFileList = []db.AudioFile{ {ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false, Order: 14}, } -const ( - triangleSize = float32(16) - visibleCount = 9 - listShiftDown = int32(50) - scrollbarWidth = int32(4) - scrollbarGap = int32(16) // gap between right edge of list and scrollbar -) +const triangleSize = float32(16) type AudioOptions struct { Base @@ -58,7 +51,7 @@ type AudioOptions struct { // Audio toggle AudioStatusToggle StringItem - // Volume row (excluded from focus) + // Volume row VolumeLabel StringItem volumePercent StringItem volumeBarX int32 @@ -66,33 +59,6 @@ type AudioOptions struct { volumeBarW int32 volumeBarH int32 - // Separator + track section - separatorY int32 - tracksHeading StringItem - trackItems []StringItem - - // Selected track (-1 = none) - selectedTrack int - - // Navigation - // focusedIndex: 0 = AudioStatusToggle, 1..N = trackItems[focusedIndex-1] - // Volume is intentionally excluded from focus. - focusedIndex int - - // Scrolling window - windowStart int - listTopY int32 - listLeftX int32 - itemHeight int32 - trackSpacing int32 - - // Scrollbar geometry (computed once in layout, thumb Y updated on scroll) - scrollbarX int32 - scrollbarTrackY int32 // bottom of the scrollbar track - scrollbarTrackH int32 // total height of the track - scrollbarThumbH int32 // height of the thumb - scrollbarThumbY int32 // current bottom Y of the thumb (updated on scroll) - // Triangle cursor triVAO uint32 triVBO uint32 @@ -106,10 +72,6 @@ type AudioOptions struct { func (p *AudioOptions) Init(ctx *PageCtx) error { var err error p.verticalMargin = int32(20) - p.focusedIndex = 0 - p.selectedTrack = -1 - p.windowStart = 0 - p.trackSpacing = p.verticalMargin / 2 // Font fm := fontManager.Get() @@ -146,7 +108,7 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { p.title.Scale(titleScale) // Prompt - p.prompt, err = NewStringItem("Up/Down to navigate -- Enter to select -- Left/Right to change volume -- Backspace to go back", p.font, colors.White) + p.prompt, err = NewStringItem("Enter to toggle audio -- Left/Right to change volume -- Backspace to go back", p.font, colors.White) if err != nil { return fmt.Errorf("creating prompt: %w", err) } @@ -167,17 +129,11 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") } - // "Available Tracks" heading - headingScale := float32(40) / float32(baseFontSize) - p.tracksHeading, err = NewStringItem("Available Tracks", p.font, colors.White) - if err != nil { - return fmt.Errorf("creating tracks heading: %w", err) - } - p.tracksHeading.Scale(headingScale) - - // Track items - if err = p.buildTrackItems(); err != nil { - return fmt.Errorf("building track items: %w", err) + // Make sure the audio_files table is populated. This page no longer + // presents any track selection UI, but other parts of the app still + // read from this table. + if err = p.seedAudioFiles(); err != nil { + logger.Error("failed to seed audio files: %v", err) } // Triangle cursor VAO/VBO — stride is vec2 only, no tex coords @@ -200,84 +156,18 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { return nil } -// ── Track items ─────────────────────────────────────────────────────────────── - -func (p *AudioOptions) buildTrackItems() error { - var audioFileList = []db.AudioFile{} - if err := p.Context.DbPtr.Find(&audioFileList).Error; err != nil { +// seedAudioFiles ensures the audio_files table has its default rows. +func (p *AudioOptions) seedAudioFiles() error { + var existing []db.AudioFile + if err := p.Context.DbPtr.Find(&existing).Error; err != nil { return err } - - if len(audioFileList) == 0 { - audioFileList = AudioFileList - p.Context.DbPtr.Save(&audioFileList) - } - - for i := range p.trackItems { - if p.trackItems[i].ID != 0 { - gl.DeleteTextures(1, &p.trackItems[i].ID) - } - } - p.trackItems = make([]StringItem, len(AudioFileList)) - - for i, entry := range AudioFileList { - color := colors.White - if i == p.selectedTrack { - color = colors.Green - } - item, err := NewStringItem(entry.Name, p.font, color) - if err != nil { - return fmt.Errorf("creating track item %d: %w", i, err) - } - item.Scale(p.ItemScale) - p.trackItems[i] = item + if len(existing) == 0 { + return p.Context.DbPtr.Save(&AudioFileList).Error } return nil } -// positionTrackItems places the visible window of items and parks the rest off-screen. -func (p *AudioOptions) positionTrackItems() { - peekCount := visibleCount + 1 - - for i := range p.trackItems { - slot := i - p.windowStart - - if slot < 0 || slot >= peekCount { - p.trackItems[i].X = p.listLeftX - p.trackItems[i].Y = -9999 - continue - } - - itemY := p.listTopY - int32(slot)*(p.itemHeight+p.trackSpacing) - p.trackItems[i].X = p.listLeftX - p.trackItems[i].Y = itemY - } -} - -// ── Scrollbar ───────────────────────────────────────────────────────────────── - -// updateScrollbarThumb recalculates the thumb Y position based on windowStart. -// Called whenever windowStart changes. -func (p *AudioOptions) updateScrollbarThumb() { - total := len(AudioFileList) - if total <= visibleCount { - // Entire list fits — thumb fills the track, nothing to scroll - p.scrollbarThumbY = p.scrollbarTrackY - return - } - - // How far through the scrollable range are we? - // maxWindowStart is the furthest windowStart can go - maxWindowStart := total - visibleCount - fraction := float32(p.windowStart) / float32(maxWindowStart) - - // Thumb travels from the top of the track down to (trackTop - thumbH) - // "top" in our Y-up projection means the larger Y value - trackTop := p.scrollbarTrackY + p.scrollbarTrackH - travelRange := p.scrollbarTrackH - p.scrollbarThumbH - p.scrollbarThumbY = trackTop - p.scrollbarThumbH - int32(fraction*float32(travelRange)) -} - // ── Audio status toggle ─────────────────────────────────────────────────────── func (p *AudioOptions) CreateAudioStatusItem() error { @@ -345,7 +235,7 @@ func (p *AudioOptions) layout() { p.prompt.X = p.ScreenCenterX - p.prompt.W/2 p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H - // Volume row (not focusable) + // Volume row barH := int32(float32(p.Context.CfgPtr.StandardFontSize) * p.ItemScale) barW := int32(float32(p.ScreenWidth) * 0.30) barX := p.ScreenCenterX - barW/2 @@ -363,100 +253,14 @@ func (p *AudioOptions) layout() { p.volumePercent.X = barX + barW + gap p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2 - // "Available Tracks" heading — centred, left edge becomes the list anchor - p.tracksHeading.X = p.ScreenCenterX - p.tracksHeading.W/2 + // Audio toggle — centred below the volume row + p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 + p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H - // Toggle — left-aligned to the heading - p.AudioStatusToggle.X = p.tracksHeading.X - p.AudioStatusToggle.Y = barY - p.verticalMargin*2 - p.AudioStatusToggle.H - - // Separator - p.separatorY = p.AudioStatusToggle.Y - p.verticalMargin*3 - - // Heading Y — below separator - p.tracksHeading.Y = p.separatorY - p.verticalMargin - p.tracksHeading.H - - // Track list geometry — shifted down by listShiftDown - if len(p.trackItems) > 0 { - p.itemHeight = p.trackItems[0].H - } - - p.listLeftX = p.tracksHeading.X - p.listTopY = p.tracksHeading.Y - p.verticalMargin - listShiftDown - - // ── Scrollbar geometry ──────────────────────────────────────────────────── - // The scrollbar track spans the full visible window height. - // It sits to the right of the list with a small gap. - windowH := int32(visibleCount)*(p.itemHeight+p.trackSpacing) - p.trackSpacing - scrollbarBottom := p.listTopY - windowH - p.scrollbarTrackH = windowH - p.scrollbarTrackY = scrollbarBottom // bottom Y of the track (Y-up coords) - - // Right edge of the list: use the widest track name as an approximation. - // Since items are left-aligned and widths vary, we use a fixed width based - // on the heading width which is a reasonable proxy for the list column width. - listRightX := p.listLeftX + p.tracksHeading.W - p.scrollbarX = listRightX + scrollbarGap - - // Thumb height is proportional: visibleCount / total items - total := len(AudioFileList) - if total > 0 { - thumbFraction := float32(visibleCount) / float32(total) - p.scrollbarThumbH = int32(thumbFraction * float32(p.scrollbarTrackH)) - if p.scrollbarThumbH < 8 { - p.scrollbarThumbH = 8 // minimum thumb size so it's always clickable - } - } - - // Triangle cursor X — just outside the left edge of the list + // Triangle cursor — just outside the left edge of the toggle, vertically centred on it cursorGap := int32(12) - p.triX = float32(p.listLeftX) - float32(triangleSize) - float32(cursorGap) - - p.positionTrackItems() - p.updateScrollbarThumb() - p.repositionCursor() -} - -// repositionCursor vertically centres the triangle on the focused item. -func (p *AudioOptions) repositionCursor() { - var targetY, targetH int32 - - if p.focusedIndex == 0 { - targetY = p.AudioStatusToggle.Y - targetH = p.AudioStatusToggle.H - } else { - trackIdx := p.focusedIndex - 1 - slot := trackIdx - p.windowStart - targetY = p.listTopY - int32(slot)*(p.itemHeight+p.trackSpacing) - targetH = p.itemHeight - } - - p.triCenterY = float32(targetY) + float32(targetH)/2 -} - -// ── Scroll ──────────────────────────────────────────────────────────────────── - -func (p *AudioOptions) scrollToFocus() { - if p.focusedIndex == 0 { - p.windowStart = 0 - p.positionTrackItems() - p.updateScrollbarThumb() - p.repositionCursor() - return - } - - trackIdx := p.focusedIndex - 1 - - if trackIdx >= p.windowStart+visibleCount { - p.windowStart = trackIdx - visibleCount + 1 - } - if trackIdx < p.windowStart { - p.windowStart = trackIdx - } - - p.positionTrackItems() - p.updateScrollbarThumb() - p.repositionCursor() + p.triX = float32(p.AudioStatusToggle.X) - float32(triangleSize) - float32(cursorGap) + p.triCenterY = float32(p.AudioStatusToggle.Y) + float32(p.AudioStatusToggle.H)/2 } // ── Toggle ──────────────────────────────────────────────────────────────────── @@ -469,46 +273,6 @@ func (p *AudioOptions) ToggleAudio() { p.layout() } -// ── Track selection ─────────────────────────────────────────────────────────── - -func (p *AudioOptions) onTrackSelected(index int, entry db.AudioFile) { - p.selectedTrack = index - if err := p.buildTrackItems(); err != nil { - logger.Error("onTrackSelected: failed to rebuild track items: %v", err) - return - } - p.layout() - - // Create file names and paths - // Set newly selected entry as Selected - entry.Selected = true - // Set path - if !strings.Contains(entry.Path, ".local/") { - entry.Path = filepath.Join(p.Context.CfgPtr.AssetsDir, "audio/", entry.Path) - } - // Clear previously selected. - var previouslySelectedAudio db.AudioFile - if err := p.Context.DbPtr.Where("Selected = ?", true).First(&previouslySelectedAudio).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - logger.Info("No audio previously selected") - } else { - logger.Error("Unable to fetch previously selected audio from database %s", err) - } - } else { - previouslySelectedAudio.Selected = false - if err = p.Context.DbPtr.Save(&previouslySelectedAudio).Error; err != nil { - logger.Error("Unable to update selected audio: %v", err) - return - } - } - // Save it to the database - if err := p.Context.DbPtr.Save(&entry).Error; err != nil { - logger.Error("Error saving to database: %v", err) - return - } - logger.Info("Updated audio selection: %s", entry.Name) -} - // ── Events ──────────────────────────────────────────────────────────────────── func (p *AudioOptions) HandleEvent(event *sdl.Event) error { @@ -518,25 +282,8 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error { } switch e.Keysym.Sym { - case sdl.K_UP: - if p.focusedIndex > 0 { - p.focusedIndex-- - p.scrollToFocus() - } - - case sdl.K_DOWN: - if p.focusedIndex < len(AudioFileList) { - p.focusedIndex++ - p.scrollToFocus() - } - case sdl.K_RETURN: - if p.focusedIndex == 0 { - p.ToggleAudio() - } else { - trackIdx := p.focusedIndex - 1 - p.onTrackSelected(trackIdx, AudioFileList[trackIdx]) - } + p.ToggleAudio() case sdl.K_RIGHT: p.UpdateVolume(10) @@ -563,17 +310,6 @@ func (p *AudioOptions) Update(dt float32) error { // ── Render helpers ──────────────────────────────────────────────────────────── -func (p *AudioOptions) renderSeparator() error { - lineW := float32(p.ScreenWidth) * 0.80 - lineX := float32(p.ScreenCenterX) - lineW/2 - return p.RenderSolidColorQuad( - p.solidShader, - mgl32.Vec2{lineX, float32(p.separatorY)}, - mgl32.Vec2{lineW, 2}, - colors.White, - ) -} - func (p *AudioOptions) renderVolumeBar() error { vol := p.Context.CfgPtr.SavedVolume @@ -609,37 +345,6 @@ func (p *AudioOptions) renderVolumeBar() error { return nil } -// renderScrollbar draws the scrollbar track (dim) and thumb (white). -// Only rendered when the list is longer than the visible window. -func (p *AudioOptions) renderScrollbar() error { - if len(AudioFileList) <= visibleCount { - return nil - } - - // Track — a thin dim line the full height of the window - trackColor := sdl.Color{R: 80, G: 80, B: 80, A: 255} - if err := p.RenderSolidColorQuad( - p.solidShader, - mgl32.Vec2{float32(p.scrollbarX), float32(p.scrollbarTrackY)}, - mgl32.Vec2{float32(scrollbarWidth), float32(p.scrollbarTrackH)}, - trackColor, - ); err != nil { - return fmt.Errorf("rendering scrollbar track: %w", err) - } - - // Thumb — white, positioned proportionally within the track - if err := p.RenderSolidColorQuad( - p.solidShader, - mgl32.Vec2{float32(p.scrollbarX), float32(p.scrollbarThumbY)}, - mgl32.Vec2{float32(scrollbarWidth), float32(p.scrollbarThumbH)}, - colors.White, - ); err != nil { - return fmt.Errorf("rendering scrollbar thumb: %w", err) - } - - return nil -} - // renderCursor draws the spinning triangle cursor. func (p *AudioOptions) renderCursor() error { scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed))) @@ -693,32 +398,6 @@ func (p *AudioOptions) Render() error { return fmt.Errorf("render audio toggle: %w", err) } - // Separator - if err := p.renderSeparator(); err != nil { - return fmt.Errorf("render separator: %w", err) - } - - // "Available Tracks" heading — fixed, never moves - if err := p.RenderTexture(p.textShader, p.tracksHeading.ID, p.tracksHeading.Position(), p.tracksHeading.RawDimensions(), p.tracksHeading.Color); err != nil { - return fmt.Errorf("render tracks heading: %w", err) - } - - // Track list - for i := range p.trackItems { - item := &p.trackItems[i] - if item.Y < 0 { - continue - } - if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color); err != nil { - return fmt.Errorf("render track item %d: %w", i, err) - } - } - - // Scrollbar - if err := p.renderScrollbar(); err != nil { - return fmt.Errorf("render scrollbar: %w", err) - } - // Spinning triangle cursor — always on top if err := p.renderCursor(); err != nil { return fmt.Errorf("render cursor: %w", err) @@ -735,10 +414,6 @@ func (p *AudioOptions) Destroy() error { gl.DeleteTextures(1, &p.VolumeLabel.ID) gl.DeleteTextures(1, &p.volumePercent.ID) gl.DeleteTextures(1, &p.AudioStatusToggle.ID) - gl.DeleteTextures(1, &p.tracksHeading.ID) - for i := range p.trackItems { - gl.DeleteTextures(1, &p.trackItems[i].ID) - } gl.DeleteBuffers(1, &p.triVBO) gl.DeleteVertexArrays(1, &p.triVAO) return nil From 5bd2b752676b6e997ab557a5f6792ff8eda8ac72 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 19 Jun 2026 00:11:48 -0500 Subject: [PATCH 18/18] Auto play audio based on solfeggio frequencies --- internal/page/scrollerPage.go | 151 ++++++++++++++++++++++++---------- 1 file changed, 109 insertions(+), 42 deletions(-) diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index 46ef508..6f128e5 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -3,6 +3,8 @@ package page import ( "fmt" "math" + "os" + "path/filepath" "strings" "github.com/go-gl/gl/v3.3-core/gl" @@ -29,6 +31,11 @@ type ScrollerPage struct { streams []proclamationStream streamIndex int + // currentAudioFreq is the solfeggio Hz value of whatever track is + // currently playing. 0 means "no specific frequency" (either the + // legacy Selected-AudioFile track, or nothing yet). + currentAudioFreq int + IsDownloadingAudio bool lines *LineDeque @@ -85,6 +92,11 @@ type proclamationStream struct { wordIndex int palette []sdl.Color // Color palette for this proclamation colorIndex int // Track which color to use next + + // frequencyValue is the solfeggio Hz value that should be audible while + // this stream plays. 0 means "no specific frequency" (the default + // proclamation) — entering this stream never changes the audio. + frequencyValue int } // LineDeque is a double-ended queue for Line values backed by a circular buffer. @@ -262,15 +274,25 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error { } } - var selectedAudio db.AudioFile - - if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil { - logger.Error("error querying database for selected audio: %v", err) + // Start audio for the first proclamation in the stream. Falls back to + // whatever track is marked Selected in the audio_files table if the + // first stream has no frequency (e.g. only the default proclamation is + // queued) or its matching file doesn't exist on disk yet. + started := false + if len(p.streams) > 0 { + started = p.switchAudioForFrequency(p.streams[0].frequencyValue) } - logger.Info("Playing %s", selectedAudio.Path) - if err := mixer.Play(selectedAudio.Path, true, p.Context.CfgPtr); err != nil { - logger.Error("Mixer returned an error: %v", err) + if !started { + var selectedAudio db.AudioFile + if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil { + logger.Error("error querying database for selected audio: %v", err) + } + + logger.Info("Playing %s", selectedAudio.Path) + if err := mixer.Play(selectedAudio.Path, true, p.Context.CfgPtr); err != nil { + logger.Error("Mixer returned an error: %v", err) + } } currentDevice := mixer.CurrentDevice() @@ -350,20 +372,22 @@ func (p *ScrollerPage) loadProclamations() error { } p.streams = append(p.streams, proclamationStream{ - name: proclamation.Name, - words: words, - repeat: proclamationPlayCount, - palette: palette, - colorIndex: 0, + name: proclamation.Name, + words: words, + repeat: proclamationPlayCount, + palette: palette, + colorIndex: 0, + frequencyValue: freqValue, }) if len(defaultWords) > 0 { p.streams = append(p.streams, proclamationStream{ - name: defaultProclamation.Name, - words: defaultWords, - repeat: 1, - palette: colors.Freq_528_Colors[:], // Default palette for default proclamation - colorIndex: 0, + name: defaultProclamation.Name, + words: defaultWords, + repeat: 1, + palette: colors.Freq_528_Colors[:], // Default palette for default proclamation + colorIndex: 0, + frequencyValue: 0, // default proclamation never changes audio }) } } @@ -371,11 +395,12 @@ func (p *ScrollerPage) loadProclamations() error { // Fallback: if no non-default selected proclamations, at least play default. if len(p.streams) == 0 && len(defaultWords) > 0 { p.streams = append(p.streams, proclamationStream{ - name: defaultProclamation.Name, - words: defaultWords, - repeat: 1, - palette: colors.Freq_528_Colors[:], - colorIndex: 0, + name: defaultProclamation.Name, + words: defaultWords, + repeat: 1, + palette: colors.Freq_528_Colors[:], + colorIndex: 0, + frequencyValue: 0, }) } @@ -387,6 +412,48 @@ 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 { + return filepath.Join(p.Context.CfgPtr.AssetsDir, "audio", fmt.Sprintf("%dHz.wav", freq)) +} + +// switchAudioForFrequency swaps the currently playing track to match freq. +// +// - 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). +func (p *ScrollerPage) switchAudioForFrequency(freq int) bool { + if freq == 0 { + return false + } + if freq == p.currentAudioFreq { + return true + } + + path := p.audioPathForFrequency(freq) + if _, err := os.Stat(path); err != nil { + logger.Warning("No audio file for %dHz (%s), keeping current track", freq, path) + return false + } + + mixer.Stop(3000, p.Context.CfgPtr) + if err := mixer.Play(path, true, p.Context.CfgPtr); err != nil { + logger.Error("Mixer returned an error: %v", err) + return false + } + p.currentAudioFreq = freq + return true +} + // nextLineWords gets words for the current line and advances proclamation stream when needed. func (p *ScrollerPage) nextLineWords() []string { if len(p.streams) == 0 { @@ -409,13 +476,13 @@ func (p *ScrollerPage) nextLineWords() []string { if targetRepeats <= 0 { targetRepeats = 1 } - logger.Info("Now playing proclamation: %s", s.name) if s.completed >= targetRepeats { s.completed = 0 s.wordIndex = 0 s.colorIndex = 0 // Reset color index when proclamation restarts p.streamIndex = (p.streamIndex + 1) % len(p.streams) - fmt.Println("Now playing proclamation:", p.streams[p.streamIndex].name) + next := &p.streams[p.streamIndex] + p.switchAudioForFrequency(next.frequencyValue) } } @@ -523,23 +590,23 @@ func (p *ScrollerPage) createLine(index int, isInitial bool) error { } func (p *ScrollerPage) HandleEvent(event *sdl.Event) error { - switch e := (*event).(type) { - case *sdl.KeyboardEvent: - if e.Type != sdl.KEYDOWN { - return nil - } - switch e.Keysym.Sym { - case sdl.K_UP: - if !p.autoVelocity { - p.velocity += p.increment - } - case sdl.K_DOWN: - if !p.autoVelocity { - p.velocity -= p.increment - } - } - return nil - } + // switch e := (*event).(type) { + // case *sdl.KeyboardEvent: + // if e.Type != sdl.KEYDOWN { + // return nil + // } + // switch e.Keysym.Sym { + // case sdl.K_UP: + // if !p.autoVelocity { + // p.velocity += p.increment + // } + // case sdl.K_DOWN: + // if !p.autoVelocity { + // p.velocity -= p.increment + // } + // } + // return nil + // } return nil } @@ -650,7 +717,7 @@ func (p *ScrollerPage) Destroy() error { return nil } - mixer.Stop(1000, p.Context.CfgPtr) + mixer.Stop(3000, p.Context.CfgPtr) // Delete each cached word texture exactly once. for k, item := range p.wordTextureCache {