Add audio device switching UI

This commit is contained in:
NeilClack
2026-08-08 00:12:57 -05:00
parent b54dbca2d5
commit 2212cc9b78
11 changed files with 1528 additions and 455 deletions
+58 -298
View File
@@ -19,23 +19,9 @@ 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},
}
// AudioOptions is the hub for audio settings. It hosts the master volume and
// the audio on/off toggle, and routes to the TrackSelection and AudioDevices
// pages for picking the track and the output device.
type AudioOptions struct {
Base
font *fontManager.FontEntry
@@ -50,9 +36,8 @@ type AudioOptions struct {
AudioStatusToggle StringItem
audioFileTextures []StringItem
audioTracks []db.AudioTrack
selectedFileIndex int
audioTrackItem StringItem
outputDeviceItem StringItem
VolumeLabel StringItem
volumePercent StringItem
@@ -68,34 +53,12 @@ type AudioOptions struct {
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")
@@ -155,12 +118,10 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
}
if err = p.seedAudioTracks(); err != nil {
logger.Error("failed to seed audio tracks: %v", err)
if err = p.buildNavigationItems(); err != nil {
return fmt.Errorf("creating navigation items: %w", err)
}
p.buildAudioFileList()
triVerts := []float32{
0.5, 0.0,
-0.5, 0.5,
@@ -176,84 +137,56 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
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
// buildNavigationItems creates the "Audio Track" and "Output Device" entries
// that navigate to the TrackSelection and AudioDevices pages. Labels include
// the currently selected value so returning to this page shows the latest
// choice.
func (p *AudioOptions) buildNavigationItems() error {
var err error
var tracks []db.AudioTrack
p.Context.DbPtr.Order("`order` ASC").Find(&tracks)
p.audioTracks = tracks
dynamicItem, err := NewStringItem("Dynamic Audio", p.font, colors.White)
p.audioTrackItem, err = NewStringItem(p.currentTrackLabel(), 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)
return fmt.Errorf("creating audio track item: %w", err)
}
p.audioTrackItem.Scale(p.ItemScale)
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.outputDeviceItem, err = NewStringItem(p.currentDeviceLabel(), p.font, colors.White)
if err != nil {
return fmt.Errorf("creating output device item: %w", err)
}
p.outputDeviceItem.Scale(p.ItemScale)
p.updateSelectedIndex()
return nil
}
func (p *AudioOptions) updateSelectedIndex() {
func (p *AudioOptions) currentTrackLabel() string {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
p.selectedFileIndex = 0
return
return "Audio Track"
}
if config.DynamicAudio {
p.selectedFileIndex = 0
return
return "Audio Track: Dynamic Audio"
}
var selected db.AudioTrack
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
p.selectedFileIndex = 0
return
return "Audio Track"
}
for i, track := range p.audioTracks {
if track.ID == selected.ID {
p.selectedFileIndex = i + 1
return
}
if selected.Name != "" {
return "Audio Track: " + selected.Name
}
p.selectedFileIndex = 0
return "Audio Track"
}
func (p *AudioOptions) seedAudioTracks() error {
var existing []db.AudioTrack
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
return err
func (p *AudioOptions) currentDeviceLabel() string {
current := mixer.CurrentDevice()
if current == "" {
return "Output Device: System Default"
}
if len(existing) == 0 {
return p.Context.DbPtr.Save(&audioTrackSeed).Error
}
return nil
return "Output Device: " + mixer.HumanizeDeviceName(current)
}
func (p *AudioOptions) CreateAudioStatusItem() error {
@@ -277,31 +210,6 @@ func (p *AudioOptions) CreateAudioStatusItem() error {
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)
@@ -377,57 +285,11 @@ func (p *AudioOptions) layout() {
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.outputDeviceItem.X = p.ScreenCenterX - p.outputDeviceItem.W/2
p.outputDeviceItem.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.outputDeviceItem.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.audioTrackItem.X = p.ScreenCenterX - p.audioTrackItem.W/2
p.audioTrackItem.Y = p.outputDeviceItem.Y - p.verticalMargin - p.audioTrackItem.H
p.updateCursorPosition()
}
@@ -439,8 +301,10 @@ func (p *AudioOptions) updateCursorPosition() {
switch {
case p.focusState == focusToggle:
item = &p.AudioStatusToggle
case p.focusState >= 0 && p.focusState < len(p.audioFileTextures):
item = &p.audioFileTextures[p.focusState]
case p.focusState == 0:
item = &p.audioTrackItem
case p.focusState == 1:
item = &p.outputDeviceItem
default:
return
}
@@ -467,32 +331,24 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
return nil
}
n := len(p.audioFileTextures)
switch e.Keysym.Sym {
case sdl.K_RETURN:
if p.focusState == focusToggle {
switch p.focusState {
case focusToggle:
p.ToggleAudio()
} else if p.focusState >= 0 && p.focusState < n {
p.selectAudioFile(p.focusState)
p.updateCursorPosition()
case 0:
p.Context.NavigateTo(TRACKS)
case 1:
p.Context.NavigateTo(AUDIO_DEVICES)
}
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 = 0
p.updateCursorPosition()
} else if p.focusState < 1 {
p.focusState++
if p.focusState >= p.scrollOffset+p.maxVisible {
p.scrollOffset++
p.layout()
} else {
p.updateCursorPosition()
}
p.updateCursorPosition()
}
case sdl.K_UP:
@@ -501,35 +357,7 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
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()
p.updateCursorPosition()
}
case sdl.K_RIGHT:
@@ -618,34 +446,6 @@ func (p *AudioOptions) renderCursor() error {
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)
@@ -668,51 +468,13 @@ func (p *AudioOptions) Render() error {
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
}
if err := p.RenderTexture(p.textShader, p.audioTrackItem.ID, p.audioTrackItem.Position(), p.audioTrackItem.RawDimensions(), p.audioTrackItem.Color); err != nil {
return fmt.Errorf("render audio track item: %w", err)
}
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)
}
if err := p.RenderTexture(p.textShader, p.outputDeviceItem.ID, p.outputDeviceItem.Position(), p.outputDeviceItem.RawDimensions(), p.outputDeviceItem.Color); err != nil {
return fmt.Errorf("render output device item: %w", err)
}
p.renderScrollbar()
if err := p.renderCursor(); err != nil {
return fmt.Errorf("render cursor: %w", err)
}
@@ -726,10 +488,8 @@ 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.sectionHeader.ID)
for i := range p.audioFileTextures {
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
}
gl.DeleteTextures(1, &p.audioTrackItem.ID)
gl.DeleteTextures(1, &p.outputDeviceItem.ID)
gl.DeleteBuffers(1, &p.triVBO)
gl.DeleteVertexArrays(1, &p.triVAO)
return nil