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/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/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_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) - } - } - }) - } -} 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 ecb9a08..ff7005d 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -15,53 +15,45 @@ 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}, - {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 { - // 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) + audioTracks []db.AudioTrack + selectedFileIndex int - // Volume row VolumeLabel StringItem volumePercent StringItem volumeBarX int32 @@ -69,25 +61,42 @@ 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 ────────────────────────────────────────────────────────────────────── + sectionHeader StringItem + + scrollOffset int + maxVisible int + listItemMargin int32 + listAreaTop int32 + listAreaBottom int32 + listItemHeight int32 + scrollTrackWidth int32 + scrollTrackX int32 + scrollTrackY int32 + scrollTrackH int32 + + dotSize float32 + + listLeftX int32 +} func (p *AudioOptions) Init(ctx *PageCtx) error { var err error p.verticalMargin = int32(20) + p.listItemMargin = 6 p.focusState = focusToggle + p.scrollOffset = 0 + p.dotSize = 8.0 + p.scrollTrackWidth = 30 + p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth - // Font fm := fontManager.Get() font, ok := fm.GetFont("SpaceMonoNerdFont-Regular") if !ok { @@ -95,7 +104,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 +127,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 +142,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 +151,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) + if err = p.seedAudioTracks(); err != nil { + logger.Error("failed to seed audio tracks: %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 +176,26 @@ 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 + 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) @@ -197,26 +204,23 @@ 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) + 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) 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,40 +229,33 @@ func (p *AudioOptions) updateSelectedIndex() { return } - // Find the audio file with Selected = true - var selected db.AudioFile + var selected db.AudioTrack 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" + for i, track := range p.audioTracks { + if track.ID == selected.ID { + 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 +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 } -// ── Audio status toggle ─────────────────────────────────────────────────────── - func (p *AudioOptions) CreateAudioStatusItem() error { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { @@ -280,9 +277,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,19 +284,14 @@ 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) + p.Context.DbPtr.Model(&db.AudioTrack{}).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) + 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) } } @@ -313,8 +302,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) @@ -347,8 +334,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) @@ -358,23 +345,18 @@ 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 { 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 @@ -392,25 +374,64 @@ 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 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 + p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2 + p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H + + p.listItemHeight = p.audioFileTextures[0].H + listTop := p.sectionHeader.Y - p.listItemMargin + listBottomMargin := int32(12) + + listH := listTop - listBottomMargin + if listH < p.listItemHeight { + listH = p.listItemHeight } - // Reposition the triangle cursor for whatever row currently has focus + p.listAreaTop = listTop + p.listAreaBottom = listBottomMargin + + itemStep := p.listItemHeight + p.listItemMargin + n := len(p.audioFileTextures) + p.maxVisible = int(listH / itemStep) + if p.maxVisible < 1 { + p.maxVisible = 1 + } + if p.maxVisible > n { + p.maxVisible = n + } + + var maxItemW int32 + for i := range p.audioFileTextures { + w := p.audioFileTextures[i].W + if w > maxItemW { + maxItemW = w + } + } + p.listLeftX = p.ScreenCenterX - maxItemW/2 + + firstVisibleY := p.listAreaTop - p.listItemHeight + for i := range p.audioFileTextures { + item := &p.audioFileTextures[i] + item.X = p.listLeftX + item.Y = firstVisibleY - int32(i-p.scrollOffset)*itemStep + } + + maxScroll := n - p.maxVisible + if maxScroll < 0 { + maxScroll = 0 + } + if p.scrollOffset > maxScroll { + p.scrollOffset = maxScroll + } + + p.scrollTrackY = p.listAreaBottom + p.scrollTrackH = p.listAreaTop - p.listAreaBottom + p.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 +449,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 +461,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 +501,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 +545,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 +553,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 +592,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 +618,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 +654,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,24 +664,55 @@ 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 + p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color) + + // List background — centered width matching widest item + padding + var maxItemW int32 for i := range p.audioFileTextures { - item := &p.audioFileTextures[i] - color := colors.White - if i == p.selectedFileIndex { - color = colors.Green + w := p.audioFileTextures[i].W + if w > maxItemW { + maxItemW = w } - if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), color); err != nil { + } + bgPad := int32(16) + bgW := maxItemW + 2*bgPad + bgLeft := float32(p.ScreenCenterX) - float32(bgW)/2 + bgTop := float32(p.listAreaBottom) + bgBottom := float32(p.listAreaTop) + listBgColor := sdl.Color{R: 255, G: 255, B: 255, A: 12} + p.RenderSolidColorQuad(p.solidShader, + mgl32.Vec2{bgLeft, bgTop}, + mgl32.Vec2{float32(bgW), bgBottom - bgTop}, + listBgColor) + + // Audio file list (visible window only) + for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.audioFileTextures); i++ { + item := &p.audioFileTextures[i] + clr := colors.White + if i == p.selectedFileIndex { + clr = colors.Green + } + + if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), clr); err != nil { return fmt.Errorf("render audio file %d: %w", i, err) } + + if i == p.selectedFileIndex { + dotX := float32(item.X) - p.dotSize - 6 + dotY := float32(item.Y) + float32(item.H)/2 - p.dotSize/2 + p.RenderSolidColorQuad(p.solidShader, + mgl32.Vec2{dotX, dotY}, + mgl32.Vec2{p.dotSize, p.dotSize}, + colors.Green) + } } - // Spinning triangle cursor — always on top + p.renderScrollbar() + if err := p.renderCursor(); err != nil { return fmt.Errorf("render cursor: %w", err) } @@ -616,14 +720,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..8ed6e0a 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_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 { 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