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(audioDir(), "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 } // audioDir returns the path to the audio files directory (~/.radiantwave/audio). func audioDir() string { home, err := os.UserHomeDir() if err != nil { panic(err) } return filepath.Join(home, ".radiantwave", "audio") } // audioPathForFrequency builds the on-disk path for a solfeggio frequency's // audio file, e.g. 396 -> "~/.radiantwave/audio/396Hz.wav". func (p *ScrollerPage) audioPathForFrequency(freq int) string { return filepath.Join(audioDir(), fmt.Sprintf("%dHz.wav", freq)) } // switchAudioForFrequency swaps the currently playing track to match freq. // // - 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 } } }