Files
radiantwave-legacy/internal/page/AudioOptions.go
T
2026-07-29 14:46:11 -05:00

745 lines
20 KiB
Go

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
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},
{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 triangleSize = float32(16)
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
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
// Section header
sectionHeader StringItem
// Scrolling
scrollOffset int
maxVisible int
listItemMargin int32
listAreaTop int32
listAreaBottom int32
listItemHeight int32
scrollTrackWidth int32
scrollTrackX int32
scrollTrackY int32
scrollTrackH int32
// Selection dot
dotSize float32
// List left-alignment X (same for all items)
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.seedAudioFiles(); err != nil {
logger.Error("failed to seed audio files: %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
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 _, af := range AudioFileList {
item, err := NewStringItem(af.Name, p.font, colors.White)
if err != nil {
logger.Error("failed to create audio file string item for %s: %v", af.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.AudioFile
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
p.selectedFileIndex = 0
return
}
for i, af := range AudioFileList {
if af.ID == selected.ID {
p.selectedFileIndex = i + 1
return
}
}
p.selectedFileIndex = 0
}
func (p *AudioOptions) seedAudioFiles() error {
var existing []db.AudioFile
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
return err
}
if len(existing) == 0 {
return p.Context.DbPtr.Save(&AudioFileList).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.AudioFile{}).Where("1 = 1").Update("selected", false)
} else {
config.DynamicAudio = false
fileIndex := index - 1
if fileIndex >= 0 && fileIndex < len(AudioFileList) {
p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false)
p.Context.DbPtr.Model(&db.AudioFile{}).Where("id = ?", AudioFileList[fileIndex].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.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
}
func (p *AudioOptions) layout() {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
// Title
p.title.X = p.ScreenCenterX - p.title.W/2
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
// Prompt
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
// Volume row
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
// Audio toggle
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
// Section header — sits above the toggle, marks the start of the audio track list
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2
p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H
// Scrollable list area boundaries
p.listItemHeight = p.audioFileTextures[0].H
listTop := p.sectionHeader.Y - p.listItemMargin
listBottomMargin := int32(12)
// Available height for the list
listH := listTop - listBottomMargin
if listH < p.listItemHeight {
listH = p.listItemHeight
}
p.listAreaTop = listTop
p.listAreaBottom = listBottomMargin
// How many items fit
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
}
// Compute left alignment: find the widest item to center the block
var maxItemW int32
for i := range p.audioFileTextures {
if p.audioFileTextures[i].W > maxItemW {
maxItemW = p.audioFileTextures[i].W
}
}
p.listLeftX = p.ScreenCenterX - maxItemW/2
// Position each item — scrollOffset shifts the whole list up/down
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
}
// Clamp scroll offset so we never show empty space
maxScroll := n - p.maxVisible
if maxScroll < 0 {
maxScroll = 0
}
if p.scrollOffset > maxScroll {
p.scrollOffset = maxScroll
}
// Scrollbar track position
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)
}
// Section header
p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color)
// List background
listBgColor := sdl.Color{R: 255, G: 255, B: 255, A: 12}
bgPad := int32(16)
bgLeft := float32(p.listLeftX - bgPad)
bgTop := float32(p.listAreaBottom)
bgRight := float32(p.scrollTrackX)
bgBottom := float32(p.listAreaTop)
p.RenderSolidColorQuad(p.solidShader,
mgl32.Vec2{bgLeft, bgTop},
mgl32.Vec2{bgRight - bgLeft, 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)
}
// Selection dot — small filled circle left of the item name
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)
}
}
// Scrollbar
p.renderScrollbar()
// Spinning triangle cursor — always on top
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
}