diff --git a/flake.nix b/flake.nix index 3259b18..5ded085 100644 --- a/flake.nix +++ b/flake.nix @@ -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; }; }; diff --git a/internal/db/Schema.go b/internal/db/Schema.go index 0d7f6bb..fb19b50 100644 --- a/internal/db/Schema.go +++ b/internal/db/Schema.go @@ -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? } diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go index c38a093..ecb9a08 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -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) diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index 406f79b..fa80ea5 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -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 {