Add audio list

This commit is contained in:
NeilClack
2026-07-27 13:14:37 -05:00
parent 5854c57f6f
commit b1a5a670ab
4 changed files with 162 additions and 120 deletions
+14 -6
View File
@@ -1,5 +1,5 @@
{
description = "RhemaWave";
description = "RadiantWave";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@@ -26,17 +26,25 @@
SDL2_ttf
SDL2_image
SDL2_mixer
libGL
sqlite
dbus
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
libGL
wayland
wayland-protocols
libxkbcommon
libdecor
libx11
libxext
];
] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (with pkgs.darwin.apple_sdk.frameworks; [
OpenGL
CoreAudio
CoreFoundation
CoreServices
ForceFeedback
IOKit
AppKit
]);
nativeBuildInputs = with pkgs; [
go
@@ -46,8 +54,8 @@
in
{
packages.default = pkgs.buildGoModule {
pname = "rhemawave";
version = "v1.1.1";
pname = "radiantwave";
version = "v1.0.0";
src = ./.;
vendorHash = null;
@@ -74,7 +82,7 @@
meta = with pkgs.lib; {
description = "RhemaWave kiosk application";
license = licenses.unfree;
platforms = platforms.linux;
platforms = platforms.linux ++ platforms.darwin;
};
};
+1
View File
@@ -15,6 +15,7 @@ type AppConfig struct {
StandardFontSize int // The font size used for most text rendering
LinePattern string // "fibonacci"
AudioEnabled bool
DynamicAudio bool // When true, scroller switches audio per-proclamation frequency
SavedVolume float64 // Last saved volume as a percentage
FirstRun bool // Is this the first time the application has been run?
}
+113 -106
View File
@@ -16,12 +16,10 @@ import (
)
// focusToggle is the sentinel focusState value meaning the audio on/off toggle
// row has keyboard focus. Device indices (0..n-1) are used for everything below.
// row has keyboard focus. Audio-file indices (0..n-1) are used for everything below.
const focusToggle = -1
// AudioFileList is kept as the canonical seed data for the audio_files table.
// Track selection UI no longer lives on this page, but other parts of the
// app still read these rows from the database.
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},
@@ -41,19 +39,12 @@ var AudioFileList = []db.AudioFile{
const triangleSize = float32(16)
type AudioDevice struct {
Label string
ID uint32
index int
}
type AudioOptions struct {
// Page Attributes
Base
font *fontManager.FontEntry
textShader *shaderManager.Shader
solidShader *shaderManager.Shader
CfgPtr *db.AppConfig
// Page Textures
title StringItem
@@ -66,11 +57,9 @@ type AudioOptions struct {
// Audio toggle
AudioStatusToggle StringItem
// Audio Devices
activeDeviceIndex int
NumAudioDevices int
AudioDevices []AudioDevice
AudioDeviceTextures []StringItem
// Audio file list — index 0 = "Dynamic Audio", index 1+ = AudioFileList entries
audioFileTextures []StringItem
selectedFileIndex int // which item is currently selected (highlights green)
// Volume row
VolumeLabel StringItem
@@ -87,7 +76,7 @@ type AudioOptions struct {
triCenterY float32
spinElapsed float32
// Focus: focusToggle (-1) = toggle row, 0..n-1 = device at that index
// Focus: focusToggle (-1) = toggle row, 0..n-1 = audio file list item
focusState int
}
@@ -162,37 +151,14 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
}
// Audio output devices
if err = p.GetAudioDevices(); err != nil {
logger.Error("failed to enumerate audio devices: %v", err)
}
// Set active device index based on mixer's current device
currentName := mixer.CurrentDevice()
config, err = db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
p.activeDeviceIndex = -1 // default to not found
for i, dev := range p.AudioDevices {
if dev.Label == currentName {
p.activeDeviceIndex = i
break
}
}
// If not found and current device is default (""), check saved config
if p.activeDeviceIndex == -1 && currentName == "" && config.SelectedAudioDevice == "default" {
// Default device is selected - first device in SDL list is typically the default
if len(p.AudioDevices) > 0 {
p.activeDeviceIndex = 0
}
}
// Make sure the audio_files table is populated.
if err = p.seedAudioFiles(); err != nil {
logger.Error("failed to seed audio files: %v", err)
}
// Build the audio file list textures: "Dynamic Audio" + AudioFileList
p.buildAudioFileList()
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords
triVerts := []float32{
0.5, 0.0,
@@ -213,6 +179,72 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
return nil
}
// buildAudioFileList creates textures for the audio file list.
// Index 0 = "Dynamic Audio", index 1+ = AudioFileList entries.
func (p *AudioOptions) buildAudioFileList() {
// Clear old textures
for i := range p.audioFileTextures {
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
}
p.audioFileTextures = nil
// "Dynamic Audio" entry at index 0
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)
}
// AudioFileList entries at index 1+
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)
}
// Determine which item is currently selected
p.updateSelectedIndex()
}
// updateSelectedIndex sets selectedFileIndex based on the DB state.
func (p *AudioOptions) updateSelectedIndex() {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
p.selectedFileIndex = 0 // default to Dynamic Audio
return
}
if config.DynamicAudio {
p.selectedFileIndex = 0
return
}
// Find the audio file with Selected = true
var selected db.AudioFile
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
// No selection found — default to Dynamic Audio
p.selectedFileIndex = 0
return
}
// Map the selected AudioFile ID to its index in the list
for i, af := range AudioFileList {
if af.ID == selected.ID {
p.selectedFileIndex = i + 1 // +1 because index 0 is "Dynamic Audio"
return
}
}
// Fallback — file not in list
p.selectedFileIndex = 0
}
// seedAudioFiles ensures the audio_files table has its default rows.
func (p *AudioOptions) seedAudioFiles() error {
var existing []db.AudioFile
@@ -248,61 +280,37 @@ func (p *AudioOptions) CreateAudioStatusItem() error {
return nil
}
// ── Audio Output Devices ──────────────────────────────────────────────────────
func (p *AudioOptions) GetAudioDevices() error {
numAudioDevices := sdl.GetNumAudioDevices(false)
if numAudioDevices == 0 {
return nil
}
// Pre-allocate with zero length so append doesn't produce leading zero-value
// elements (the old code used make([]AudioDevice, n) then appended, which
// doubled the slice length with n zero-value entries at the front).
audioDevices := make([]AudioDevice, 0, numAudioDevices)
for i := range numAudioDevices {
label := sdl.GetAudioDeviceName(i, false)
audioDevices = append(audioDevices, AudioDevice{Label: label, ID: uint32(i)})
}
p.AudioDevices = audioDevices
for i := range audioDevices {
item, err := NewStringItem(p.AudioDevices[i].Label, p.font, colors.White)
if err != nil {
return err
}
item.Scale(p.ItemScale)
p.AudioDeviceTextures = append(p.AudioDeviceTextures, item)
}
p.NumAudioDevices = numAudioDevices
return nil
}
// SelectAudioDevice is called when the user confirms a device from the list.
// The device-switching implementation goes here; for now we just log the choice.
func (p *AudioOptions) SelectAudioDevice(deviceIndex int) error {
if deviceIndex < 0 || deviceIndex >= len(p.AudioDevices) {
return fmt.Errorf("invalid device index: %d", deviceIndex)
}
device := p.AudioDevices[deviceIndex]
// ── Audio file selection ─────────────────────────────────────────────────────
// selectAudioFile handles the user pressing Enter on an audio file list item.
func (p *AudioOptions) selectAudioFile(index int) {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
return fmt.Errorf("failed to get config for device switch: %w", err)
panic(err)
}
if err := mixer.SwitchDevice(device.Label, config); err != nil {
return fmt.Errorf("failed to switch to device %s: %w", device.Label, err)
if index == 0 {
// "Dynamic Audio" selected
config.DynamicAudio = true
// Clear all file selections
p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false)
} else {
// Specific audio file selected
config.DynamicAudio = false
fileIndex := index - 1 // offset for "Dynamic Audio" entry
if fileIndex >= 0 && fileIndex < len(AudioFileList) {
// Clear all selections first
p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false)
// Select the chosen file
p.Context.DbPtr.Model(&db.AudioFile{}).Where("id = ?", AudioFileList[fileIndex].ID).Update("selected", true)
}
}
// Persist the selected device to the database
config.SelectedAudioDevice = device.Label
if err := p.Context.DbPtr.Save(config).Error; err != nil {
logger.Error("failed to persist device selection: %v", err)
logger.Error("failed to save audio config: %v", err)
}
p.activeDeviceIndex = deviceIndex
return nil
p.selectedFileIndex = index
}
// ── Volume ────────────────────────────────────────────────────────────────────
@@ -388,10 +396,10 @@ func (p *AudioOptions) layout() {
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
// Device list — each item centred and stacked below the toggle
// Audio file list — each item centred and stacked below the toggle
nextY := p.AudioStatusToggle.Y - p.verticalMargin
for i := range p.AudioDeviceTextures {
item := &p.AudioDeviceTextures[i]
for i := range p.audioFileTextures {
item := &p.audioFileTextures[i]
item.X = p.ScreenCenterX - item.W/2
item.Y = nextY - item.H
nextY = item.Y - p.verticalMargin
@@ -410,8 +418,8 @@ func (p *AudioOptions) updateCursorPosition() {
switch {
case p.focusState == focusToggle:
item = &p.AudioStatusToggle
case p.focusState >= 0 && p.focusState < len(p.AudioDeviceTextures):
item = &p.AudioDeviceTextures[p.focusState]
case p.focusState >= 0 && p.focusState < len(p.audioFileTextures):
item = &p.audioFileTextures[p.focusState]
default:
return
}
@@ -446,19 +454,18 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
case sdl.K_RETURN:
if p.focusState == focusToggle {
p.ToggleAudio()
} else if p.focusState >= 0 && p.focusState < len(p.AudioDevices) {
if err := p.SelectAudioDevice(p.focusState); err != nil {
logger.Error("SelectAudioDevice: %v", err)
}
} else if p.focusState >= 0 && p.focusState < len(p.audioFileTextures) {
p.selectAudioFile(p.focusState)
p.updateCursorPosition()
}
case sdl.K_DOWN:
if p.focusState == focusToggle {
if len(p.AudioDeviceTextures) > 0 {
if len(p.audioFileTextures) > 0 {
p.focusState = 0
p.updateCursorPosition()
}
} else if p.focusState < len(p.AudioDeviceTextures)-1 {
} else if p.focusState < len(p.audioFileTextures)-1 {
p.focusState++
p.updateCursorPosition()
}
@@ -526,7 +533,7 @@ func (p *AudioOptions) renderVolumeBar() error {
{{bx, by}, {bw, thickness}},
{{bx, by + bh - thickness}, {bw, thickness}},
{{bx, by}, {thickness, bh}},
{{bx + bw - thickness, by}, {thickness, bh}}, // fix: was missing `by` as Y component
{{bx + bw - thickness, by}, {thickness, bh}},
}
for _, r := range outlineRects {
if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil {
@@ -589,15 +596,15 @@ func (p *AudioOptions) Render() error {
return fmt.Errorf("render audio toggle: %w", err)
}
// Device list
for i := range p.AudioDeviceTextures {
item := &p.AudioDeviceTextures[i]
// Audio file list
for i := range p.audioFileTextures {
item := &p.audioFileTextures[i]
color := colors.White
if i == p.activeDeviceIndex {
if i == p.selectedFileIndex {
color = colors.Green
}
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), color); err != nil {
return fmt.Errorf("render audio device %d: %w", i, err)
return fmt.Errorf("render audio file %d: %w", i, err)
}
}
@@ -617,8 +624,8 @@ func (p *AudioOptions) Destroy() error {
gl.DeleteTextures(1, &p.VolumeLabel.ID)
gl.DeleteTextures(1, &p.volumePercent.ID)
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
for i := range p.AudioDeviceTextures {
gl.DeleteTextures(1, &p.AudioDeviceTextures[i].ID)
for i := range p.audioFileTextures {
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
}
gl.DeleteBuffers(1, &p.triVBO)
gl.DeleteVertexArrays(1, &p.triVAO)
+32 -6
View File
@@ -36,7 +36,9 @@ type ScrollerPage struct {
// legacy Selected-AudioFile track, or nothing yet).
currentAudioFreq int
IsDownloadingAudio bool
audioRetryTimer float32
audioStarted bool
dynamicAudio bool
lines *LineDeque
lineHeight int32
@@ -277,16 +279,21 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
}
}
// 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.
// Read DynamicAudio setting from config.
p.dynamicAudio = config.DynamicAudio
logger.Info("DynamicAudio mode: %v", p.dynamicAudio)
// Start audio for the first proclamation in the stream.
started := false
if len(p.streams) > 0 {
if p.dynamicAudio {
started = p.switchAudioForFrequency(p.streams[0].frequencyValue)
}
}
if !started {
// Non-dynamic mode or frequency-specific file not yet available.
// Play whatever track is marked Selected in the audio_files table.
var selectedAudio db.AudioFile
if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil {
selectedAudio = db.AudioFile{
@@ -303,6 +310,8 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
}
}
p.audioStarted = started
currentDevice := mixer.CurrentDevice()
if currentDevice == "" {
currentDevice = "default"
@@ -501,8 +510,11 @@ func (p *ScrollerPage) nextLineWords() []string {
s.wordIndex = 0
s.colorIndex = 0 // Reset color index when proclamation restarts
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
if p.dynamicAudio {
next := &p.streams[p.streamIndex]
p.switchAudioForFrequency(next.frequencyValue)
result := p.switchAudioForFrequency(next.frequencyValue)
p.audioStarted = result || next.frequencyValue == 0
}
}
}
@@ -633,6 +645,20 @@ func (p *ScrollerPage) HandleEvent(event *sdl.Event) error {
func (p *ScrollerPage) Update(dt float32) error {
p.updateAutoVelocity(dt)
// In dynamic mode, retry audio switch every 5 seconds until the
// frequency-specific file is found. In non-dynamic mode the selected
// file plays continuously — nothing to retry.
if p.dynamicAudio && !p.audioStarted {
p.audioRetryTimer += dt
if p.audioRetryTimer >= 5.0 {
p.audioRetryTimer = 0
if len(p.streams) > 0 {
freq := p.streams[p.streamIndex].frequencyValue
p.audioStarted = p.switchAudioForFrequency(freq) || freq == 0
}
}
}
p.scrollOffset += dt * p.velocity
move := int32(p.scrollOffset)
if move == 0 {