Add audio list
This commit is contained in:
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
started = p.switchAudioForFrequency(p.streams[0].frequencyValue)
|
||||
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)
|
||||
next := &p.streams[p.streamIndex]
|
||||
p.switchAudioForFrequency(next.frequencyValue)
|
||||
if p.dynamicAudio {
|
||||
next := &p.streams[p.streamIndex]
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user