Files
radiantwave-legacy/internal/page/scrollerPage.go
T
NeilClack 2735af5fbb refactor: remove cached AppConfig pointer and read configuration directly from DB
Replace in-memory atomic AppConfig caching with direct DB queries via db.GetConfig
across application and page layers. This simplifies state management and ensures
all components always operate on the latest persisted configuration.

Key changes:
- Remove Application.Config atomic cache and refreshCache mechanism
- Eliminate PageCtx.CfgPtr and pass DB handle instead
- Replace all cfg reads with db.GetConfig(app.DB)
- Update mixer, audio, UI pages, and initialization paths accordingly
- Explicitly set AppConfig table name to avoid GORM pluralization issues
- Reduce DB connection pooling (SetMaxIdleConns(1))
- Standardize application font to SpaceMonoNerdFont-Regular
- Lower default audio volume (1.0 → 0.2)
- Add Unicode checkmark glyph to font map

Behavioral impact:
- Configuration is no longer cached in memory
- Every config access now hits the DB (more consistent, slightly more overhead)
2026-06-26 22:51:32 -05:00

825 lines
21 KiB
Go

package page
import (
"fmt"
"math"
"os"
"path/filepath"
"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/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/mixer"
colorPattern "rhemawavetech.com/rhemawave/internal/pattern/color"
linePattern "rhemawavetech.com/rhemawave/internal/pattern/line"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
const (
defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d"
proclamationPlayCount int = 20
// defaultProclamationPlayCount int = 1
)
type ScrollerPage struct {
Base
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
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
}
type ScrollerProclamation struct {
db.Proclamation
colorPattern.ColorPattern
ColorPalette []sdl.Color
}
type Line struct {
words []StringItem
width int32
color sdl.Color // Color for the entire line
}
// proclamationStream tracks one proclamation and how many full loops it should play.
type proclamationStream struct {
name string
words []string
repeat int
completed int
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.
// It avoids O(n) prepend and prevents capacity leaks from head-of-slice removal.
type LineDeque struct {
buf []Line
head, tail int
count int
}
func NewLineDeque(cap int) *LineDeque {
if cap < 4 {
cap = 4
}
return &LineDeque{buf: make([]Line, cap)}
}
func (d *LineDeque) Len() int { return d.count }
func (d *LineDeque) grow() {
newCap := len(d.buf) * 2
newBuf := make([]Line, newCap)
for i := 0; i < d.count; i++ {
newBuf[i] = d.buf[(d.head+i)%len(d.buf)]
}
d.buf = newBuf
d.head = 0
d.tail = d.count
}
func (d *LineDeque) PushBack(l Line) {
if d.count == len(d.buf) {
d.grow()
}
d.buf[d.tail] = l
d.tail = (d.tail + 1) % len(d.buf)
d.count++
}
func (d *LineDeque) PushFront(l Line) {
if d.count == len(d.buf) {
d.grow()
}
d.head = (d.head - 1 + len(d.buf)) % len(d.buf)
d.buf[d.head] = l
d.count++
}
func (d *LineDeque) PopFront() Line {
l := d.buf[d.head]
d.buf[d.head] = Line{} // clear for GC
d.head = (d.head + 1) % len(d.buf)
d.count--
return l
}
func (d *LineDeque) PopBack() Line {
d.tail = (d.tail - 1 + len(d.buf)) % len(d.buf)
l := d.buf[d.tail]
d.buf[d.tail] = Line{} // clear for GC
d.count--
return l
}
func (d *LineDeque) Front() Line { return d.buf[d.head] }
func (d *LineDeque) Back() Line {
return d.buf[(d.tail-1+len(d.buf))%len(d.buf)]
}
func (d *LineDeque) Get(i int) *Line {
return &d.buf[(d.head+i)%len(d.buf)]
}
func (d *LineDeque) Set(i int, l Line) {
d.buf[(d.head+i)%len(d.buf)] = l
}
func (d *LineDeque) Clear() {
for i := 0; i < d.count; i++ {
d.buf[(d.head+i)%len(d.buf)] = Line{}
}
d.head, d.tail, d.count = 0, 0, 0
}
func (p *ScrollerPage) Init(ctx *PageCtx) error {
p.increment = 100.0
p.velocity = 800.0
p.SetAutoVelocity(true, -4000.0, 4000.0, 100.0, 2.5, 6.0)
p.useColorPalettes = true // Default to enabled
sm := shaderManager.Get()
fm := fontManager.Get()
// Get font
font, ok := fm.GetFont("Scrambled")
if !ok {
return fmt.Errorf("unable to fetch font 'Scrambled'")
}
p.font = font
logger.Debug("Font name: %s", font.Name)
p.Base.Init(ctx)
// Get text shader
textShader, ok := sm.Get("text")
if !ok {
return fmt.Errorf("text shader not found")
}
p.shader = textShader
logger.Debug("Shader Program ID: %d", textShader.ProgramID)
logger.Debug("p.shader Program ID: %d", p.shader.ProgramID)
p.wordTextureCache = make(map[string]StringItem, 256)
// Load proclamation streams from database
if err := p.loadProclamations(); err != nil {
logger.Error("Error loading proclamations: %v", err)
p.Context.NavigateTo(PROCLAMATIONS)
return nil
}
// Get line pattern from database
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
linePatternName := config.LinePattern
p.activePattern, err = linePattern.Get(linePatternName)
if err != nil {
return fmt.Errorf("failed to load line pattern '%s': %w", linePatternName, err)
}
// Initialize color pattern
baseColors := []sdl.Color{
colors.Tan,
colors.PeachPuff,
colors.LightCyan,
colors.LightSeaGreen,
colors.MediumTurquoise,
colors.DarkCyan,
colors.MediumPurple,
}
p.colorPattern = colorPattern.NewColorPattern(baseColors)
// Font setup
p.fontScale = float32(32) / float32(config.StandardFontSize)
nLines := int(float32(p.ScreenHeight) / 32.0)
p.lines = NewLineDeque(nLines * 2)
// Calculate space width using the scale ratio.
spaceGlyph, ok := fm.GetGlyph(font.Name, ' ')
if !ok {
return fmt.Errorf("failed to get space glyph")
}
p.spaceWidth = int32(float32(spaceGlyph.Advance) * p.fontScale)
// Get line height using the same scale ratio.
tmpStrID, _, H, err := fm.NewCreateStringTexture(font.Name, "Mg", p.shader)
if err != nil {
return fmt.Errorf("failed to create sample texture: %w", err)
}
p.lineHeight = int32(float32(H) * p.fontScale)
gl.DeleteTextures(1, &tmpStrID)
p.lineCtx = &linePattern.LineRenderContext{
ScreenWidth: p.ScreenWidth,
Font: font,
FontScale: p.fontScale,
SpaceWidth: p.spaceWidth,
}
// Create initial lines
for i := range nLines {
if err := p.createLine(i, true); err != nil {
return fmt.Errorf("failed to create initial line %d: %w", i, 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)
}
if !started {
var selectedAudio db.AudioFile
if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil {
selectedAudio = db.AudioFile{
Name: "OriginalAudio",
Path: filepath.Join(config.AssetsDir, "audio", "OriginalAudio.wav"),
Selected: true,
Order: 0,
}
}
logger.Info("Playing %s", selectedAudio.Path)
if err := mixer.Play(selectedAudio.Path, true, config); err != nil {
logger.Error("Mixer returned an error: %v", err)
}
}
currentDevice := mixer.CurrentDevice()
if currentDevice == "" {
currentDevice = "default"
}
_ = currentDevice
return nil
}
// loadProclamations loads selected proclamations as stream units (not one giant word list).
func (p *ScrollerPage) loadProclamations() error {
p.streams = p.streams[:0]
// Load all solfeggio frequencies
var frequencies []db.SolfeggioFrequency
if err := p.Context.DbPtr.Find(&frequencies).Error; err != nil {
return fmt.Errorf("failed to load solfeggio frequencies: %v", err)
}
// Create a map of frequency ID to its value
frequencyMap := make(map[int]int)
for _, freq := range frequencies {
frequencyMap[freq.ID] = freq.Value
}
// Map frequency values to their color palettes
frequencyPalettes := map[int][]sdl.Color{
396: colors.Freq_396_Colors[:],
417: colors.Freq_417_Colors[:],
528: colors.Freq_528_Colors[:], // Default proclamation
639: colors.Freq_639_Colors[:],
741: colors.Freq_741_Colors[:],
852: colors.Freq_852_Colors[:],
963: colors.Freq_963_Colors[:],
}
// Load selected proclamations
var selected []db.Proclamation
if err := p.Context.DbPtr.Where("selected = ?", true).Find(&selected).Error; err != nil {
return err
}
// Load default proclamation
var defaultProclamation db.Proclamation
if err := p.Context.DbPtr.Where("ID = ?", defaultProclamationID).First(&defaultProclamation).Error; err != nil {
return err
}
defaultWords := strings.Fields(defaultProclamation.Content)
// Build interleaved stream:
// each selected proclamation repeats N times, then default repeats once.
for _, proclamation := range selected {
// Skip default proclamation if it's in the selected list
if proclamation.ID == defaultProclamationID {
continue
}
words := strings.Fields(proclamation.Content)
if len(words) == 0 {
continue
}
// Get the frequency value for this proclamation
freqValue, ok := frequencyMap[proclamation.SolfeggioFrequencyID]
if !ok {
logger.Warning("No frequency found for proclamation %s, using default palette", proclamation.Name)
freqValue = 528 // Default to 528 if frequency not found
}
// Get the palette for this frequency
palette, ok := frequencyPalettes[freqValue]
if !ok {
logger.Warning("No palette found for frequency %d, using default palette", freqValue)
palette = colors.Freq_528_Colors[:] // Fallback to default palette
}
p.streams = append(p.streams, proclamationStream{
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,
frequencyValue: 0, // default proclamation never changes audio
})
}
}
// 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,
frequencyValue: 0,
})
}
if len(p.streams) == 0 {
return fmt.Errorf("failed to load proclamations: no proclamation words available")
}
p.streamIndex = 0
return nil
}
// audioPathForFrequency builds the on-disk path for a solfeggio frequency's
// audio file, e.g. 396 -> "<AssetsDir>/audio/396Hz.wav".
func (p *ScrollerPage) audioPathForFrequency(freq int) string {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
return filepath.Join(config.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
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
mixer.Stop(3000, config)
if err := mixer.Play(path, true, config); 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 {
return nil
}
s := &p.streams[p.streamIndex]
if len(s.words) == 0 {
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
return p.nextLineWords()
}
prevIdx := s.wordIndex
words := p.activePattern.GetNextLine(s.words, &s.wordIndex, p.lineCtx)
// If word index wraps, one full proclamation pass completed.
if s.wordIndex < prevIdx {
s.completed++
targetRepeats := s.repeat
if targetRepeats <= 0 {
targetRepeats = 1
}
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)
next := &p.streams[p.streamIndex]
p.switchAudioForFrequency(next.frequencyValue)
}
}
return words
}
// getCachedWord returns a reusable textured StringItem for a word.
func (p *ScrollerPage) getCachedWord(word string) (StringItem, error) {
if cached, ok := p.wordTextureCache[word]; ok {
return cached, nil
}
base, err := NewStringItem(word, p.font, sdl.Color{R: 255, G: 255, B: 255, A: 255})
if err != nil {
return StringItem{}, fmt.Errorf("unable to create cached string item for word '%s': %w", word, err)
}
base.Scale(p.fontScale)
p.wordTextureCache[word] = base
return base, nil
}
// buildLineFromWords builds a line using cached texture IDs and per-instance color/position.
func (p *ScrollerPage) buildLineFromWords(words []string) (Line, error) {
line := Line{words: []StringItem{}}
// Get the current proclamation's color palette
var lineColor sdl.Color
if p.useColorPalettes && len(p.streams) > 0 {
currentStream := &p.streams[p.streamIndex]
if len(currentStream.palette) > 0 {
// Use the current color from the palette
lineColor = currentStream.palette[currentStream.colorIndex]
// Increment color index for the next line
currentStream.colorIndex = (currentStream.colorIndex + 1) % len(currentStream.palette)
} else {
// Fallback to default color if no palette is found
lineColor = p.colorPattern.Color()
}
} else {
// Use the default color pattern if useColorPalettes is false
lineColor = p.colorPattern.Color()
}
for _, word := range words {
if word == "" {
continue
}
cached, err := p.getCachedWord(word)
if err != nil {
return Line{}, err
}
item := cached
item.Color = lineColor // Assign the line color to all words in the line
if len(line.words) > 0 {
line.width += p.spaceWidth
}
line.words = append(line.words, item)
line.width += item.W
}
line.color = lineColor // Store the line color
return line, nil
}
// positionLine sets X/Y for all words in a line.
func (p *ScrollerPage) positionLine(line *Line, baseY int32) {
startX := p.ScreenCenterX - (line.width / 2)
for j := range line.words {
if j == 0 {
line.words[j].X = startX
} else {
line.words[j].X = line.words[j-1].X + line.words[j-1].W + p.spaceWidth
}
line.words[j].Y = baseY
}
}
// createLine is a helper to generate a new line at a specific index.
// isInitial determines if this is during initialization (positions from top)
// or dynamic creation (positions relative to existing lines).
func (p *ScrollerPage) createLine(index int, isInitial bool) error {
words := p.nextLineWords()
line, err := p.buildLineFromWords(words)
if err != nil {
return err
}
var baseY int32
if isInitial {
baseY = int32(p.scrollY) - int32(p.lineHeight)*int32(index)
} else {
baseY = 0
}
p.positionLine(&line, baseY)
if isInitial {
p.lines.PushBack(line)
}
return nil
}
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
// }
return nil
}
func (p *ScrollerPage) Update(dt float32) error {
p.updateAutoVelocity(dt)
p.scrollOffset += dt * p.velocity
move := int32(p.scrollOffset)
if move == 0 {
return nil
}
p.scrollOffset -= float32(move)
// Move all lines vertically
for i := 0; i < p.lines.Len(); i++ {
line := p.lines.Get(i)
for j := range line.words {
line.words[j].Y += move
}
}
// Handle line replacement based on scroll direction
if p.velocity > 0 {
// Moving up: replace lines that go off top
for p.lines.Len() > 0 && len(p.lines.Front().words) > 0 && p.lines.Front().words[0].Y > p.ScreenHeight {
_ = p.lines.PopFront() // no texture delete; textures are shared via cache
if err := p.addLineAtBottom(); err != nil {
return err
}
}
} else if p.velocity < 0 {
// Moving down: replace lines that go off bottom
for p.lines.Len() > 0 {
back := p.lines.Back()
if len(back.words) == 0 || back.words[0].Y+back.words[0].H >= 0 {
break
}
_ = p.lines.PopBack() // no texture delete; textures are shared via cache
if err := p.addLineAtTop(); err != nil {
return err
}
}
}
return nil
}
// addLineAtBottom creates and adds a new line at the bottom of the display.
func (p *ScrollerPage) addLineAtBottom() error {
newWords := p.nextLineWords()
line, err := p.buildLineFromWords(newWords)
if err != nil {
return err
}
var baseY int32
if p.lines.Len() > 0 && len(p.lines.Back().words) > 0 {
baseY = p.lines.Back().words[0].Y - int32(p.lineHeight)
} else {
baseY = -int32(p.lineHeight)
}
p.positionLine(&line, baseY)
p.lines.PushBack(line)
return nil
}
// addLineAtTop creates and adds a new line at the top of the display.
func (p *ScrollerPage) addLineAtTop() error {
newWords := p.nextLineWords()
line, err := p.buildLineFromWords(newWords)
if err != nil {
return err
}
var baseY int32
if p.lines.Len() > 0 && len(p.lines.Front().words) > 0 {
baseY = p.lines.Front().words[0].Y + int32(p.lineHeight)
} else {
baseY = p.ScreenHeight
}
p.positionLine(&line, baseY)
p.lines.PushFront(line)
return nil
}
func (p *ScrollerPage) Render() error {
p.BeginTextureRendering(p.shader)
for i := 0; i < p.lines.Len(); i++ {
line := p.lines.Get(i)
for _, word := range line.words {
if word.ID == 0 {
continue
}
p.RenderTextureBatched(p.shader, word.ID, word.Position(), word.RawDimensions(), word.Color)
}
}
p.EndTextureRendering()
return nil
}
func (p *ScrollerPage) Destroy() error {
if p == nil {
return nil
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
mixer.Stop(3000, config)
// Delete each cached word texture exactly once.
for k, item := range p.wordTextureCache {
if item.ID != 0 {
id := item.ID
gl.DeleteTextures(1, &id)
}
delete(p.wordTextureCache, k)
}
gl.Flush()
// Drop references for GC safely
p.lines = nil
p.streams = nil
p.lineCtx = nil
p.font = nil
p.shader = nil
p.colorPattern = nil
p.wordTextureCache = nil
return nil
}
// SetAutoVelocity configures the automatic velocity system for the ScrollerPage.
func (p *ScrollerPage) SetAutoVelocity(enabled bool, floor, ceiling, step, delay, hold float32) {
p.autoVelocity = enabled
p.velocityFloor = -float32(math.Abs(float64(floor)))
p.velocityCeiling = float32(math.Abs(float64(ceiling)))
p.velocityStep = float32(math.Abs(float64(step)))
p.velocityDelay = delay
p.hold = hold
// Initialize state
p.velocityDirection = 1
p.velocityState = 0
p.velocityTimer = 0.0
p.velocity = p.velocityFloor
}
// SetUseColorPalettes enables or disables the use of color palettes for lines.
func (p *ScrollerPage) SetUseColorPalettes(enabled bool) {
p.useColorPalettes = enabled
}
// updateAutoVelocity handles automatic velocity changes.
func (p *ScrollerPage) updateAutoVelocity(dt float32) {
if !p.autoVelocity {
return
}
switch p.velocityState {
case 0: // Changing velocity
if p.velocityDirection == 1 {
p.velocity += p.velocityStep * dt
if p.velocity >= p.velocityCeiling {
p.velocity = p.velocityCeiling
p.velocityState = 1
p.velocityTimer = 0.0
}
} else {
p.velocity -= p.velocityStep * dt
if p.velocity <= p.velocityFloor {
p.velocity = p.velocityFloor
p.velocityState = 2
p.velocityTimer = 0.0
}
}
case 1: // Waiting at ceiling
p.velocityTimer += dt
if p.velocityTimer >= p.velocityDelay {
p.velocityDirection = -1
p.velocityState = 0
}
case 2: // Waiting at floor
p.velocityTimer += dt
if p.velocityTimer >= p.velocityDelay {
p.velocityDirection = 1
p.velocityState = 0
}
}
}