From 6dda46288473eff065d2257f6c36a4b2abe468bc Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 29 Jul 2026 14:46:11 -0500 Subject: [PATCH 1/4] Add audio selection --- flake.nix | 14 +- internal/page/AudioOptions.go | 287 +++++++++++++++++++++++----------- internal/page/home.go | 2 +- scripts/setup-debian.sh | 71 +++++++++ 4 files changed, 274 insertions(+), 100 deletions(-) create mode 100755 scripts/setup-debian.sh diff --git a/flake.nix b/flake.nix index 5ded085..8917af7 100644 --- a/flake.nix +++ b/flake.nix @@ -18,7 +18,7 @@ let pkgs = import nixpkgs { inherit system; - config.allowUnfreePredicate = pkg: (nixpkgs.lib.getName pkg) == "rhemawave"; + config.allowUnfreePredicate = pkg: (nixpkgs.lib.getName pkg) == "radiantwave"; }; buildInputs = with pkgs; [ @@ -27,8 +27,8 @@ SDL2_image SDL2_mixer sqlite - dbus ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + dbus libGL wayland wayland-protocols @@ -36,15 +36,7 @@ libdecor libx11 libxext - ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (with pkgs.darwin.apple_sdk.frameworks; [ - OpenGL - CoreAudio - CoreFoundation - CoreServices - ForceFeedback - IOKit - AppKit - ]); + ]; # Darwin: system SDK provides OpenGL, AppKit, etc. automatically nativeBuildInputs = with pkgs; [ go diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go index ecb9a08..aa48357 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -15,11 +15,8 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) -// focusToggle is the sentinel focusState value meaning the audio on/off toggle -// 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. 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}, @@ -40,28 +37,22 @@ var AudioFileList = []db.AudioFile{ const triangleSize = float32(16) type AudioOptions struct { - // Page Attributes Base font *fontManager.FontEntry textShader *shaderManager.Shader solidShader *shaderManager.Shader - // Page Textures title StringItem prompt StringItem - // Layout verticalMargin int32 ItemScale float32 - // Audio toggle AudioStatusToggle StringItem - // Audio file list — index 0 = "Dynamic Audio", index 1+ = AudioFileList entries audioFileTextures []StringItem - selectedFileIndex int // which item is currently selected (highlights green) + selectedFileIndex int - // Volume row VolumeLabel StringItem volumePercent StringItem volumeBarX int32 @@ -69,25 +60,46 @@ type AudioOptions struct { volumeBarW int32 volumeBarH int32 - // Triangle cursor triVAO uint32 triVBO uint32 triX float32 triCenterY float32 spinElapsed float32 - // Focus: focusToggle (-1) = toggle row, 0..n-1 = audio file list item focusState int -} -// ── Init ────────────────────────────────────────────────────────────────────── + // 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 - // Font fm := fontManager.Get() font, ok := fm.GetFont("SpaceMonoNerdFont-Regular") if !ok { @@ -95,7 +107,6 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { } p.font = font - // Shaders sm := shaderManager.Get() p.textShader, ok = sm.Get("text") if !ok { @@ -119,14 +130,12 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { titleScale := float32(64) / float32(baseFontSize) p.ItemScale = float32(32) / float32(baseFontSize) - // Title p.title, err = NewStringItem("Audio Options", p.font, colors.White) if err != nil { return fmt.Errorf("creating title: %w", err) } p.title.Scale(titleScale) - // Prompt p.prompt, err = NewStringItem( "Enter to select -- Up/Down to navigate -- Left/Right to change volume -- Backspace to go back", p.font, colors.White, @@ -136,7 +145,6 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { } p.prompt.Scale(p.ItemScale) - // Volume row p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White) if err != nil { return fmt.Errorf("creating volume label: %w", err) @@ -146,20 +154,16 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { logger.Error("failed to create VolumePercentItem in AudioOptions.Init()") } - // Audio toggle if err = p.CreateAudioStatusItem(); err != nil { logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") } - // 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, -0.5, 0.5, @@ -175,20 +179,22 @@ 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 } -// 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) @@ -197,7 +203,6 @@ func (p *AudioOptions) buildAudioFileList() { 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 { @@ -208,15 +213,13 @@ func (p *AudioOptions) buildAudioFileList() { 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 + p.selectedFileIndex = 0 return } @@ -225,27 +228,22 @@ func (p *AudioOptions) updateSelectedIndex() { 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" + p.selectedFileIndex = i + 1 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 if err := p.Context.DbPtr.Find(&existing).Error; err != nil { @@ -257,8 +255,6 @@ func (p *AudioOptions) seedAudioFiles() error { return nil } -// ── Audio status toggle ─────────────────────────────────────────────────────── - func (p *AudioOptions) CreateAudioStatusItem() error { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { @@ -280,9 +276,6 @@ func (p *AudioOptions) CreateAudioStatusItem() error { return nil } -// ── 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 { @@ -290,18 +283,13 @@ func (p *AudioOptions) selectAudioFile(index int) { } 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 + fileIndex := index - 1 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) } } @@ -313,8 +301,6 @@ func (p *AudioOptions) selectAudioFile(index int) { p.selectedFileIndex = index } -// ── Volume ──────────────────────────────────────────────────────────────────── - func (p *AudioOptions) CreateVolumePercentItem() error { if p.volumePercent.ID != 0 { gl.DeleteTextures(1, &p.volumePercent.ID) @@ -358,8 +344,6 @@ func (p *AudioOptions) UpdateVolume(level int) { p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2 } -// ── Layout ──────────────────────────────────────────────────────────────────── - func (p *AudioOptions) layout() { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { @@ -392,25 +376,72 @@ func (p *AudioOptions) layout() { p.volumePercent.X = barX + barW + gap p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2 - // Audio toggle — centred below the volume row + // Audio toggle p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H - // Audio file list — each item centred and stacked below the toggle - nextY := p.AudioStatusToggle.Y - p.verticalMargin - 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 + // 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 } - // Reposition the triangle cursor for whatever row currently has focus + 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() } -// updateCursorPosition moves triX / triCenterY to sit just left of the -// focused row. Call this any time focusState changes or layout() reruns. func (p *AudioOptions) updateCursorPosition() { cursorGap := int32(12) @@ -428,8 +459,6 @@ func (p *AudioOptions) updateCursorPosition() { p.triCenterY = float32(item.Y) + float32(item.H)/2 } -// ── Toggle ──────────────────────────────────────────────────────────────────── - func (p *AudioOptions) ToggleAudio() { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { @@ -442,32 +471,38 @@ func (p *AudioOptions) ToggleAudio() { p.layout() } -// ── Events ──────────────────────────────────────────────────────────────────── - 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 < len(p.audioFileTextures) { + } else if p.focusState >= 0 && p.focusState < n { p.selectAudioFile(p.focusState) p.updateCursorPosition() } case sdl.K_DOWN: if p.focusState == focusToggle { - if len(p.audioFileTextures) > 0 { + 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() } - } else if p.focusState < len(p.audioFileTextures)-1 { - p.focusState++ - p.updateCursorPosition() } case sdl.K_UP: @@ -476,7 +511,35 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error { p.updateCursorPosition() } else if p.focusState > 0 { p.focusState-- - p.updateCursorPosition() + 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: @@ -492,8 +555,6 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error { return nil } -// ── Update ──────────────────────────────────────────────────────────────────── - func (p *AudioOptions) Update(dt float32) error { p.spinElapsed += dt if p.spinElapsed >= 1.0 { @@ -502,8 +563,6 @@ func (p *AudioOptions) Update(dt float32) error { return nil } -// ── Render helpers ──────────────────────────────────────────────────────────── - func (p *AudioOptions) renderVolumeBar() error { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { @@ -543,7 +602,6 @@ func (p *AudioOptions) renderVolumeBar() error { return nil } -// renderCursor draws the spinning triangle cursor next to the focused row. func (p *AudioOptions) renderCursor() error { scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed))) @@ -570,7 +628,33 @@ func (p *AudioOptions) renderCursor() error { return nil } -// ── Render ──────────────────────────────────────────────────────────────────── +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 { @@ -580,7 +664,6 @@ func (p *AudioOptions) Render() error { return fmt.Errorf("render prompt: %w", err) } - // Volume row 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) } @@ -591,23 +674,52 @@ func (p *AudioOptions) Render() error { return fmt.Errorf("render volume percent: %w", err) } - // Toggle 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) } - // Audio file list - for i := range p.audioFileTextures { + // 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] - color := colors.White + clr := colors.White if i == p.selectedFileIndex { - color = colors.Green + clr = colors.Green } - if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), color); err != nil { + + 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) @@ -616,14 +728,13 @@ func (p *AudioOptions) Render() error { return nil } -// ── Destroy ─────────────────────────────────────────────────────────────────── - 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) } diff --git a/internal/page/home.go b/internal/page/home.go index 087828b..b8d30d8 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -13,7 +13,7 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) -var GitVersion = "07112026-1710" +var GitVersion = "07292026_1445" type Home struct { Base diff --git a/scripts/setup-debian.sh b/scripts/setup-debian.sh new file mode 100755 index 0000000..0ffc806 --- /dev/null +++ b/scripts/setup-debian.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "=== Debian dev environment setup ===" + +# Update package index +sudo apt-get update + +# --- Core dev & build tools --- +sudo apt-get install -y \ + curl \ + g++ \ + gcc \ + git \ + make \ + cmake \ + ninja-build \ + pkg-config + +# --- SDL2 and all official extension libraries --- +sudo apt-get install -y \ + libsdl2-dev \ + libsdl2-mixer-dev \ + libsdl2-net-dev \ + libsdl2-ttf-dev \ + libsdl2-image-dev \ + libsdl2-gfx-dev + +# --- SDL3 (available in Debian testing/sid, but not bookworm) --- +# If this fails, SDL3 isn't in your Debian release yet. +# See https://github.com/libsdl-org/SDL for building from source. +sudo apt-get install -y libsdl3-dev || echo "SDL3 not found in apt (expected on older Debian releases)" + +# --- Go --- +if ! command -v go &>/dev/null; then + echo "Installing Go..." + GO_VERSION=$(curl -sL https://go.dev/VERSION?m=text | head -1) + GO_TAR="${GO_VERSION}.linux-amd64.tar.gz" + curl -sLO "https://go.dev/dl/${GO_TAR}" + sudo rm -rf /usr/local/go + sudo tar -C /usr/local -xzf "${GO_TAR}" + rm "${GO_TAR}" + export PATH=$PATH:/usr/local/go/bin + if ! grep -q '/usr/local/go/bin' "$HOME/.profile" 2>/dev/null; then + echo 'export PATH=$PATH:/usr/local/go/bin' >> "$HOME/.profile" + fi +else + echo "Go already installed: $(go version)" +fi + +# --- Nix (multi-user install) --- +if ! command -v nix &>/dev/null; then + echo "Installing Nix..." + sh <(curl -sL https://nixos.org/nix/install) --daemon + echo "Nix installed. You may need to log out and back in, or run:" + echo " . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh" +else + echo "Nix already installed: $(nix --version)" +fi + +echo "" +echo "=== Setup complete ===" +echo "Installed: gcc, g++, make, cmake, ninja, pkg-config" +echo "Installed: SDL2 + mixer/net/ttf/image/gfx" +echo "Installed: Go $(go version 2>/dev/null || echo '(check PATH)')" +echo "Installed: Nix $(nix --version 2>/dev/null || echo '(restart shell)')" +if dpkg -l libsdl3-dev &>/dev/null; then + echo "Installed: SDL3" +else + echo "SDL3: not in apt for this release (install from https://github.com/libsdl-org/SDL)" +fi From 7a3b6a66b72f600878d5e89e7c9d854c69a56a55 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 29 Jul 2026 14:57:32 -0500 Subject: [PATCH 2/4] Remove wpctl parsing tests (TestParseSinkLine, TestParseSinks) These tests cover parseSinkLine and parseSinks, which parse the output of wpctl status. Both functions will be removed in the next commit as part of migrating away from PipeWire/wpctl to pure SDL_mixer volume control. --- internal/mixer/mixer_internal_test.go | 167 -------------------------- 1 file changed, 167 deletions(-) diff --git a/internal/mixer/mixer_internal_test.go b/internal/mixer/mixer_internal_test.go index 0e97ef7..8fc8469 100644 --- a/internal/mixer/mixer_internal_test.go +++ b/internal/mixer/mixer_internal_test.go @@ -142,171 +142,4 @@ func TestPlayDoesNotDeadlockWhenUninitialized(t *testing.T) { } } -func TestParseSinkLine(t *testing.T) { - tests := []struct { - name string - input string - wantOK bool - wantID int - wantName string - wantVol float64 - wantDef bool - }{ - { - name: "default sink with volume", - input: "*51. USB Audio [vol: 1.00]", - wantOK: true, - wantID: 51, - wantName: "USB Audio", - wantVol: 1.00, - wantDef: true, - }, - { - name: "non-default sink with volume", - input: "33. Built-in Audio Analog Stereo [vol: 0.65]", - wantOK: true, - wantID: 33, - wantName: "Built-in Audio Analog Stereo", - wantVol: 0.65, - wantDef: false, - }, - { - name: "default sink with muted tag", - input: "*51. USB Audio [vol: 0.50 MUTED]", - wantOK: true, - wantID: 51, - wantName: "USB Audio", - wantVol: 0.50, - wantDef: true, - }, - { - name: "sink without volume field", - input: "33. Built-in Audio Analog Stereo", - wantOK: true, - wantID: 33, - wantName: "Built-in Audio Analog Stereo", - wantVol: 0.0, - wantDef: false, - }, - { - name: "empty string", - input: "", - wantOK: false, - }, - { - name: "no dot separator", - input: "no dot here", - wantOK: false, - }, - { - name: "non-numeric ID", - input: "abc. Some Device [vol: 0.80]", - wantOK: false, - }, - { - name: "zero volume", - input: "1. Muted Device [vol: 0.00]", - wantOK: true, - wantID: 1, - wantName: "Muted Device", - wantVol: 0.0, - wantDef: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sink, ok := parseSinkLine(tt.input) - if ok != tt.wantOK { - t.Fatalf("parseSinkLine(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) - } - if !ok { - return - } - if sink.ID != tt.wantID { - t.Errorf("ID = %d, want %d", sink.ID, tt.wantID) - } - if sink.Name != tt.wantName { - t.Errorf("Name = %q, want %q", sink.Name, tt.wantName) - } - if sink.Volume != tt.wantVol { - t.Errorf("Volume = %f, want %f", sink.Volume, tt.wantVol) - } - if sink.IsDefault != tt.wantDef { - t.Errorf("IsDefault = %v, want %v", sink.IsDefault, tt.wantDef) - } - }) - } -} - -func TestParseSinks(t *testing.T) { - tests := []struct { - name string - input string - wantCount int - wantNames []string - }{ - { - name: "two sinks", - input: `Audio - Sinks: - *51. USB Audio [vol: 1.00] - 33. Built-in Audio Analog Stereo [vol: 0.65] - Sources: - 52. Microphone [vol: 0.80]`, - wantCount: 2, - wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"}, - }, - { - name: "no sinks section", - input: `Audio - Sources: - 52. Microphone [vol: 0.80]`, - wantCount: 0, - }, - { - name: "sinks with tree characters", - input: `Audio - ├─Sinks: - │ ├─*51. USB Audio [vol: 1.00] - │ └─33. Built-in Audio Analog Stereo [vol: 0.65] - └─Sources: - └─52. Microphone [vol: 0.80]`, - wantCount: 2, - wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"}, - }, - { - name: "empty input", - input: ``, - wantCount: 0, - }, - { - name: "video section ignored", - input: `Video - Sinks: - 100. HDMI Output [vol: 1.00] -Audio - Sinks: - 51. USB Audio [vol: 1.00]`, - wantCount: 1, - wantNames: []string{"USB Audio"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sinks, err := parseSinks([]byte(tt.input)) - if err != nil { - t.Fatalf("parseSinks returned error: %v", err) - } - if len(sinks) != tt.wantCount { - t.Fatalf("got %d sinks, want %d", len(sinks), tt.wantCount) - } - for i, name := range tt.wantNames { - if sinks[i].Name != name { - t.Errorf("sink[%d].Name = %q, want %q", i, sinks[i].Name, name) - } - } - }) - } -} From 3480e7594a3bab78b83f637e90da9f49cb3cd8be Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 29 Jul 2026 14:58:15 -0500 Subject: [PATCH 3/4] Replace wpctl volume control with SDL_mixer VolumeMusic Remove all PipeWire/wpctl system calls (SetDefaultSinkVolume, listSinkIDs, listSinks, SetAllSinkVolumes, parseSinks, parseSinkLine, Sink struct). Volume is now managed entirely through SDL_mixer's VolumeMusic(), making audio fully system-agnostic. Changes: - mixer.go: delete ~170 lines of wpctl code; add SetVolume() public API using mix.VolumeMusic(math.Round(level * MAX_VOLUME)) - Init/Play/SwitchDevice set volume inline (they already hold the mutex) - Play() now uses stored currentVolume instead of hardcoded MAX_VOLUME - AudioOptions: SetAllSinkVolumes -> SetVolume - mixer_test.go: add TestSetVolume (validation + error path coverage) --- internal/mixer/mixer.go | 229 ++++------------------------------ internal/mixer/mixer_test.go | 33 +++++ internal/page/AudioOptions.go | 4 +- 3 files changed, 62 insertions(+), 204 deletions(-) diff --git a/internal/mixer/mixer.go b/internal/mixer/mixer.go index 5d4f086..00b225f 100644 --- a/internal/mixer/mixer.go +++ b/internal/mixer/mixer.go @@ -1,14 +1,9 @@ -// internal/audio/mixer/mixer.go package mixer import ( - "bufio" - "bytes" "errors" "fmt" - "os/exec" - "strconv" - "strings" + "math" "sync" "github.com/veandco/go-sdl2/mix" @@ -25,9 +20,10 @@ var ( currentMusic *mix.Music currentPath string - currentLoops int // 0 = once, -1 = forever + currentLoops int + + currentVolume float64 - // Config knobs (could be made public or read from your config) defaultFreq = 48000 defaultFormat = uint16(sdl.AUDIO_F32) defaultChannels = 2 @@ -37,13 +33,6 @@ var ( refreshCacheCallback func() ) -type Sink struct { - ID int - Name string - Volume float64 - IsDefault bool -} - // Init initializes SDL audio + SDL_mixer. Safe to call multiple times. func Init(cfg *db.AppConfig, onUpdate func()) error { mu.Lock() @@ -58,7 +47,6 @@ func Init(cfg *db.AppConfig, onUpdate func()) error { return fmt.Errorf("SDL audio init failed: %w", err) } - // FIX 1: Initialize MP3 and OGG decoders err := mix.Init(mix.INIT_MP3 | mix.INIT_OGG) if err != nil { logger.Error("Failed to initialize MP3/OGG support: %v", err) @@ -73,7 +61,9 @@ func Init(cfg *db.AppConfig, onUpdate func()) error { return err } - SetAllSinkVolumes(cfg.SavedVolume) + currentVolume = cfg.SavedVolume + v := int(math.Round(currentVolume * float64(mix.MAX_VOLUME))) + mix.VolumeMusic(v) inited = true return nil @@ -83,8 +73,6 @@ func Init(cfg *db.AppConfig, onUpdate func()) error { func openDeviceLocked(name string) error { if err := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, name, defaultAllow); err != nil { if name != "" { - // Log the failure of the requested device - // Fallback to default device if err2 := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, "", defaultAllow); err2 != nil { return fmt.Errorf("failed to open device %q and default device: %w", name, err2) @@ -106,16 +94,13 @@ func openDeviceLocked(name string) error { // Play loads a WAV (as music) and plays it. If loopForever is true, loops forever. // If something is already playing, it stops and replaces it. func Play(path string, loopForever bool, cfg *db.AppConfig) error { - // Check if audio is enabled or disabled if !cfg.AudioEnabled { return nil } - // Lock the mutex mu.Lock() defer mu.Unlock() - // Check if mixer has been initialized and that a device is open if !inited { logger.Error("audio mixer not initialized") return errors.New("audio mixer not initialized") @@ -130,19 +115,16 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error { return errors.New("empty audio path") } - // Stop and free any prior music mix.HaltMusic() if currentMusic != nil { currentMusic.Free() currentMusic = nil } - // Load the music file m, err := mix.LoadMUS(path) if err != nil { return fmt.Errorf("LoadMUS(%q): %w", path, err) } - // Set current music to the loaded music file currentMusic = m currentPath = path if loopForever { @@ -151,9 +133,9 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error { currentLoops = 0 } - mix.VolumeMusic(mix.MAX_VOLUME) - volume := mix.VolumeMusic(-1) - logger.Info("Current volume is %d", volume) + v := int(math.Round(currentVolume * float64(mix.MAX_VOLUME))) + mix.VolumeMusic(v) + logger.Info("Current volume is %d", v) if err := currentMusic.Play(currentLoops); err != nil { currentMusic.Free() @@ -174,7 +156,7 @@ func Stop(fadeMs int, cfg *db.AppConfig) { } if fadeMs > 0 && mix.PlayingMusic() { - _ = mix.FadeOutMusic(fadeMs) // async fade; we’ll still free below + _ = mix.FadeOutMusic(fadeMs) } mix.HaltMusic() @@ -196,59 +178,24 @@ func IsPlaying() bool { return mix.PlayingMusic() } -// SetDefaultSinkVolume sets volume 0.0–1.0 on the default sink -func SetDefaultSinkVolume(level float64) error { +// SetVolume sets the SDL_mixer music volume to level (0.0–1.0). +func SetVolume(level float64) error { if level < 0 || level > 1 { return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level) } - pct := fmt.Sprintf("%.2f", level) - cmd := exec.Command("wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", pct) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("wpctl failed: %w\noutput: %s", err, out) - } - return nil -} - -// listSinkIDs returns just the numeric IDs of all audio sinks. -func listSinkIDs() ([]int, error) { - sinks, err := listSinks() - if err != nil { - return nil, err - } - ids := make([]int, len(sinks)) - for i, s := range sinks { - ids[i] = s.ID - } - return ids, nil -} - -// listSinks returns full Sink info for all audio sinks. -func listSinks() ([]Sink, error) { - out, err := exec.Command("wpctl", "status").Output() - if err != nil { - return nil, fmt.Errorf("wpctl status: %w", err) - } - return parseSinks(out) -} - -// SetAllSinkVolumes sets volume on every audio sink found via wpctl status -func SetAllSinkVolumes(level float64) error { - ids, err := listSinkIDs() - if err != nil { - return err - } - pct := fmt.Sprintf("%.2f", level) - for _, id := range ids { - cmd := exec.Command("wpctl", "set-volume", strconv.Itoa(id), pct) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("wpctl set-volume %d failed: %w\noutput: %s", id, err, out) - } + mu.Lock() + defer mu.Unlock() + if !inited { + return errors.New("audio mixer not initialized") } + currentVolume = level + v := int(math.Round(level * float64(mix.MAX_VOLUME))) + mix.VolumeMusic(v) return nil } // ConvertVolume converts a desired volume percentage (0%, 25%, 33%, 90%, etc) to -// the float64 value needed to pass to SetAllSinkVolumes 0..1.0 +// the float64 value needed to pass to SetVolume 0..1.0 // Percentages should be passed as integers. 25% -> 25 func ConvertVolume(volPerc int) float64 { if volPerc == 0 { @@ -257,116 +204,6 @@ func ConvertVolume(volPerc int) float64 { return float64(volPerc) / 100.0 } -// parseSinks parses the output of `wpctl status` and extracts audio sinks. -func parseSinks(data []byte) ([]Sink, error) { - var sinks []Sink - - scanner := bufio.NewScanner(bytes.NewReader(data)) - - inAudioSection := false - inSinksSection := false - - for scanner.Scan() { - line := scanner.Text() - stripped := strings.TrimLeft(line, " │├└─") - - // Detect top-level sections: "Audio", "Video", "Settings", etc. - // These are lines with no leading tree characters. - if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "│") { - inAudioSection = strings.TrimSpace(line) == "Audio" - inSinksSection = false - continue - } - - if !inAudioSection { - continue - } - - // Detect subsection headers within Audio - if strings.Contains(stripped, "Sinks:") { - inSinksSection = true - continue - } - // Any other subsection header resets sink parsing - if strings.HasSuffix(strings.TrimSpace(stripped), ":") { - inSinksSection = false - continue - } - - if !inSinksSection { - continue - } - - sink, ok := parseSinkLine(stripped) - if ok { - sinks = append(sinks, sink) - } - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("scanning wpctl output: %w", err) - } - - return sinks, nil -} - -// parseSinkLine parses a single sink entry line like: -// -// "*51. USB Audio [vol: 1.00]" -// "33. Built-in Audio Analog Stereo [vol: 0.65]" -func parseSinkLine(s string) (Sink, bool) { - s = strings.TrimSpace(s) - if s == "" { - return Sink{}, false - } - - var sink Sink - - // Leading '*' marks the default sink - if strings.HasPrefix(s, "*") { - sink.IsDefault = true - s = strings.TrimPrefix(s, "*") - s = strings.TrimSpace(s) - } - - // Expect ". ..." - dotIdx := strings.Index(s, ".") - if dotIdx < 0 { - return Sink{}, false - } - - idStr := strings.TrimSpace(s[:dotIdx]) - id, err := strconv.Atoi(idStr) - if err != nil { - return Sink{}, false - } - sink.ID = id - - rest := strings.TrimSpace(s[dotIdx+1:]) - - // Extract [vol: X.XX] if present - volStart := strings.Index(rest, "[vol:") - if volStart >= 0 { - sink.Name = strings.TrimSpace(rest[:volStart]) - volPart := rest[volStart:] - // "[vol: 0.65]" or "[vol: 0.65 MUTED]" - volPart = strings.TrimPrefix(volPart, "[vol:") - volPart = strings.TrimRight(volPart, "]") - volPart = strings.TrimSpace(volPart) - fields := strings.Fields(volPart) - if len(fields) > 0 { - v, err := strconv.ParseFloat(fields[0], 64) - if err == nil { - sink.Volume = v - } - } - } else { - sink.Name = strings.TrimSpace(rest) - } - - return sink, true -} - // ListDevices returns the list of available output device names (from core SDL). func ListDevices() []string { mu.Lock() @@ -398,20 +235,15 @@ func SwitchDevice(newName string, cfg *db.AppConfig) error { return errors.New("audio mixer not initialized") } - // Log device switch attempt - // Automatically defaults to "system default" - oldDevice := CurrentDevice() // Which device is to be closed - // SDL expects "" for default device, not "system default" - if newName == "" { // Which device is being switched to? + oldDevice := CurrentDevice() + if newName == "" { newName = "" } - // Capture current state wasPlaying := mix.PlayingMusic() savedPath := currentPath savedLoops := currentLoops - // Stop and release current music before reopening device mix.HaltMusic() if currentMusic != nil { currentMusic.Free() @@ -420,34 +252,27 @@ func SwitchDevice(newName string, cfg *db.AppConfig) error { currentPath = "" currentLoops = 0 - // Close and reopen device if deviceOpen { mix.CloseAudio() deviceOpen = false } - // Attempt to open the newName device if err := openDeviceLocked(newName); err != nil { - // If error switching to new device, switch back to the old device - // oldDevice is already "" for default (which openDeviceLocked expects) if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil { - // We should not hit an error when switching back, but if we do... logger.Error("Failed to switch to device: %s", oldDevice) } - // Could not switch to the new device, return the error return err } - // Restore saved volume after reopening device if cfg != nil { - SetAllSinkVolumes(cfg.SavedVolume) + currentVolume = cfg.SavedVolume + v := int(math.Round(cfg.SavedVolume * float64(mix.MAX_VOLUME))) + mix.VolumeMusic(v) } - // Optionally resume previous track if there was one if wasPlaying && savedPath != "" { m, err := mix.LoadMUS(savedPath) if err != nil { - // Don't fail the device switch just because reload failed return fmt.Errorf("device switched, but reloading %q failed: %w", savedPath, err) } currentMusic = m @@ -489,7 +314,7 @@ func Shutdown() { } if inited { - mix.Quit() // Add this to clean up the decoders + mix.Quit() sdl.QuitSubSystem(sdl.INIT_AUDIO) inited = false } diff --git a/internal/mixer/mixer_test.go b/internal/mixer/mixer_test.go index 796f032..d7babf7 100644 --- a/internal/mixer/mixer_test.go +++ b/internal/mixer/mixer_test.go @@ -7,6 +7,39 @@ import ( "rhemawavetech.com/rhemawave/internal/mixer" ) +func TestSetVolume(t *testing.T) { + tests := []struct { + name string + level float64 + wantErr bool + }{ + {"zero volume", 0.0, false}, + {"half volume", 0.5, false}, + {"max volume", 1.0, false}, + {"above range", 1.5, true}, + {"negative", -0.1, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := mixer.SetVolume(tt.level) + if tt.wantErr { + if err == nil { + t.Errorf("SetVolume(%v) = nil, want error", tt.level) + } + return + } + if err != nil { + // For valid input, failure is expected when SDL isn't initialized + // (that's fine — we're testing validation, not SDL integration) + if err.Error() != "audio mixer not initialized" { + t.Errorf("SetVolume(%v) = %v, want 'audio mixer not initialized' or nil", tt.level, err) + } + } + }) + } +} + func TestConvertVolume(t *testing.T) { tests := []struct { name string diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go index aa48357..edd1721 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -333,8 +333,8 @@ func (p *AudioOptions) UpdateVolume(level int) { } config.SavedVolume = newVol p.Context.DbPtr.Save(&config) - if err := mixer.SetAllSinkVolumes(newVol); err != nil { - logger.Error("Error setting allSinkVolumes: %v", err) + if err := mixer.SetVolume(newVol); err != nil { + logger.Error("Error setting volume: %v", err) } if err := p.CreateVolumePercentItem(); err != nil { logger.Error("failed to refresh volume percent item: %v", err) From 91711eef93280ae490c163e31b9d63786616f055 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 29 Jul 2026 16:19:15 -0500 Subject: [PATCH 4/4] Replace AudioFile with AudioTrack, fix download+orphan+MD5 sync, always play original audio fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AudioTrack table replaces AudioFile (Filename, Name, AssignedFrequency, MD5, Selected, Order) — fixes relative-path bug in scrollerPage.go that caused "file not found" for single-track selection. Download goroutine now: - Accepts *gorm.DB and updates AudioTrack.MD5 after each download. - Treats checksums.md5 as source of truth: removes local files and DB records not in the manifest (orphans). - Strips "./" prefix from manifest filenames (GNU md5sum format). AudioOptions.go seeds 14 AudioTrack records matching server checksums.md5. List background quad width matches the widest item (+ padding), centered. scrollerPage.go switchAudioForFrequency falls back to original/selected audio when a frequency has no matching track or file. playOriginalAudio is a no-op on repeat calls to avoid 5-second audio restart loop. Fixes progress display: passes Max=0 during downloads so Home page shows "x / X Files Downloaded" text instead of bogus "100.0%" percentage. Downstream audio switching (DynamicAudio) verifies an AudioTrack exists for the frequency before assigning it to a stream — frequencies without a track (e.g. 528 Hz) gracefully use original audio instead. --- internal/application/application.go | 2 +- internal/db/DB.go | 2 +- internal/db/Schema.go | 14 ++- internal/download/Download.go | 100 +++++++++++----- internal/download/download_test.go | 180 ++++++++++++++++++++++++++++ internal/page/AudioOptions.go | 114 ++++++++---------- internal/page/home.go | 2 +- internal/page/scrollerPage.go | 140 ++++++++++++++-------- 8 files changed, 402 insertions(+), 152 deletions(-) create mode 100644 internal/download/download_test.go diff --git a/internal/application/application.go b/internal/application/application.go index 3610781..0b751fc 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -195,7 +195,7 @@ func Run() { } } - go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, audioDir, "https://files.radiantwavetech.com/audio/") + go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, app.DB, audioDir, "https://files.radiantwavetech.com/audio/") app.runEventLoop() } diff --git a/internal/db/DB.go b/internal/db/DB.go index 0e852a5..2c2676f 100644 --- a/internal/db/DB.go +++ b/internal/db/DB.go @@ -107,6 +107,6 @@ func Migrate(db *gorm.DB) error { &Proclamation{}, &ProclamationCategory{}, &SolfeggioFrequency{}, - &AudioFile{}, + &AudioTrack{}, ) } diff --git a/internal/db/Schema.go b/internal/db/Schema.go index fb19b50..241dac8 100644 --- a/internal/db/Schema.go +++ b/internal/db/Schema.go @@ -50,10 +50,12 @@ type SolfeggioFrequency struct { Value int } -type AudioFile struct { - ID int `gorm:"primaryKey"` - Name string - Path string - Selected bool - Order int +type AudioTrack struct { + ID int `gorm:"primaryKey"` + Filename string + Name string + AssignedFrequency int + MD5 string + Selected bool + Order int } diff --git a/internal/download/Download.go b/internal/download/Download.go index 60c2bd0..363b8f1 100644 --- a/internal/download/Download.go +++ b/internal/download/Download.go @@ -10,6 +10,8 @@ import ( "path/filepath" "strings" + "gorm.io/gorm" + "rhemawavetech.com/rhemawave/internal/db" "rhemawavetech.com/rhemawave/internal/logger" ) @@ -25,36 +27,36 @@ type DownloadStatus struct { // manifest maps filename → expected MD5 hex string type manifest map[string]string -// ─── Server script to generate checksums.md5 ───────────────────────────────── -// -// Run this on your file server whenever you add or replace audio files: -// -// cd /path/to/audio && md5sum *.mp3 > checksums.md5 -// -// That's it. The app will pick up changes automatically on next run. -// ───────────────────────────────────────────────────────────────────────────── - const manifestFilename = "checksums.md5" -// DownloadAudioFiles downloads audio files, skipping any that already exist -// locally with a matching MD5. Files with a wrong checksum are deleted and -// re-downloaded. Progress is sent to progressChan, which is closed on return. -func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlBase string) { +// DownloadAudioFiles downloads audio files using the remote checksums.md5 as +// the source of truth. It: +// - Downloads any file in the manifest that is missing locally. +// - Re-downloads files whose local MD5 differs from the manifest. +// - Removes local files not listed in the manifest (orphans). +// - Removes DB AudioTrack records not listed in the manifest. +// - Updates AudioTrack.MD5 in the database after every successful download. +// +// Progress is sent to progressChan, which is closed on return. +func DownloadAudioFiles(progressChan chan<- DownloadStatus, dbHandle *gorm.DB, destDir string, urlBase string) { defer close(progressChan) - send := func(cur, max int64, msg string, err error) { - progressChan <- DownloadStatus{Cur: cur, Max: max, Msg: msg, Err: err} + var totalDownloaded int64 + var filesDownloaded int + + send := func(cur int64, msg string, err error) { + progressChan <- DownloadStatus{Cur: cur, Max: 0, Msg: msg, Err: err} } - send(0, 0, "Fetching file manifest...", nil) + send(0, "Fetching file manifest...", nil) m, err := fetchManifest(urlBase + manifestFilename) if err != nil { - send(0, 0, "Failed to fetch manifest", err) + send(0, "Failed to fetch manifest", err) return } logger.Info("download: manifest loaded, %d files listed", len(m)) - send(0, 0, "Comparing checksums...", nil) + send(0, "Comparing checksums...", nil) type work struct { filename string @@ -82,19 +84,47 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB } } + // ── Remove local files not in manifest (orphans) ───────────────────── + entries, err := os.ReadDir(destDir) + if err == nil { + for _, entry := range entries { + if entry.IsDir() { + continue + } + if _, ok := m[entry.Name()]; !ok { + orphanPath := filepath.Join(destDir, entry.Name()) + logger.Info("download: removing orphan file %s", orphanPath) + if err := os.Remove(orphanPath); err != nil { + logger.Warning("download: failed to remove orphan %s: %v", orphanPath, err) + } + } + } + } else { + logger.Warning("download: could not read destDir for orphan cleanup: %v", err) + } + + // ── Remove DB records not in manifest ──────────────────────────────── + if dbHandle != nil { + var allTracks []db.AudioTrack + dbHandle.Find(&allTracks) + for _, track := range allTracks { + if _, ok := m[track.Filename]; !ok { + logger.Info("download: removing orphan DB record %s (filename=%s)", track.Name, track.Filename) + dbHandle.Delete(&track) + } + } + } + if len(queue) == 0 { - send(0, 0, "All files are up to date.", nil) + send(0, "All files are up to date.", nil) logger.Info("download: nothing to do") return } total := len(queue) - send(0, 0, fmt.Sprintf("0 / %d Files Downloaded", total), nil) + send(0, fmt.Sprintf("0 / %d Files Downloaded", total), nil) logger.Info("download: %d file(s) to download", total) - totalDownloaded := int64(0) - filesDownloaded := 0 - for _, w := range queue { url := urlBase + w.filename destPath := filepath.Join(destDir, w.filename) @@ -103,12 +133,12 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB downloaded, err := downloadFile(url, destPath, func(n int64) { totalDownloaded += n - send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), nil) + send(totalDownloaded, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), nil) }) if err != nil { logger.Error("download: failed to download %s: %v", w.filename, err) - send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), err) + send(totalDownloaded, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), err) return } @@ -116,24 +146,35 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB if err != nil { logger.Error("download: could not hash %s after download: %v", w.filename, err) os.Remove(destPath) - send(totalDownloaded, 0, "Checksum verification failed", err) + send(totalDownloaded, "Checksum verification failed", err) return } if gotMD5 != w.expectedMD5 { err := fmt.Errorf("checksum mismatch after download: got %s, want %s", gotMD5, w.expectedMD5) logger.Error("download: %s %v", w.filename, err) os.Remove(destPath) - send(totalDownloaded, 0, "Checksum verification failed — file removed", err) + send(totalDownloaded, "Checksum verification failed — file removed", err) return } + // Update MD5 in database + if dbHandle != nil { + var track db.AudioTrack + result := dbHandle.Where("filename = ?", w.filename).First(&track) + if result.Error == nil { + track.MD5 = gotMD5 + dbHandle.Save(&track) + logger.Debug("download: updated MD5 for %s", w.filename) + } + } + filesDownloaded++ statusMsg := fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total) logger.Info("download: %s (%d bytes)", statusMsg, downloaded) - send(totalDownloaded, 0, statusMsg, nil) + send(totalDownloaded, statusMsg, nil) } - send(totalDownloaded, totalDownloaded, "All downloads complete!", nil) + send(totalDownloaded, "All downloads complete!", nil) logger.Info("download: all files downloaded and verified") } @@ -167,6 +208,7 @@ func fetchManifest(url string) (manifest, error) { } hash := strings.TrimSpace(parts[0]) name := strings.TrimSpace(parts[1]) + name = strings.TrimPrefix(name, "./") m[name] = hash } diff --git a/internal/download/download_test.go b/internal/download/download_test.go new file mode 100644 index 0000000..c98aa58 --- /dev/null +++ b/internal/download/download_test.go @@ -0,0 +1,180 @@ +package download_test + +import ( + "crypto/md5" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "rhemawavetech.com/rhemawave/internal/db" + "rhemawavetech.com/rhemawave/internal/download" +) + +func md5Hex(data []byte) string { + return fmt.Sprintf("%x", md5.Sum(data)) +} + +// TestDownloadFileHashChanged verifies that when the server manifest reports a +// new MD5 for an existing local file, the old file is removed, the new version +// is downloaded, and the database record is updated with the new hash. +func TestDownloadFileHashChanged(t *testing.T) { + audioDir := t.TempDir() + dbDir := t.TempDir() + + gdb, err := gorm.Open(sqlite.Open(filepath.Join(dbDir, "test.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := gdb.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + + if err := gdb.AutoMigrate(&db.AudioTrack{}); err != nil { + t.Fatal(err) + } + + oldContent := []byte("this is the OLD version of the audio file") + oldMD5 := md5Hex(oldContent) + newContent := []byte("this is the NEW version of the audio file") + newMD5 := md5Hex(newContent) + + track := db.AudioTrack{ + Filename: "174Hz.wav", + Name: "174 Hz", + AssignedFrequency: 174, + MD5: oldMD5, + Selected: true, + Order: 2, + } + if err := gdb.Create(&track).Error; err != nil { + t.Fatal(err) + } + + oldPath := filepath.Join(audioDir, "174Hz.wav") + if err := os.WriteFile(oldPath, oldContent, 0644); err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/checksums.md5": + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "%s 174Hz.wav\n", newMD5) + case "/174Hz.wav": + w.WriteHeader(http.StatusOK) + w.Write(newContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + progress := make(chan download.DownloadStatus, 100) + done := make(chan struct{}) + go func() { + download.DownloadAudioFiles(progress, gdb, audioDir, server.URL+"/") + close(done) + }() + for range progress { + } + <-done + + newPath := filepath.Join(audioDir, "174Hz.wav") + gotContent, err := os.ReadFile(newPath) + if err != nil { + t.Fatalf("reading new file: %v", err) + } + if string(gotContent) != string(newContent) { + t.Errorf("new file content = %q, want %q", string(gotContent), string(newContent)) + } + + var updated db.AudioTrack + if err := gdb.First(&updated, track.ID).Error; err != nil { + t.Fatalf("fetching updated track: %v", err) + } + if updated.MD5 != newMD5 { + t.Errorf("DB MD5 = %q, want %q", updated.MD5, newMD5) + } +} + +// TestDownloadOrphanRemoval verifies that files and DB records not in the +// manifest are removed. +func TestDownloadOrphanRemoval(t *testing.T) { + audioDir := t.TempDir() + dbDir := t.TempDir() + + gdb, err := gorm.Open(sqlite.Open(filepath.Join(dbDir, "test.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := gdb.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + + if err := gdb.AutoMigrate(&db.AudioTrack{}); err != nil { + t.Fatal(err) + } + + orphanPath := filepath.Join(audioDir, "orphan.wav") + if err := os.WriteFile(orphanPath, []byte("orphan"), 0644); err != nil { + t.Fatal(err) + } + + gdb.Create(&db.AudioTrack{Filename: "ghost.wav", Name: "Ghost Track", Order: 99}) + + keptContent := []byte("kept file") + keptMD5 := md5Hex(keptContent) + keptPath := filepath.Join(audioDir, "kept.wav") + if err := os.WriteFile(keptPath, keptContent, 0644); err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/checksums.md5": + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "%s kept.wav\n", keptMD5) + case "/kept.wav": + w.WriteHeader(http.StatusOK) + w.Write(keptContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + progress := make(chan download.DownloadStatus, 100) + done := make(chan struct{}) + go func() { + download.DownloadAudioFiles(progress, gdb, audioDir, server.URL+"/") + close(done) + }() + for range progress { + } + <-done + + if _, err := os.Stat(orphanPath); !os.IsNotExist(err) { + t.Errorf("orphan file %s should have been removed", orphanPath) + } + + var count int64 + gdb.Model(&db.AudioTrack{}).Where("filename = ?", "ghost.wav").Count(&count) + if count != 0 { + t.Errorf("orphan DB record ghost.wav should have been deleted, count=%d", count) + } + + if _, err := os.Stat(keptPath); os.IsNotExist(err) { + t.Errorf("kept file %s should still exist after download", keptPath) + } +} diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go index edd1721..ff7005d 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -17,25 +17,25 @@ import ( 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) +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}, +} + type AudioOptions struct { Base font *fontManager.FontEntry @@ -51,6 +51,7 @@ type AudioOptions struct { AudioStatusToggle StringItem audioFileTextures []StringItem + audioTracks []db.AudioTrack selectedFileIndex int VolumeLabel StringItem @@ -68,10 +69,8 @@ type AudioOptions struct { focusState int - // Section header sectionHeader StringItem - // Scrolling scrollOffset int maxVisible int listItemMargin int32 @@ -83,10 +82,8 @@ type AudioOptions struct { scrollTrackY int32 scrollTrackH int32 - // Selection dot dotSize float32 - // List left-alignment X (same for all items) listLeftX int32 } @@ -158,8 +155,8 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") } - if err = p.seedAudioFiles(); err != nil { - logger.Error("failed to seed audio files: %v", err) + if err = p.seedAudioTracks(); err != nil { + logger.Error("failed to seed audio tracks: %v", err) } p.buildAudioFileList() @@ -195,6 +192,10 @@ func (p *AudioOptions) buildAudioFileList() { } p.audioFileTextures = nil + var tracks []db.AudioTrack + p.Context.DbPtr.Order("`order` ASC").Find(&tracks) + p.audioTracks = tracks + dynamicItem, err := NewStringItem("Dynamic Audio", p.font, colors.White) if err != nil { logger.Error("failed to create Dynamic Audio string item: %v", err) @@ -203,10 +204,10 @@ func (p *AudioOptions) buildAudioFileList() { p.audioFileTextures = append(p.audioFileTextures, dynamicItem) } - for _, af := range AudioFileList { - item, err := NewStringItem(af.Name, p.font, colors.White) + for _, track := range tracks { + item, err := NewStringItem(track.Name, p.font, colors.White) if err != nil { - logger.Error("failed to create audio file string item for %s: %v", af.Name, err) + logger.Error("failed to create audio track string item for %s: %v", track.Name, err) continue } item.Scale(p.ItemScale) @@ -228,14 +229,14 @@ func (p *AudioOptions) updateSelectedIndex() { return } - var selected db.AudioFile + var selected db.AudioTrack 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 { + for i, track := range p.audioTracks { + if track.ID == selected.ID { p.selectedFileIndex = i + 1 return } @@ -244,13 +245,13 @@ func (p *AudioOptions) updateSelectedIndex() { p.selectedFileIndex = 0 } -func (p *AudioOptions) seedAudioFiles() error { - var existing []db.AudioFile +func (p *AudioOptions) seedAudioTracks() error { + var existing []db.AudioTrack if err := p.Context.DbPtr.Find(&existing).Error; err != nil { return err } if len(existing) == 0 { - return p.Context.DbPtr.Save(&AudioFileList).Error + return p.Context.DbPtr.Save(&audioTrackSeed).Error } return nil } @@ -284,13 +285,13 @@ func (p *AudioOptions) selectAudioFile(index int) { if index == 0 { config.DynamicAudio = true - p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false) + p.Context.DbPtr.Model(&db.AudioTrack{}).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) + 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) } } @@ -350,15 +351,12 @@ func (p *AudioOptions) layout() { 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 @@ -376,20 +374,16 @@ func (p *AudioOptions) layout() { 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 @@ -398,7 +392,6 @@ func (p *AudioOptions) layout() { p.listAreaTop = listTop p.listAreaBottom = listBottomMargin - // How many items fit itemStep := p.listItemHeight + p.listItemMargin n := len(p.audioFileTextures) p.maxVisible = int(listH / itemStep) @@ -409,16 +402,15 @@ func (p *AudioOptions) layout() { 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 + w := p.audioFileTextures[i].W + if w > maxItemW { + maxItemW = 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] @@ -426,7 +418,6 @@ func (p *AudioOptions) layout() { 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 @@ -435,7 +426,6 @@ func (p *AudioOptions) layout() { p.scrollOffset = maxScroll } - // Scrollbar track position p.scrollTrackY = p.listAreaBottom p.scrollTrackH = p.listAreaTop - p.listAreaBottom @@ -678,19 +668,25 @@ func (p *AudioOptions) Render() error { 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} + // 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 + } + } bgPad := int32(16) - bgLeft := float32(p.listLeftX - bgPad) + bgW := maxItemW + 2*bgPad + bgLeft := float32(p.ScreenCenterX) - float32(bgW)/2 bgTop := float32(p.listAreaBottom) - bgRight := float32(p.scrollTrackX) 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{bgRight - bgLeft, bgBottom - bgTop}, + mgl32.Vec2{float32(bgW), bgBottom - bgTop}, listBgColor) // Audio file list (visible window only) @@ -705,7 +701,6 @@ func (p *AudioOptions) Render() error { 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 @@ -714,13 +709,10 @@ func (p *AudioOptions) Render() error { 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) } diff --git a/internal/page/home.go b/internal/page/home.go index b8d30d8..8ed6e0a 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -13,7 +13,7 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) -var GitVersion = "07292026_1445" +var GitVersion = "07292026_1618" type Home struct { Base diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index fa80ea5..d287efb 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -38,6 +38,7 @@ type ScrollerPage struct { audioRetryTimer float32 audioStarted bool + originalAudioStarted bool dynamicAudio bool lines *LineDeque @@ -284,34 +285,32 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error { 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 len(p.streams) > 0 && p.dynamicAudio { + p.switchAudioForFrequency(p.streams[0].frequencyValue) + p.audioStarted = true } - 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{ - Name: "OriginalAudio", - Path: filepath.Join(audioDir(), "OriginalAudio.wav"), - Selected: true, - Order: 0, + if !p.audioStarted && !p.dynamicAudio { + // Static track selected in Audio Options. Play whatever track is + // marked Selected in the audio_tracks table. + var selectedTrack db.AudioTrack + if err := p.Context.DbPtr.Where("selected = ?", true).First(&selectedTrack).Error; err != nil { + logger.Warning("No selected audio track found, audio will start when a track is chosen") + } else { + path := filepath.Join(audioDir(), selectedTrack.Filename) + if _, err := os.Stat(path); err == nil { + logger.Info("Playing %s", path) + if err := mixer.Play(path, true, config); err != nil { + logger.Error("Mixer returned an error: %v", err) + } else { + p.audioStarted = true + } + } else { + logger.Warning("Selected audio file %s not found yet (download may still be in progress)", path) } } - - logger.Info("Playing %s", selectedAudio.Path) - if err := mixer.Play(selectedAudio.Path, true, config); err != nil { - logger.Error("Mixer returned an error: %v", err) - } } - p.audioStarted = started - currentDevice := mixer.CurrentDevice() if currentDevice == "" { currentDevice = "default" @@ -438,36 +437,55 @@ func audioDir() string { return filepath.Join(home, ".radiantwave", "audio") } -// audioPathForFrequency builds the on-disk path for a solfeggio frequency's -// audio file, e.g. 396 -> "~/.radiantwave/audio/396Hz.wav". -func (p *ScrollerPage) audioPathForFrequency(freq int) string { - return filepath.Join(audioDir(), fmt.Sprintf("%dHz.wav", freq)) -} - // switchAudioForFrequency swaps the currently playing track to match freq. +// If freq is 0 or no audio track exists for it, falls back to the original +// / selected audio track (so audio never goes silent in Ready state). // -// - freq == 0 ("no specific frequency", i.e. the default proclamation) is -// always a no-op — whatever is playing keeps playing. -// - If freq already matches what's playing, nothing happens. -// - If the matching file doesn't exist on disk yet, the current track -// keeps playing untouched (so missing solfeggio audio never causes -// silence — and starts working automatically once the file is added). -// - Otherwise the current track is stopped immediately and freq's track -// starts right away (hard cut, no fade). -// -// Returns true if freq's track is now playing (either just switched, or -// already was). +// Returns true if audio is now playing (either frequency-specific or fallback). func (p *ScrollerPage) switchAudioForFrequency(freq int) bool { - if freq == 0 { - return false - } - if freq == p.currentAudioFreq { + if freq != 0 && freq == p.currentAudioFreq { return true } - path := p.audioPathForFrequency(freq) + if freq != 0 { + if track, err := p.audioTrackForFrequency(freq); err == nil { + path := filepath.Join(audioDir(), track.Filename) + if _, err := os.Stat(path); err == nil { + config, err := db.GetConfig(p.Context.DbPtr) + if err != nil { + panic(err) + } + mixer.Stop(3000, config) + if err := mixer.Play(path, true, config); err != nil { + logger.Error("Mixer returned an error: %v", err) + return false + } + p.currentAudioFreq = freq + return true + } + } + } + + return p.playOriginalAudio() +} + +// playOriginalAudio plays the selected AudioTrack, or the OriginalAudio track +// if nothing is selected. Returns true if audio started successfully. +func (p *ScrollerPage) playOriginalAudio() bool { + if p.originalAudioStarted { + return true + } + + var track db.AudioTrack + if err := p.Context.DbPtr.Where("selected = ?", true).First(&track).Error; err != nil { + if err := p.Context.DbPtr.Where("assigned_frequency = ?", 0).First(&track).Error; err != nil { + logger.Warning("No audio tracks available") + return false + } + } + path := filepath.Join(audioDir(), track.Filename) if _, err := os.Stat(path); err != nil { - logger.Warning("No audio file for %dHz (%s), keeping current track", freq, path) + logger.Warning("Original audio file not found: %s", path) return false } config, err := db.GetConfig(p.Context.DbPtr) @@ -479,10 +497,17 @@ func (p *ScrollerPage) switchAudioForFrequency(freq int) bool { logger.Error("Mixer returned an error: %v", err) return false } - p.currentAudioFreq = freq + p.originalAudioStarted = true + p.currentAudioFreq = 0 return true } +func (p *ScrollerPage) audioTrackForFrequency(freq int) (db.AudioTrack, error) { + var track db.AudioTrack + err := p.Context.DbPtr.Where("assigned_frequency = ?", freq).First(&track).Error + return track, err +} + // nextLineWords gets words for the current line and advances proclamation stream when needed. func (p *ScrollerPage) nextLineWords() []string { if len(p.streams) == 0 { @@ -645,20 +670,29 @@ 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 { + // Periodically try to switch to the frequency-specific file in case + // it was just downloaded (only when the stream's frequency differs from + // whatever is currently playing). + if p.dynamicAudio && len(p.streams) > 0 { 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 + freq := p.streams[p.streamIndex].frequencyValue + if freq != 0 && freq != p.currentAudioFreq { + p.switchAudioForFrequency(freq) } } } + // Non-dynamic mode: retry selected track in case download just finished. + if !p.dynamicAudio && !p.audioStarted { + p.audioRetryTimer += dt + if p.audioRetryTimer >= 5.0 { + p.audioRetryTimer = 0 + p.audioStarted = p.playOriginalAudio() + } + } + p.scrollOffset += dt * p.velocity move := int32(p.scrollOffset) if move == 0 {