package page import ( "fmt" "math" "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/db" "rhemawavetech.com/rhemawave/internal/fontManager" "rhemawavetech.com/rhemawave/internal/logger" "rhemawavetech.com/rhemawave/internal/mixer" "rhemawavetech.com/rhemawave/internal/shaderManager" ) const focusToggle = -1 const triangleSize = float32(16) var audioTrackSeed = []db.AudioTrack{ {Filename: "OriginalAudio.wav", Name: "Original Audio", AssignedFrequency: 0, MD5: "d39c2676c315438752ab0c55e9427d74", Order: 1}, {Filename: "174Hz.wav", Name: "174 Hz", AssignedFrequency: 174, MD5: "42abaf332e628b646ac2cc9d1b5385e7", Order: 2}, {Filename: "396Hz.wav", Name: "396 Hz", AssignedFrequency: 396, MD5: "d53d45849b042122a3c177fad95ff499", Order: 3}, {Filename: "417Hz.wav", Name: "417 Hz", AssignedFrequency: 417, MD5: "056a7aae0b53111f5c568ea7ecd1fadb", Order: 4}, {Filename: "432Hz.wav", Name: "432 Hz", AssignedFrequency: 432, MD5: "276c57179372404ff59c35024911468e", Order: 5}, {Filename: "639Hz.wav", Name: "639 Hz", AssignedFrequency: 639, MD5: "4e460148230f4417fef39e9817f75e06", Order: 6}, {Filename: "741Hz.wav", Name: "741 Hz", AssignedFrequency: 741, MD5: "cfbd9b03b539db69bcefdd91cedc9ad1", Order: 7}, {Filename: "852Hz.wav", Name: "852 Hz", AssignedFrequency: 852, MD5: "485700296d017f8a1b0a7583a55a7783", Order: 8}, {Filename: "963Hz.wav", Name: "963 Hz", AssignedFrequency: 963, MD5: "2670e13294daef8490a862c078aa8af4", Order: 9}, {Filename: "1111Hz.wav", Name: "1111 Hz", AssignedFrequency: 1111, MD5: "67bbc7619dc21d0750519113472ca335", Order: 10}, {Filename: "14Hz_Binaural.wav", Name: "14 Hz Binaural Beat", AssignedFrequency: 14, MD5: "807c14866640d2ab27a6f5d7fdd24e77", Order: 11}, {Filename: "40Hz_Binaural.wav", Name: "40 Hz Binaural Beat", AssignedFrequency: 40, MD5: "42d60d55a16db4e9b29da47a5a102f2c", Order: 12}, {Filename: "AlphaWaves.wav", Name: "Alpha Waves", AssignedFrequency: 0, MD5: "07a93fa0e633a00d97b8a5e5a259d6ff", Order: 13}, {Filename: "PinealGland.wav", Name: "Pineal Gland", AssignedFrequency: 0, MD5: "998d74e3826b30086d64ff0df7b9f133", Order: 14}, } type AudioOptions struct { Base font *fontManager.FontEntry textShader *shaderManager.Shader solidShader *shaderManager.Shader title StringItem prompt StringItem verticalMargin int32 ItemScale float32 AudioStatusToggle StringItem audioFileTextures []StringItem audioTracks []db.AudioTrack selectedFileIndex int VolumeLabel StringItem volumePercent StringItem volumeBarX int32 volumeBarY int32 volumeBarW int32 volumeBarH int32 triVAO uint32 triVBO uint32 triX float32 triCenterY float32 spinElapsed float32 focusState int sectionHeader StringItem scrollOffset int maxVisible int listItemMargin int32 listAreaTop int32 listAreaBottom int32 listItemHeight int32 scrollTrackWidth int32 scrollTrackX int32 scrollTrackY int32 scrollTrackH int32 dotSize float32 listLeftX int32 } func (p *AudioOptions) Init(ctx *PageCtx) error { var err error p.verticalMargin = int32(20) p.listItemMargin = 6 p.focusState = focusToggle p.scrollOffset = 0 p.dotSize = 8.0 p.scrollTrackWidth = 30 p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth fm := fontManager.Get() font, ok := fm.GetFont("SpaceMonoNerdFont-Regular") if !ok { return fmt.Errorf("failed to get font: %w", sdl.GetError()) } p.font = font sm := shaderManager.Get() p.textShader, ok = sm.Get("text") if !ok { return fmt.Errorf("failed to get text shader: %w", sdl.GetError()) } p.solidShader, ok = sm.Get("solid_color") if !ok { return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError()) } if err = p.Base.Init(ctx); err != nil { return fmt.Errorf("base init failed: %w", err) } config, err := db.GetConfig(p.Context.DbPtr) if err != nil { panic(err) } baseFontSize := config.StandardFontSize titleScale := float32(64) / float32(baseFontSize) p.ItemScale = float32(32) / float32(baseFontSize) p.title, err = NewStringItem("Audio Options", p.font, colors.White) if err != nil { return fmt.Errorf("creating title: %w", err) } p.title.Scale(titleScale) p.prompt, err = NewStringItem( "Enter to select -- Up/Down to navigate -- 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) 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()") } if err = p.CreateAudioStatusItem(); err != nil { logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") } if err = p.seedAudioTracks(); err != nil { logger.Error("failed to seed audio tracks: %v", err) } p.buildAudioFileList() 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.sectionHeader, err = NewStringItem("Audio Track", p.font, colors.Silver) if err != nil { return fmt.Errorf("creating section header: %w", err) } p.sectionHeader.Scale(p.ItemScale * 1.2) p.layout() return nil } func (p *AudioOptions) buildAudioFileList() { for i := range p.audioFileTextures { gl.DeleteTextures(1, &p.audioFileTextures[i].ID) } p.audioFileTextures = nil var tracks []db.AudioTrack p.Context.DbPtr.Order("`order` ASC").Find(&tracks) p.audioTracks = tracks dynamicItem, err := NewStringItem("Dynamic Audio", p.font, colors.White) if err != nil { logger.Error("failed to create Dynamic Audio string item: %v", err) } else { dynamicItem.Scale(p.ItemScale) p.audioFileTextures = append(p.audioFileTextures, dynamicItem) } for _, track := range tracks { item, err := NewStringItem(track.Name, p.font, colors.White) if err != nil { logger.Error("failed to create audio track string item for %s: %v", track.Name, err) continue } item.Scale(p.ItemScale) p.audioFileTextures = append(p.audioFileTextures, item) } p.updateSelectedIndex() } func (p *AudioOptions) updateSelectedIndex() { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { p.selectedFileIndex = 0 return } if config.DynamicAudio { p.selectedFileIndex = 0 return } var selected db.AudioTrack if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil { p.selectedFileIndex = 0 return } for i, track := range p.audioTracks { if track.ID == selected.ID { p.selectedFileIndex = i + 1 return } } p.selectedFileIndex = 0 } func (p *AudioOptions) seedAudioTracks() error { var existing []db.AudioTrack if err := p.Context.DbPtr.Find(&existing).Error; err != nil { return err } if len(existing) == 0 { return p.Context.DbPtr.Save(&audioTrackSeed).Error } return nil } func (p *AudioOptions) CreateAudioStatusItem() error { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { panic(err) } on := config.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 } func (p *AudioOptions) selectAudioFile(index int) { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { panic(err) } if index == 0 { config.DynamicAudio = true p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false) } else { config.DynamicAudio = false trackIndex := index - 1 if trackIndex >= 0 && trackIndex < len(p.audioTracks) { p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false) p.Context.DbPtr.Model(&db.AudioTrack{}).Where("id = ?", p.audioTracks[trackIndex].ID).Update("selected", true) } } if err := p.Context.DbPtr.Save(config).Error; err != nil { logger.Error("failed to save audio config: %v", err) } p.selectedFileIndex = index } func (p *AudioOptions) CreateVolumePercentItem() error { if p.volumePercent.ID != 0 { gl.DeleteTextures(1, &p.volumePercent.ID) } config, err := db.GetConfig(p.Context.DbPtr) if err != nil { panic(err) } pct := int(config.SavedVolume * 100) 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) } item.Scale(p.ItemScale) p.volumePercent = item return nil } func (p *AudioOptions) UpdateVolume(level int) { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { panic(err) } newVol := mixer.ConvertVolume(level) + config.SavedVolume if newVol < 0.0 || newVol > 1.0 { return } if newVol > .99 { newVol = 1.0 } config.SavedVolume = newVol p.Context.DbPtr.Save(&config) if err := mixer.SetVolume(newVol); err != nil { logger.Error("Error setting volume: %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 } func (p *AudioOptions) layout() { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { panic(err) } p.title.X = p.ScreenCenterX - p.title.W/2 p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2 p.prompt.X = p.ScreenCenterX - p.prompt.W/2 p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H barH := int32(float32(config.StandardFontSize) * p.ItemScale) barW := int32(float32(p.ScreenWidth) * 0.30) barX := p.ScreenCenterX - barW/2 barY := p.prompt.Y - p.verticalMargin*2 - barH p.volumeBarX = barX p.volumeBarY = barY p.volumeBarW = barW p.volumeBarH = barH p.VolumeLabel.X = barX - p.VolumeLabel.W - 20 p.VolumeLabel.Y = barY + (barH-p.VolumeLabel.H)/2 gap := int32(12) p.volumePercent.X = barX + barW + gap p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2 p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2 p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H p.listItemHeight = p.audioFileTextures[0].H listTop := p.sectionHeader.Y - p.listItemMargin listBottomMargin := int32(12) listH := listTop - listBottomMargin if listH < p.listItemHeight { listH = p.listItemHeight } p.listAreaTop = listTop p.listAreaBottom = listBottomMargin itemStep := p.listItemHeight + p.listItemMargin n := len(p.audioFileTextures) p.maxVisible = int(listH / itemStep) if p.maxVisible < 1 { p.maxVisible = 1 } if p.maxVisible > n { p.maxVisible = n } var maxItemW int32 for i := range p.audioFileTextures { w := p.audioFileTextures[i].W if w > maxItemW { maxItemW = w } } p.listLeftX = p.ScreenCenterX - maxItemW/2 firstVisibleY := p.listAreaTop - p.listItemHeight for i := range p.audioFileTextures { item := &p.audioFileTextures[i] item.X = p.listLeftX item.Y = firstVisibleY - int32(i-p.scrollOffset)*itemStep } maxScroll := n - p.maxVisible if maxScroll < 0 { maxScroll = 0 } if p.scrollOffset > maxScroll { p.scrollOffset = maxScroll } p.scrollTrackY = p.listAreaBottom p.scrollTrackH = p.listAreaTop - p.listAreaBottom p.updateCursorPosition() } func (p *AudioOptions) updateCursorPosition() { cursorGap := int32(12) var item *StringItem switch { case p.focusState == focusToggle: item = &p.AudioStatusToggle case p.focusState >= 0 && p.focusState < len(p.audioFileTextures): item = &p.audioFileTextures[p.focusState] default: return } p.triX = float32(item.X) - triangleSize - float32(cursorGap) p.triCenterY = float32(item.Y) + float32(item.H)/2 } func (p *AudioOptions) ToggleAudio() { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { logger.Error("panic! Cannot query database for configuration") panic(err) } config.AudioEnabled = !config.AudioEnabled p.Context.DbPtr.Save(config) p.CreateAudioStatusItem() p.layout() } func (p *AudioOptions) HandleEvent(event *sdl.Event) error { e, ok := (*event).(*sdl.KeyboardEvent) if !ok || e.Type != sdl.KEYDOWN { return nil } n := len(p.audioFileTextures) switch e.Keysym.Sym { case sdl.K_RETURN: if p.focusState == focusToggle { p.ToggleAudio() } else if p.focusState >= 0 && p.focusState < n { p.selectAudioFile(p.focusState) p.updateCursorPosition() } case sdl.K_DOWN: if p.focusState == focusToggle { if n > 0 { p.focusState = 0 p.scrollOffset = 0 p.layout() } } else if p.focusState < n-1 { p.focusState++ if p.focusState >= p.scrollOffset+p.maxVisible { p.scrollOffset++ p.layout() } else { p.updateCursorPosition() } } case sdl.K_UP: if p.focusState == 0 { p.focusState = focusToggle p.updateCursorPosition() } else if p.focusState > 0 { p.focusState-- if p.focusState < p.scrollOffset { p.scrollOffset-- p.layout() } else { p.updateCursorPosition() } } case sdl.K_PAGEUP: if p.focusState > 0 { p.focusState -= p.maxVisible - 1 if p.focusState < 0 { p.focusState = 0 } p.scrollOffset = p.focusState p.layout() } case sdl.K_PAGEDOWN: if p.focusState < n-1 { p.focusState += p.maxVisible - 1 if p.focusState >= n { p.focusState = n - 1 } p.scrollOffset = p.focusState - p.maxVisible + 1 if p.scrollOffset < 0 { p.scrollOffset = 0 } p.layout() } case sdl.K_RIGHT: p.UpdateVolume(10) case sdl.K_LEFT: p.UpdateVolume(-10) case sdl.K_BACKSPACE: p.Context.NavigateTo(SETTINGS) } return nil } func (p *AudioOptions) Update(dt float32) error { p.spinElapsed += dt if p.spinElapsed >= 1.0 { p.spinElapsed -= 1.0 } return nil } func (p *AudioOptions) renderVolumeBar() error { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { panic(err) } vol := config.SavedVolume fillW := float32(p.volumeBarW) * float32(vol) if fillW > 0 { 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) } } thickness := float32(2) bx := float32(p.volumeBarX) by := float32(p.volumeBarY) bw := float32(p.volumeBarW) bh := float32(p.volumeBarH) outlineRects := [4][2]mgl32.Vec2{ {{bx, by}, {bw, thickness}}, {{bx, by + bh - thickness}, {bw, thickness}}, {{bx, by}, {thickness, bh}}, {{bx + bw - thickness, by}, {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: %w", err) } } return nil } 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 } func (p *AudioOptions) renderScrollbar() { n := len(p.audioFileTextures) if n <= p.maxVisible { return } trackColor := sdl.Color{R: 255, G: 255, B: 255, A: 30} thumbColor := sdl.Color{R: 255, G: 255, B: 255, A: 100} p.RenderSolidColorQuad(p.solidShader, mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)}, mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackH)}, trackColor) visibleRatio := float32(p.maxVisible) / float32(n) scrollRatio := float32(p.scrollOffset) / float32(n) thumbHeight := float32(p.scrollTrackH) * visibleRatio if thumbHeight < 20 { thumbHeight = 20 } thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackH)*scrollRatio p.RenderSolidColorQuad(p.solidShader, mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY}, mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight}, thumbColor) } 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 { 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 { return fmt.Errorf("render prompt: %w", err) } if err := p.RenderTexture(p.textShader, p.VolumeLabel.ID, p.VolumeLabel.Position(), p.VolumeLabel.RawDimensions(), p.VolumeLabel.Color); err != nil { return fmt.Errorf("render volume label: %w", err) } if err := p.renderVolumeBar(); err != nil { 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.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil { return fmt.Errorf("render audio toggle: %w", err) } p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color) // List background — centered width matching widest item + padding var maxItemW int32 for i := range p.audioFileTextures { w := p.audioFileTextures[i].W if w > maxItemW { maxItemW = w } } bgPad := int32(16) bgW := maxItemW + 2*bgPad bgLeft := float32(p.ScreenCenterX) - float32(bgW)/2 bgTop := float32(p.listAreaBottom) bgBottom := float32(p.listAreaTop) listBgColor := sdl.Color{R: 255, G: 255, B: 255, A: 12} p.RenderSolidColorQuad(p.solidShader, mgl32.Vec2{bgLeft, bgTop}, mgl32.Vec2{float32(bgW), bgBottom - bgTop}, listBgColor) // Audio file list (visible window only) for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.audioFileTextures); i++ { item := &p.audioFileTextures[i] clr := colors.White if i == p.selectedFileIndex { clr = colors.Green } if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), clr); err != nil { return fmt.Errorf("render audio file %d: %w", i, err) } if i == p.selectedFileIndex { dotX := float32(item.X) - p.dotSize - 6 dotY := float32(item.Y) + float32(item.H)/2 - p.dotSize/2 p.RenderSolidColorQuad(p.solidShader, mgl32.Vec2{dotX, dotY}, mgl32.Vec2{p.dotSize, p.dotSize}, colors.Green) } } p.renderScrollbar() if err := p.renderCursor(); err != nil { return fmt.Errorf("render cursor: %w", err) } return nil } func (p *AudioOptions) Destroy() error { gl.DeleteTextures(1, &p.title.ID) gl.DeleteTextures(1, &p.prompt.ID) gl.DeleteTextures(1, &p.VolumeLabel.ID) gl.DeleteTextures(1, &p.volumePercent.ID) gl.DeleteTextures(1, &p.AudioStatusToggle.ID) gl.DeleteTextures(1, &p.sectionHeader.ID) for i := range p.audioFileTextures { gl.DeleteTextures(1, &p.audioFileTextures[i].ID) } gl.DeleteBuffers(1, &p.triVBO) gl.DeleteVertexArrays(1, &p.triVAO) return nil }