Add audio device switching UI

This commit is contained in:
NeilClack
2026-08-08 00:12:57 -05:00
parent b54dbca2d5
commit 2212cc9b78
11 changed files with 1528 additions and 455 deletions
+190
View File
@@ -0,0 +1,190 @@
# Audio output device picker — design suggestions
Goal: give the kiosk operator a way to select which audio output sink
RadiantWave plays through, persist it across reboots, and have the choice
actually take effect — without depending on SDL's unstable device
enumeration.
## Binaries available on the kiosk system closure
The production kiosk closure ships these audio tools (do not assume anything
else):
- `wireplumber` 0.5.15 → **`wpctl`** (status, list, set-default, set-volume,
set-mute, settings, inspect). Installed in kiosk `systemPackages`.
- `pipewire` 1.6.8 → `pw-cli`, **`pw-dump`** (JSON), `pw-play`, `pw-cat`,
`pw-metadata`, `pw-link`, `pw-mon`, `pw-top`.
- **No `pactl`/`pacmd`.** The pipewire-pulse daemon is present
(`services.pipewire.pulse.enable = true`) but the libpulse client tools are
not in the closure. Avoid PulseAudio tooling.
`wpctl` is the right tool: it is high-level, already on the system, and talks
directly to WirePlumber in the kiosk user session.
## Recommended model: route through the PipeWire default sink
Keep the app opening only SDL's **default** device (`mix.OpenAudioDevice("")`)
and change which sink is the default with WirePlumber. SDL's default-device
path already follows the PipeWire default sink, so this makes "selecting an
output device" a WirePlumber concern instead of an SDL concern.
Why not SDL enumeration / named opens (current dead code):
- The app links `sdl2-compat` (SDL2 API over SDL3) + SDL3's PipeWire driver,
where device IDs/descriptions are unstable across PipeWire restarts
(libsdl-org/SDL#12278).
- `mix.OpenAudioDevice(name)` under sdl2-compat requires an exact match against
a cached name list and, on any mismatch, `openDeviceLocked`
(`internal/mixer/mixer.go:90-108`) silently falls back to the system default.
- On `main`, `SwitchDevice` and `ListDevices` have **no callers** — the feature
is unwired. Only `CurrentDevice()` is read, and its result is discarded in
`scrollerPage.go:314-318`.
Routing through `wpctl set-default` uses the mechanism PipeWire/WirePlumber
already maintain, and the kiosk's "pin all sinks to 100%" unit
(`radiantwave-audio-init.service`) guarantees the volume stays 100% whichever
sink ends up default.
## Device enumeration
```
wpctl list audio sinks
```
Tab-separated fields per line:
| # | field | example |
|---|------------------|----------------------------|
| 1 | object ID | `84` |
| 2 | name/description | `Built-in Audio Analog Stereo` |
| 3 | media/object type| `audio/sink` |
| 4 | default marker | `*` if current default |
Use field 2 (the description) as the **stable, persisted key** — numeric IDs
change across reboots, and PipeWire descriptions are what the operator
recognizes. Field 4 gives the "current device" highlight. Field 1 feeds
`wpctl set-default`. (See the kioskconfig `radiantwave-audio-init.service`
for the same parse: `wpctl list audio sinks | cut -f1`.)
`wpctl status` is an alternative for interactive debugging; `wpctl list` is
preferred for parsing.
## Switch flow
1. Operator picks a device in the UI.
2. `wpctl set-default <ID>` (runs as the kiosk user in the same PipeWire
session; no sudo).
3. Reopen SDL's default device: `mix.CloseAudio()` then
`mix.OpenAudioDevice("")`. The stream now follows the new default sink.
4. Preserve volume and resume the current track/loop — reuse the existing
`SwitchDevice` teardown/restore logic.
5. Persist the chosen **description** to `config.SelectedAudioDevice`.
## Startup restore
`Init` (`internal/mixer/mixer.go:42-87`) currently resolves
`SelectedAudioDevice` against SDL enumeration via `resolvePreferredDevice` /
`matchDevice` (`internal/mixer/device.go:79-107`). Change the resolution to:
1. `wpctl list audio sinks` → build `ID ↔ description` map.
2. Match the stored description (exact, then case-insensitive substring —
same policy as `matchDevice`).
3. On match: `wpctl set-default <ID>` (with a short retry to cover WirePlumber
not yet exposing sinks at boot), then open the default SDL device.
4. On no match: log and fall back to the default (the existing warning pattern
at `mixer.go:72`). Do **not** write the fallback back to the config so a
missing device stays easily re-selectable.
## API changes (`internal/mixer`)
Replace the SDL-name-based surface with a wpctl-backed one:
```go
// Device is one selectable output sink.
type Device struct {
ID string // wpctl object ID (field 1)
Description string // PipeWire node description (field 2, persisted)
Default bool // current default sink (field 4 == "*")
Label string // HumanizeDeviceName(Description) for display
}
// ListDevices returns sinks from `wpctl list audio sinks`.
func ListDevices() []Device
// SetDefault makes dev the PipeWire default sink and reopens the SDL default
// device so playback follows it. Preserves volume and resumes the current
// track/loop (reuse the current SwitchDevice teardown/restore).
func SetDefault(dev Device, cfg *db.AppConfig) error
```
- Keep `CurrentDevice()` returning the description of the sink that was last
set as default (or `""` for "system default").
- `HumanizeDeviceName` (`internal/mixer/device.go`) keeps working: the
descriptions it already maps (hdmi/monitor/tv, headset, usb, built-in/analog)
are exactly the PipeWire node descriptions `wpctl list` reports.
- If keeping the SDL-based `SwitchDevice` around, document it as superseded by
`SetDefault`; on `main` it has no callers.
Suggested layout:
- `internal/mixer/device.go` — Device type, wpctl-backed enumeration,
humanization (edit in place).
- `internal/mixer/devicectl_unix.go` — `exec` helpers: `listSinks()`,
`setDefault(id)`, wrapped with `wpctl` resolved from PATH.
- `internal/mixer/devicectl_other.go` — stub returning
`errors.New("audio device control not supported on this platform")` if
cross-platform builds matter (kiosk is Linux-only).
- `internal/mixer/devicectl_test.go` — table tests parsing fixtures of
`wpctl list audio sinks` output (multiple sinks, default marker present and
absent, descriptions containing spaces).
## UI (`internal/page`)
New page `internal/page/AudioDevices.go`, mirroring `AudioOptions` structure
(`StringItem` list, triangle cursor, scroll, `updateSelectedIndex`):
- Items = `mixer.ListDevices()`, label = `Device.Label`, with the current
default (`Device.Default`) rendered green like the selected track in
`AudioOptions.Render()`.
- First item: `System Default` (opens the default device without touching
WirePlumber defaults — a safe always-valid choice).
- `Enter` → `mixer.SetDefault(dev, config)`; refresh highlight; log errors
without leaving the page.
- `Backspace` → navigate to Audio Options (mirror `AudioOptions.HandleEvent`).
- `Update`/`Render`/`Destroy` mirror `AudioOptions`; the page is simple enough
to copy the pattern wholesale.
Wiring:
- Add an "Output Device" entry to the Audio Options page (either a new
`StringItem` in `AudioOptions.go` that navigates to `AudioDevices`, or a
section header like the existing `"Audio Track"` section).
- `AudioOptions.HandleEvent` already routes `Enter` per `focusState`; extend it
to route the new entry, mirroring `p.Context.NavigateTo(SETTINGS)`.
## Packaging
`wpctl` currently only exists because kioskconfig puts `pkgs.wireplumber` in
`systemPackages`. Make the app self-contained:
- Add `wireplumber` to the package's runtime PATH in `flake.nix` (e.g. via the
existing `makeWrapper` that already provides `SSL_CERT_FILE`), so the app
builds and runs correctly on any host, not just the kiosk.
- Keep `pkgs.wireplumber` in kioskconfig `systemPackages` (it also backs the
`radiantwave-audio-init` service) — no change needed there.
## Migration
Existing `SelectedAudioDevice` values hold raw SDL device names. On first run
after this change, if the stored value matches no `wpctl` description, log and
fall back to the default (existing behavior pattern) — no data migration
required. Once a device is selected through the picker, the description is
stored going forward.
## Testing
- Unit: parse fixtures of `wpctl list audio sinks` output; verify
`SetDefault` invokes `wpctl set-default` (fake `wpctl` on PATH).
- Kiosk smoke test: build + deploy, `wpctl status` before/after; pick the HDMI
sink in the UI, confirm the stream link target via `pw-dump`/`pw-top` and
that volume reads 100% on the new sink.
+3 -1
View File
@@ -413,7 +413,9 @@ func (app *Application) initializeAudio() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
if err := mixer.Init(config, app.refreshCache); err != nil { if err := mixer.Init(config, app.refreshCache, func(c *db.AppConfig) error {
return app.DB.Save(c).Error
}); err != nil {
logger.Error("Unable to initialize audio device '%s': %v", config.SelectedAudioDevice, err) logger.Error("Unable to initialize audio device '%s': %v", config.SelectedAudioDevice, err)
logger.Error("Audio will be unavailable until a device is configured") logger.Error("Audio will be unavailable until a device is configured")
} else { } else {
+2 -2
View File
@@ -10,13 +10,13 @@ type AppConfig struct {
EmailAddress string EmailAddress string
LicenseKey string LicenseKey string
SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL
SelectedAudioDevice string // The pipewire device name SelectedAudioDevice string // Raw SDL audio device name from enumeration; empty string = system default. Humanized for display via mixer.HumanizeDeviceName
InitialFontSize int // How large to create the font atlas InitialFontSize int // How large to create the font atlas
StandardFontSize int // The font size used for most text rendering StandardFontSize int // The font size used for most text rendering
LinePattern string // "fibonacci" LinePattern string // "fibonacci"
AudioEnabled bool AudioEnabled bool
DynamicAudio bool // When true, scroller switches audio per-proclamation frequency DynamicAudio bool // When true, scroller switches audio per-proclamation frequency
SavedVolume float64 // Last saved volume as a percentage SavedVolume float64 // In-app volume as a 0.0–1.0 fraction of the OS audio sink (kiosk pins the sink to 100%, so this is the effective output level)
FirstRun bool // Is this the first time the application has been run? FirstRun bool // Is this the first time the application has been run?
} }
+108
View File
@@ -0,0 +1,108 @@
package mixer
import (
"strings"
"github.com/veandco/go-sdl2/sdl"
)
// Device describes an audio output device as seen through SDL's core
// enumeration APIs. Name is the raw SDL device string used to open the device
// and to persist the selection; Label is the humanized name shown to the user.
type Device struct {
Name string
Label string
}
// humanizeRules maps raw SDL device names to human-readable labels. Rules are
// evaluated in order; the first rule whose keyword is a case-insensitive
// substring of the raw name wins.
var humanizeRules = []struct {
keywords []string
label string
}{
{[]string{"hdmi", "monitor", "tv"}, "HDMI | Monitor | TV"},
{[]string{"headset", "headphone"}, "Headset"},
{[]string{"usb"}, "USB"},
{[]string{"analog", "built-in", "builtin", "internal", "speaker"}, "Internal Speakers"},
}
// HumanizeDeviceName maps a raw SDL audio device name to a human-readable
// label. Raw names come from SDL_GetAudioDeviceName, e.g. PipeWire sink
// descriptions ("Built-in Audio Analog Stereo", "Built-in Audio Digital
// Stereo (HDMI)") or PulseAudio sink names ("alsa_output.pci-0000_00_1f.3.
// analog-stereo"). If no rule matches, the raw name is returned unchanged.
func HumanizeDeviceName(raw string) string {
lower := strings.ToLower(raw)
for _, rule := range humanizeRules {
for _, kw := range rule.keywords {
if strings.Contains(lower, kw) {
return rule.label
}
}
}
return raw
}
// enumeratePlaybackDevices returns the available playback devices from SDL's
// core audio enumeration, each with a humanized label. It is the single place
// that touches SDL2's device enumeration APIs; a future SDL3 migration only
// needs to change this function and the mixer.OpenAudioDevice boundary.
func enumeratePlaybackDevices() []Device {
n := sdl.GetNumAudioDevices(false)
if n <= 0 {
return nil
}
out := make([]Device, 0, n)
for i := 0; i < n; i++ {
if name := sdl.GetAudioDeviceName(i, false); name != "" {
out = append(out, Device{Name: name, Label: HumanizeDeviceName(name)})
}
}
return out
}
// ListDevices returns the list of available output devices (from core SDL).
// Name holds the raw device string (use it with SwitchDevice/CurrentDevice);
// Label holds the humanized display name.
func ListDevices() []Device {
mu.Lock()
defer mu.Unlock()
return enumeratePlaybackDevices()
}
// matchDevice resolves a stored/preferred device name against enumerated
// device names. An exact match wins; otherwise the first device whose name
// contains the preferred string (case-insensitive) is returned, tolerating
// PipeWire/PulseAudio node-name suffixes. Returns false when the preferred
// name is empty or no device matches.
func matchDevice(names []string, preferred string) (string, bool) {
if preferred == "" {
return "", false
}
for _, name := range names {
if name == preferred {
return name, true
}
}
lowerPref := strings.ToLower(preferred)
for _, name := range names {
if strings.Contains(strings.ToLower(name), lowerPref) {
return name, true
}
}
return "", false
}
// resolvePreferredDevice finds the enumeration name to open for a persisted
// device selection. Returns false when the preferred name is empty or no
// enumerated device matches, in which case the caller should fall back to the
// system default.
func resolvePreferredDevice(preferred string) (string, bool) {
devs := enumeratePlaybackDevices()
names := make([]string, 0, len(devs))
for _, d := range devs {
names = append(names, d.Name)
}
return matchDevice(names, preferred)
}
+31 -20
View File
@@ -31,10 +31,15 @@ var (
defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE
refreshCacheCallback func() refreshCacheCallback func()
persistConfigCallback func(*db.AppConfig) error
) )
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times. // Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
func Init(cfg *db.AppConfig, onUpdate func()) error { // persistConfig persists the given config (used to save the selected audio
// device across restarts); it may be nil, in which case device selections are
// not saved.
func Init(cfg *db.AppConfig, onUpdate func(), persistConfig func(*db.AppConfig) error) error {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if inited { if inited {
@@ -42,6 +47,7 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
} }
refreshCacheCallback = onUpdate refreshCacheCallback = onUpdate
persistConfigCallback = persistConfig
if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil { if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil {
return fmt.Errorf("SDL audio init failed: %w", err) return fmt.Errorf("SDL audio init failed: %w", err)
@@ -52,9 +58,20 @@ func Init(cfg *db.AppConfig, onUpdate func()) error {
logger.Error("Failed to initialize MP3/OGG support: %v", err) logger.Error("Failed to initialize MP3/OGG support: %v", err)
} }
// A stored "default" (the first-run seed) is the system default.
deviceName := cfg.SelectedAudioDevice deviceName := cfg.SelectedAudioDevice
if deviceName == "default" { if deviceName == "" || deviceName == "default" {
deviceName = "" deviceName = ""
} else {
// Honor a persisted device selection by resolving it against the
// currently enumerated devices, falling back to the system default if
// it is no longer present.
if resolved, ok := resolvePreferredDevice(deviceName); ok {
deviceName = resolved
} else {
logger.Warning("Selected audio device %q not found; using system default", cfg.SelectedAudioDevice)
deviceName = ""
}
} }
if err := openDeviceLocked(deviceName); err != nil { if err := openDeviceLocked(deviceName); err != nil {
sdl.QuitSubSystem(sdl.INIT_AUDIO) sdl.QuitSubSystem(sdl.INIT_AUDIO)
@@ -178,7 +195,10 @@ func IsPlaying() bool {
return mix.PlayingMusic() return mix.PlayingMusic()
} }
// SetVolume sets the SDL_mixer music volume to level (0.0–1.0). // SetVolume sets the SDL_mixer music volume to level (0.0–1.0), mapped onto
// SDL_mixer's 0–128 scale (mix.MAX_VOLUME). This is relative to the OS audio
// sink, which the kiosk shell pins to 100%, so in-app volume is 0–100% of the
// sink output.
func SetVolume(level float64) error { func SetVolume(level float64) error {
if level < 0 || level > 1 { if level < 0 || level > 1 {
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level) return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
@@ -204,23 +224,6 @@ func ConvertVolume(volPerc int) float64 {
return float64(volPerc) / 100.0 return float64(volPerc) / 100.0
} }
// ListDevices returns the list of available output device names (from core SDL).
func ListDevices() []string {
mu.Lock()
defer mu.Unlock()
n := sdl.GetNumAudioDevices(false)
if n <= 0 {
return nil
}
out := make([]string, 0, n)
for i := 0; i < n; i++ {
if name := sdl.GetAudioDeviceName(i, false); name != "" {
out = append(out, name)
}
}
return out
}
// CurrentDevice returns the last requested/opened device name ("" means default). // CurrentDevice returns the last requested/opened device name ("" means default).
func CurrentDevice() string { func CurrentDevice() string {
return deviceName return deviceName
@@ -265,6 +268,14 @@ func SwitchDevice(newName string, cfg *db.AppConfig) error {
} }
if cfg != nil { if cfg != nil {
// Persist whatever device is actually open (openDeviceLocked falls
// back to the system default if the requested name cannot be opened).
cfg.SelectedAudioDevice = CurrentDevice()
if persistConfigCallback != nil {
if err := persistConfigCallback(cfg); err != nil {
logger.Error("Failed to persist audio device selection: %v", err)
}
}
currentVolume = cfg.SavedVolume currentVolume = cfg.SavedVolume
v := int(math.Round(cfg.SavedVolume * float64(mix.MAX_VOLUME))) v := int(math.Round(cfg.SavedVolume * float64(mix.MAX_VOLUME)))
mix.VolumeMusic(v) mix.VolumeMusic(v)
+58 -132
View File
@@ -1,145 +1,71 @@
package mixer package mixer
import ( import "testing"
"testing"
"time"
"rhemawavetech.com/rhemawave/internal/db" func TestHumanizeDeviceName(t *testing.T) {
) tests := []struct {
name string
// Regression tests for critical audio bugs (v0.5) raw string
want string
// Regression: Stop() used to return early when AudioEnabled was false, }{
// leaving music playing after the user toggled audio off mid-playback. {"hdmi description", "Built-in Audio Digital Stereo (HDMI)", "HDMI | Monitor | TV"},
func TestStopCleansUpWhenAudioDisabled(t *testing.T) { {"hdmi alsa name", "alsa_output.pci-0000_00_1f.3.hdmi-stereo-extra1", "HDMI | Monitor | TV"},
mu.Lock() {"monitor", "Monitor", "HDMI | Monitor | TV"},
wasInited := inited {"tv", "TV", "HDMI | Monitor | TV"},
inited = true {"uppercase hdmi", "HDMI", "HDMI | Monitor | TV"},
deviceOpen = false {"headset", "Headset", "Headset"},
currentPath = "/some/track.wav" {"headphones", "WH-1000XM4 Headphones", "Headset"},
currentLoops = -1 {"bluetooth headset name", "bluez_output.00_11_22_33_44_55.headset-head-unit", "Headset"},
mu.Unlock() {"usb description", "USB Audio", "USB"},
{"usb alsa name", "alsa_output.usb-046d_0825-00.analog-stereo", "USB"},
defer func() { {"internal analog description", "Built-in Audio Analog Stereo", "Internal Speakers"},
mu.Lock() {"internal alsa name", "alsa_output.pci-0000_00_1f.3.analog-stereo", "Internal Speakers"},
inited = wasInited {"internal speakers label", "Internal Speakers", "Internal Speakers"},
currentPath = "" {"virtual speaker", "Virtual Speaker", "Internal Speakers"},
currentLoops = 0 {"no match fallback", "Custom Node 42", "Custom Node 42"},
mu.Unlock() {"empty fallback", "", ""},
}()
cfg := &db.AppConfig{AudioEnabled: false}
Stop(0, cfg)
mu.Lock()
defer mu.Unlock()
if currentPath != "" {
t.Errorf("currentPath = %q, want empty after Stop with AudioEnabled=false", currentPath)
} }
if currentLoops != 0 {
t.Errorf("currentLoops = %d, want 0 after Stop with AudioEnabled=false", currentLoops) for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := HumanizeDeviceName(tt.raw); got != tt.want {
t.Errorf("HumanizeDeviceName(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
} }
} }
// Regression: SwitchDevice used to convert oldDevice="" to "system default" func TestMatchDeviceName(t *testing.T) {
// and pass that string to openDeviceLocked, which SDL does not recognize. names := []string{
func TestSwitchDeviceReturnsErrorWhenUninitialized(t *testing.T) { "Built-in Audio Analog Stereo",
mu.Lock() "Built-in Audio Digital Stereo (HDMI)",
wasInited := inited "USB Audio",
inited = false "Headset",
deviceOpen = false
mu.Unlock()
defer func() {
mu.Lock()
inited = wasInited
mu.Unlock()
}()
cfg := &db.AppConfig{AudioEnabled: true, SavedVolume: 0.5}
err := SwitchDevice("Some Device", cfg)
if err == nil {
t.Fatal("SwitchDevice on uninitialized mixer should return error")
}
} }
// Regression: SwitchDevice with nil config must not panic when attempting tests := []struct {
// to restore volume after reopening the device. name string
func TestSwitchDeviceNilConfigDoesNotPanic(t *testing.T) { preferred string
mu.Lock() want string
wasInited := inited wantOK bool
inited = false }{
deviceOpen = false {"empty preferred", "", "", false},
mu.Unlock() {"exact match", "USB Audio", "USB Audio", true},
{"substring match", "hdmi", "Built-in Audio Digital Stereo (HDMI)", true},
defer func() { {"suffix tolerance", "Built-in Audio Digital Stereo", "Built-in Audio Digital Stereo (HDMI)", true},
mu.Lock() {"case insensitive", "usb audio", "USB Audio", true},
inited = wasInited {"no match", "Bluetooth Dongle", "", false},
mu.Unlock()
}()
err := SwitchDevice("Some Device", nil)
if err == nil {
t.Fatal("SwitchDevice on uninitialized mixer should return error")
}
} }
// Regression: SwitchDevice with empty device name (system default) should for _, tt := range tests {
// not be converted to "system default" before passing to openDeviceLocked. t.Run(tt.name, func(t *testing.T) {
func TestSwitchDeviceAcceptsEmptyDeviceName(t *testing.T) { got, ok := matchDevice(names, tt.preferred)
mu.Lock() if ok != tt.wantOK {
wasInited := inited t.Fatalf("matchDevice(%q) ok = %v, want %v", tt.preferred, ok, tt.wantOK)
inited = false }
deviceOpen = false if ok && got != tt.want {
mu.Unlock() t.Errorf("matchDevice(%q) = %q, want %q", tt.preferred, got, tt.want)
}
defer func() { })
mu.Lock()
inited = wasInited
mu.Unlock()
}()
cfg := &db.AppConfig{SavedVolume: 0.75}
err := SwitchDevice("", cfg)
// Returns error because mixer is not initialized, but should not panic
// and should handle the empty name gracefully.
if err == nil {
t.Fatal("SwitchDevice on uninitialized mixer should return error")
} }
} }
// TestPlayDoesNotDeadlockWhenUninitialized verifies that Play() returns an error
// (not a deadlock) when the mixer has not been initialized.
// The old code would deadlock because Play() held mu then called SwitchDevice()
// which also tried to acquire mu.
func TestPlayDoesNotDeadlockWhenUninitialized(t *testing.T) {
// Ensure mixer is in uninitialized state
mu.Lock()
wasInited := inited
inited = false
deviceOpen = false
mu.Unlock()
defer func() {
mu.Lock()
inited = wasInited
mu.Unlock()
}()
cfg := &db.AppConfig{AudioEnabled: true}
done := make(chan error, 1)
go func() {
done <- Play("nonexistent.wav", false, cfg)
}()
select {
case err := <-done:
if err == nil {
t.Fatal("Play() returned nil error on uninitialized mixer, want error")
}
case <-time.After(5 * time.Second):
t.Fatal("Play() deadlocked on uninitialized mixer (did not return within 5s)")
}
}
+504
View File
@@ -0,0 +1,504 @@
package page
import (
"fmt"
"math"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/mixer"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
const systemDefaultDeviceName = "System Default"
// AudioDevices lists the available audio output sinks and lets the operator
// pick which one RadiantWave plays through. The selection is persisted to the
// config (via mixer.SwitchDevice) and restored on the next launch.
type AudioDevices struct {
Base
font *fontManager.FontEntry
textShader *shaderManager.Shader
solidShader *shaderManager.Shader
title StringItem
prompt StringItem
verticalMargin int32
ItemScale float32
sectionHeader StringItem
devices []mixer.Device
deviceItems []StringItem
selectedDeviceIndex int
focusState int
triVAO uint32
triVBO uint32
triX float32
triCenterY float32
spinElapsed float32
scrollOffset int
maxVisible int
listItemMargin int32
listAreaTop int32
listAreaBottom int32
listItemHeight int32
scrollTrackWidth int32
scrollTrackX int32
scrollTrackY int32
scrollTrackH int32
dotSize float32
listLeftX int32
}
// currentDeviceIndex maps the currently opened SDL device name onto the
// selectable list. Index 0 is always "System Default" ("" name). A matching
// device returns its 1-based list index; no match (or an empty list) returns
// -1 so the caller can leave the highlight unset.
func currentDeviceIndex(devices []mixer.Device, currentName string) int {
if currentName == "" {
return 0
}
for i, dev := range devices {
if dev.Name == currentName {
return i + 1
}
}
return -1
}
func (p *AudioDevices) Init(ctx *PageCtx) error {
var err error
p.verticalMargin = int32(20)
p.listItemMargin = 6
p.focusState = 0
p.scrollOffset = 0
p.dotSize = 8.0
p.scrollTrackWidth = 30
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
fm := fontManager.Get()
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
if !ok {
return fmt.Errorf("failed to get font: %w", sdl.GetError())
}
p.font = font
sm := shaderManager.Get()
p.textShader, ok = sm.Get("text")
if !ok {
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
}
p.solidShader, ok = sm.Get("solid_color")
if !ok {
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
}
if err = p.Base.Init(ctx); err != nil {
return fmt.Errorf("base init failed: %w", err)
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
baseFontSize := config.StandardFontSize
titleScale := float32(64) / float32(baseFontSize)
p.ItemScale = float32(32) / float32(baseFontSize)
p.title, err = NewStringItem("Audio Output Device", p.font, colors.White)
if err != nil {
return fmt.Errorf("creating title: %w", err)
}
p.title.Scale(titleScale)
p.prompt, err = NewStringItem(
"Enter to select -- Up/Down to navigate -- Backspace to go back",
p.font, colors.White,
)
if err != nil {
return fmt.Errorf("creating prompt: %w", err)
}
p.prompt.Scale(p.ItemScale)
p.sectionHeader, err = NewStringItem("Output Device", p.font, colors.Silver)
if err != nil {
return fmt.Errorf("creating section header: %w", err)
}
p.sectionHeader.Scale(p.ItemScale * 1.2)
p.buildDeviceList()
triVerts := []float32{
0.5, 0.0,
-0.5, 0.5,
-0.5, -0.5,
}
gl.GenVertexArrays(1, &p.triVAO)
gl.GenBuffers(1, &p.triVBO)
gl.BindVertexArray(p.triVAO)
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
gl.EnableVertexAttribArray(0)
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
gl.BindVertexArray(0)
p.layout()
return nil
}
func (p *AudioDevices) buildDeviceList() {
for i := range p.deviceItems {
gl.DeleteTextures(1, &p.deviceItems[i].ID)
}
p.deviceItems = nil
p.devices = mixer.ListDevices()
systemDefaultItem, err := NewStringItem(systemDefaultDeviceName, p.font, colors.White)
if err != nil {
logger.Error("failed to create System Default string item: %v", err)
} else {
systemDefaultItem.Scale(p.ItemScale)
p.deviceItems = append(p.deviceItems, systemDefaultItem)
}
for _, dev := range p.devices {
label := dev.Label
if label == "" {
label = dev.Name
}
item, err := NewStringItem(label, p.font, colors.White)
if err != nil {
logger.Error("failed to create audio device string item for %q: %v", dev.Name, err)
continue
}
item.Scale(p.ItemScale)
p.deviceItems = append(p.deviceItems, item)
}
p.updateSelectedIndex()
}
func (p *AudioDevices) updateSelectedIndex() {
p.selectedDeviceIndex = currentDeviceIndex(p.devices, mixer.CurrentDevice())
if p.focusState < 0 || p.focusState >= len(p.deviceItems) {
p.focusState = 0
}
}
// selectDevice makes the item at index the active output sink and persists the
// choice. Index 0 is the system default ("") — the SDL default device with no
// PipeWire/WirePlumber involvement. Errors are logged but do not leave the page.
func (p *AudioDevices) selectDevice(index int) {
if index < 0 || index >= len(p.deviceItems) {
return
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
logger.Error("selectDevice: failed to load config: %v", err)
return
}
name := ""
if index > 0 {
devIdx := index - 1
if devIdx >= 0 && devIdx < len(p.devices) {
name = p.devices[devIdx].Name
}
}
if err := mixer.SwitchDevice(name, config); err != nil {
logger.Error("Failed to switch audio device: %v", err)
return
}
p.updateSelectedIndex()
p.updateCursorPosition()
}
func (p *AudioDevices) layout() {
p.title.X = p.ScreenCenterX - p.title.W/2
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2
p.sectionHeader.Y = p.prompt.Y - p.verticalMargin - p.sectionHeader.H
p.listItemHeight = p.deviceItems[0].H
listTop := p.sectionHeader.Y - p.listItemMargin
listBottomMargin := int32(12)
listH := listTop - listBottomMargin
if listH < p.listItemHeight {
listH = p.listItemHeight
}
p.listAreaTop = listTop
p.listAreaBottom = listBottomMargin
itemStep := p.listItemHeight + p.listItemMargin
n := len(p.deviceItems)
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.deviceItems {
w := p.deviceItems[i].W
if w > maxItemW {
maxItemW = w
}
}
p.listLeftX = p.ScreenCenterX - maxItemW/2
firstVisibleY := p.listAreaTop - p.listItemHeight
for i := range p.deviceItems {
item := &p.deviceItems[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()
}
func (p *AudioDevices) updateCursorPosition() {
cursorGap := int32(12)
if p.focusState < 0 || p.focusState >= len(p.deviceItems) {
return
}
item := &p.deviceItems[p.focusState]
p.triX = float32(item.X) - triangleSize - float32(cursorGap)
p.triCenterY = float32(item.Y) + float32(item.H)/2
}
func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
e, ok := (*event).(*sdl.KeyboardEvent)
if !ok || e.Type != sdl.KEYDOWN {
return nil
}
n := len(p.deviceItems)
switch e.Keysym.Sym {
case sdl.K_RETURN:
p.selectDevice(p.focusState)
case sdl.K_DOWN:
if p.focusState < n-1 {
p.focusState++
if p.focusState >= p.scrollOffset+p.maxVisible {
p.scrollOffset++
p.layout()
} else {
p.updateCursorPosition()
}
}
case sdl.K_UP:
if p.focusState > 0 {
p.focusState--
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_BACKSPACE:
p.Context.NavigateTo(AUDIO)
}
return nil
}
func (p *AudioDevices) Update(dt float32) error {
p.spinElapsed += dt
if p.spinElapsed >= 1.0 {
p.spinElapsed -= 1.0
}
return nil
}
func (p *AudioDevices) renderCursor() error {
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
model := mgl32.Ident4()
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
mvp := p.Projection.Mul4(model)
gl.UseProgram(p.solidShader.ProgramID)
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
gl.Uniform4f(
p.solidShader.LocColor,
float32(colors.White.R)/255.0,
float32(colors.White.G)/255.0,
float32(colors.White.B)/255.0,
float32(colors.White.A)/255.0,
)
gl.BindVertexArray(p.triVAO)
gl.DrawArrays(gl.TRIANGLES, 0, 3)
gl.BindVertexArray(0)
gl.UseProgram(0)
return nil
}
func (p *AudioDevices) renderScrollbar() {
n := len(p.deviceItems)
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 *AudioDevices) Render() error {
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
return fmt.Errorf("render title: %w", err)
}
if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil {
return fmt.Errorf("render prompt: %w", err)
}
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.deviceItems {
w := p.deviceItems[i].W
if w > maxItemW {
maxItemW = w
}
}
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)
// Device list (visible window only)
for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.deviceItems); i++ {
item := &p.deviceItems[i]
clr := colors.White
if i == p.selectedDeviceIndex {
clr = colors.Green
}
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), clr); err != nil {
return fmt.Errorf("render device %d: %w", i, err)
}
if i == p.selectedDeviceIndex {
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)
}
}
p.renderScrollbar()
if err := p.renderCursor(); err != nil {
return fmt.Errorf("render cursor: %w", err)
}
return nil
}
func (p *AudioDevices) Destroy() error {
gl.DeleteTextures(1, &p.title.ID)
gl.DeleteTextures(1, &p.prompt.ID)
gl.DeleteTextures(1, &p.sectionHeader.ID)
for i := range p.deviceItems {
gl.DeleteTextures(1, &p.deviceItems[i].ID)
}
gl.DeleteBuffers(1, &p.triVBO)
gl.DeleteVertexArrays(1, &p.triVAO)
return nil
}
+36
View File
@@ -0,0 +1,36 @@
package page
import (
"testing"
"rhemawavetech.com/rhemawave/internal/mixer"
)
func TestCurrentDeviceIndex(t *testing.T) {
devices := []mixer.Device{
{Name: "Built-in Audio Analog Stereo", Label: "Internal Speakers"},
{Name: "Built-in Audio Digital Stereo (HDMI)", Label: "HDMI | Monitor | TV"},
}
tests := []struct {
name string
devices []mixer.Device
currentName string
want int
}{
{"empty list, default device", nil, "", 0},
{"empty list, named device", nil, "USB Audio", -1},
{"default device", devices, "", 0},
{"exact first device match", devices, "Built-in Audio Analog Stereo", 1},
{"exact second device match", devices, "Built-in Audio Digital Stereo (HDMI)", 2},
{"no match", devices, "USB Audio", -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := currentDeviceIndex(tt.devices, tt.currentName); got != tt.want {
t.Errorf("currentDeviceIndex(%v, %q) = %d, want %d", tt.devices, tt.currentName, got, tt.want)
}
})
}
}
+58 -298
View File
@@ -19,23 +19,9 @@ const focusToggle = -1
const triangleSize = float32(16) const triangleSize = float32(16)
var audioTrackSeed = []db.AudioTrack{ // AudioOptions is the hub for audio settings. It hosts the master volume and
{Filename: "OriginalAudio.wav", Name: "Original Audio", AssignedFrequency: 0, MD5: "d39c2676c315438752ab0c55e9427d74", Order: 1}, // the audio on/off toggle, and routes to the TrackSelection and AudioDevices
{Filename: "174Hz.wav", Name: "174 Hz", AssignedFrequency: 174, MD5: "42abaf332e628b646ac2cc9d1b5385e7", Order: 2}, // pages for picking the track and the output device.
{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 { type AudioOptions struct {
Base Base
font *fontManager.FontEntry font *fontManager.FontEntry
@@ -50,9 +36,8 @@ type AudioOptions struct {
AudioStatusToggle StringItem AudioStatusToggle StringItem
audioFileTextures []StringItem audioTrackItem StringItem
audioTracks []db.AudioTrack outputDeviceItem StringItem
selectedFileIndex int
VolumeLabel StringItem VolumeLabel StringItem
volumePercent StringItem volumePercent StringItem
@@ -68,34 +53,12 @@ type AudioOptions struct {
spinElapsed float32 spinElapsed float32
focusState int focusState int
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 { func (p *AudioOptions) Init(ctx *PageCtx) error {
var err error var err error
p.verticalMargin = int32(20) p.verticalMargin = int32(20)
p.listItemMargin = 6
p.focusState = focusToggle p.focusState = focusToggle
p.scrollOffset = 0
p.dotSize = 8.0
p.scrollTrackWidth = 30
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
fm := fontManager.Get() fm := fontManager.Get()
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular") font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
@@ -155,12 +118,10 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
} }
if err = p.seedAudioTracks(); err != nil { if err = p.buildNavigationItems(); err != nil {
logger.Error("failed to seed audio tracks: %v", err) return fmt.Errorf("creating navigation items: %w", err)
} }
p.buildAudioFileList()
triVerts := []float32{ triVerts := []float32{
0.5, 0.0, 0.5, 0.0,
-0.5, 0.5, -0.5, 0.5,
@@ -176,84 +137,56 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
gl.BindBuffer(gl.ARRAY_BUFFER, 0) gl.BindBuffer(gl.ARRAY_BUFFER, 0)
gl.BindVertexArray(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() p.layout()
return nil return nil
} }
func (p *AudioOptions) buildAudioFileList() { // buildNavigationItems creates the "Audio Track" and "Output Device" entries
for i := range p.audioFileTextures { // that navigate to the TrackSelection and AudioDevices pages. Labels include
gl.DeleteTextures(1, &p.audioFileTextures[i].ID) // the currently selected value so returning to this page shows the latest
} // choice.
p.audioFileTextures = nil func (p *AudioOptions) buildNavigationItems() error {
var err error
var tracks []db.AudioTrack p.audioTrackItem, err = NewStringItem(p.currentTrackLabel(), p.font, colors.White)
p.Context.DbPtr.Order("`order` ASC").Find(&tracks)
p.audioTracks = tracks
dynamicItem, err := NewStringItem("Dynamic Audio", p.font, colors.White)
if err != nil { if err != nil {
logger.Error("failed to create Dynamic Audio string item: %v", err) return fmt.Errorf("creating audio track item: %w", err)
} else {
dynamicItem.Scale(p.ItemScale)
p.audioFileTextures = append(p.audioFileTextures, dynamicItem)
} }
p.audioTrackItem.Scale(p.ItemScale)
for _, track := range tracks { p.outputDeviceItem, err = NewStringItem(p.currentDeviceLabel(), p.font, colors.White)
item, err := NewStringItem(track.Name, p.font, colors.White)
if err != nil { if err != nil {
logger.Error("failed to create audio track string item for %s: %v", track.Name, err) return fmt.Errorf("creating output device item: %w", err)
continue
} }
item.Scale(p.ItemScale) p.outputDeviceItem.Scale(p.ItemScale)
p.audioFileTextures = append(p.audioFileTextures, item)
return nil
} }
p.updateSelectedIndex() func (p *AudioOptions) currentTrackLabel() string {
}
func (p *AudioOptions) updateSelectedIndex() {
config, err := db.GetConfig(p.Context.DbPtr) config, err := db.GetConfig(p.Context.DbPtr)
if err != nil { if err != nil {
p.selectedFileIndex = 0 return "Audio Track"
return
} }
if config.DynamicAudio { if config.DynamicAudio {
p.selectedFileIndex = 0 return "Audio Track: Dynamic Audio"
return
} }
var selected db.AudioTrack var selected db.AudioTrack
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil { if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
p.selectedFileIndex = 0 return "Audio Track"
return }
if selected.Name != "" {
return "Audio Track: " + selected.Name
}
return "Audio Track"
} }
for i, track := range p.audioTracks { func (p *AudioOptions) currentDeviceLabel() string {
if track.ID == selected.ID { current := mixer.CurrentDevice()
p.selectedFileIndex = i + 1 if current == "" {
return return "Output Device: System Default"
} }
} return "Output Device: " + mixer.HumanizeDeviceName(current)
p.selectedFileIndex = 0
}
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(&audioTrackSeed).Error
}
return nil
} }
func (p *AudioOptions) CreateAudioStatusItem() error { func (p *AudioOptions) CreateAudioStatusItem() error {
@@ -277,31 +210,6 @@ func (p *AudioOptions) CreateAudioStatusItem() error {
return nil return nil
} }
func (p *AudioOptions) selectAudioFile(index int) {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
if index == 0 {
config.DynamicAudio = true
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false)
} else {
config.DynamicAudio = false
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)
}
}
if err := p.Context.DbPtr.Save(config).Error; err != nil {
logger.Error("failed to save audio config: %v", err)
}
p.selectedFileIndex = index
}
func (p *AudioOptions) CreateVolumePercentItem() error { func (p *AudioOptions) CreateVolumePercentItem() error {
if p.volumePercent.ID != 0 { if p.volumePercent.ID != 0 {
gl.DeleteTextures(1, &p.volumePercent.ID) gl.DeleteTextures(1, &p.volumePercent.ID)
@@ -377,57 +285,11 @@ func (p *AudioOptions) layout() {
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2 p.outputDeviceItem.X = p.ScreenCenterX - p.outputDeviceItem.W/2
p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H p.outputDeviceItem.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.outputDeviceItem.H
p.listItemHeight = p.audioFileTextures[0].H p.audioTrackItem.X = p.ScreenCenterX - p.audioTrackItem.W/2
listTop := p.sectionHeader.Y - p.listItemMargin p.audioTrackItem.Y = p.outputDeviceItem.Y - p.verticalMargin - p.audioTrackItem.H
listBottomMargin := int32(12)
listH := listTop - listBottomMargin
if listH < p.listItemHeight {
listH = p.listItemHeight
}
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() p.updateCursorPosition()
} }
@@ -439,8 +301,10 @@ func (p *AudioOptions) updateCursorPosition() {
switch { switch {
case p.focusState == focusToggle: case p.focusState == focusToggle:
item = &p.AudioStatusToggle item = &p.AudioStatusToggle
case p.focusState >= 0 && p.focusState < len(p.audioFileTextures): case p.focusState == 0:
item = &p.audioFileTextures[p.focusState] item = &p.audioTrackItem
case p.focusState == 1:
item = &p.outputDeviceItem
default: default:
return return
} }
@@ -467,32 +331,24 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
return nil return nil
} }
n := len(p.audioFileTextures)
switch e.Keysym.Sym { switch e.Keysym.Sym {
case sdl.K_RETURN: case sdl.K_RETURN:
if p.focusState == focusToggle { switch p.focusState {
case focusToggle:
p.ToggleAudio() p.ToggleAudio()
} else if p.focusState >= 0 && p.focusState < n { case 0:
p.selectAudioFile(p.focusState) p.Context.NavigateTo(TRACKS)
p.updateCursorPosition() case 1:
p.Context.NavigateTo(AUDIO_DEVICES)
} }
case sdl.K_DOWN: case sdl.K_DOWN:
if p.focusState == focusToggle { if p.focusState == focusToggle {
if n > 0 {
p.focusState = 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() p.updateCursorPosition()
} } else if p.focusState < 1 {
p.focusState++
p.updateCursorPosition()
} }
case sdl.K_UP: case sdl.K_UP:
@@ -501,36 +357,8 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
p.updateCursorPosition() p.updateCursorPosition()
} else if p.focusState > 0 { } else if p.focusState > 0 {
p.focusState-- p.focusState--
if p.focusState < p.scrollOffset {
p.scrollOffset--
p.layout()
} else {
p.updateCursorPosition() 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: case sdl.K_RIGHT:
p.UpdateVolume(10) p.UpdateVolume(10)
@@ -618,34 +446,6 @@ func (p *AudioOptions) renderCursor() error {
return nil return nil
} }
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 { 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 { if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
return fmt.Errorf("render title: %w", err) return fmt.Errorf("render title: %w", err)
@@ -668,50 +468,12 @@ func (p *AudioOptions) Render() error {
return fmt.Errorf("render audio toggle: %w", err) return fmt.Errorf("render audio toggle: %w", err)
} }
p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color) if err := p.RenderTexture(p.textShader, p.audioTrackItem.ID, p.audioTrackItem.Position(), p.audioTrackItem.RawDimensions(), p.audioTrackItem.Color); err != nil {
return fmt.Errorf("render audio track item: %w", err)
// 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
} }
if err := p.RenderTexture(p.textShader, p.outputDeviceItem.ID, p.outputDeviceItem.Position(), p.outputDeviceItem.RawDimensions(), p.outputDeviceItem.Color); err != nil {
return fmt.Errorf("render output device item: %w", err)
} }
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)
}
}
p.renderScrollbar()
if err := p.renderCursor(); err != nil { if err := p.renderCursor(); err != nil {
return fmt.Errorf("render cursor: %w", err) return fmt.Errorf("render cursor: %w", err)
@@ -726,10 +488,8 @@ func (p *AudioOptions) Destroy() error {
gl.DeleteTextures(1, &p.VolumeLabel.ID) gl.DeleteTextures(1, &p.VolumeLabel.ID)
gl.DeleteTextures(1, &p.volumePercent.ID) gl.DeleteTextures(1, &p.volumePercent.ID)
gl.DeleteTextures(1, &p.AudioStatusToggle.ID) gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
gl.DeleteTextures(1, &p.sectionHeader.ID) gl.DeleteTextures(1, &p.audioTrackItem.ID)
for i := range p.audioFileTextures { gl.DeleteTextures(1, &p.outputDeviceItem.ID)
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
}
gl.DeleteBuffers(1, &p.triVBO) gl.DeleteBuffers(1, &p.triVBO)
gl.DeleteVertexArrays(1, &p.triVAO) gl.DeleteVertexArrays(1, &p.triVAO)
return nil return nil
+530
View File
@@ -0,0 +1,530 @@
package page
import (
"fmt"
"math"
"github.com/go-gl/gl/v3.3-core/gl"
"github.com/go-gl/mathgl/mgl32"
"github.com/veandco/go-sdl2/sdl"
"rhemawavetech.com/rhemawave/internal/colors"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/fontManager"
"rhemawavetech.com/rhemawave/internal/logger"
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
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 TrackSelection struct {
Base
font *fontManager.FontEntry
textShader *shaderManager.Shader
solidShader *shaderManager.Shader
title StringItem
prompt StringItem
verticalMargin int32
ItemScale float32
sectionHeader StringItem
audioFileTextures []StringItem
audioTracks []db.AudioTrack
selectedFileIndex int
focusState int
triVAO uint32
triVBO uint32
triX float32
triCenterY float32
spinElapsed float32
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 *TrackSelection) Init(ctx *PageCtx) error {
var err error
p.verticalMargin = int32(20)
p.listItemMargin = 6
p.focusState = 0
p.scrollOffset = 0
p.dotSize = 8.0
p.scrollTrackWidth = 30
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
fm := fontManager.Get()
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
if !ok {
return fmt.Errorf("failed to get font: %w", sdl.GetError())
}
p.font = font
sm := shaderManager.Get()
p.textShader, ok = sm.Get("text")
if !ok {
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
}
p.solidShader, ok = sm.Get("solid_color")
if !ok {
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
}
if err = p.Base.Init(ctx); err != nil {
return fmt.Errorf("base init failed: %w", err)
}
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
baseFontSize := config.StandardFontSize
titleScale := float32(64) / float32(baseFontSize)
p.ItemScale = float32(32) / float32(baseFontSize)
p.title, err = NewStringItem("Audio Track Selection", p.font, colors.White)
if err != nil {
return fmt.Errorf("creating title: %w", err)
}
p.title.Scale(titleScale)
p.prompt, err = NewStringItem(
"Enter to select -- Up/Down to navigate -- Backspace to go back",
p.font, colors.White,
)
if err != nil {
return fmt.Errorf("creating prompt: %w", err)
}
p.prompt.Scale(p.ItemScale)
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)
if err = p.seedAudioTracks(); err != nil {
logger.Error("failed to seed audio tracks: %v", err)
}
p.buildAudioFileList()
triVerts := []float32{
0.5, 0.0,
-0.5, 0.5,
-0.5, -0.5,
}
gl.GenVertexArrays(1, &p.triVAO)
gl.GenBuffers(1, &p.triVBO)
gl.BindVertexArray(p.triVAO)
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
gl.EnableVertexAttribArray(0)
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
gl.BindVertexArray(0)
p.layout()
return nil
}
func (p *TrackSelection) buildAudioFileList() {
for i := range p.audioFileTextures {
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
}
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)
} else {
dynamicItem.Scale(p.ItemScale)
p.audioFileTextures = append(p.audioFileTextures, dynamicItem)
}
for _, track := range tracks {
item, err := NewStringItem(track.Name, p.font, colors.White)
if err != nil {
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)
}
p.updateSelectedIndex()
}
func (p *TrackSelection) updateSelectedIndex() {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
p.selectedFileIndex = 0
return
}
if config.DynamicAudio {
p.selectedFileIndex = 0
return
}
var selected db.AudioTrack
if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil {
p.selectedFileIndex = 0
return
}
for i, track := range p.audioTracks {
if track.ID == selected.ID {
p.selectedFileIndex = i + 1
return
}
}
p.selectedFileIndex = 0
}
func (p *TrackSelection) 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(&audioTrackSeed).Error
}
return nil
}
func (p *TrackSelection) selectAudioFile(index int) {
config, err := db.GetConfig(p.Context.DbPtr)
if err != nil {
panic(err)
}
if index == 0 {
config.DynamicAudio = true
p.Context.DbPtr.Model(&db.AudioTrack{}).Where("1 = 1").Update("selected", false)
} else {
config.DynamicAudio = false
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)
}
}
if err := p.Context.DbPtr.Save(config).Error; err != nil {
logger.Error("failed to save audio config: %v", err)
}
p.selectedFileIndex = index
}
func (p *TrackSelection) layout() {
p.title.X = p.ScreenCenterX - p.title.W/2
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2
p.sectionHeader.Y = p.prompt.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
}
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()
}
func (p *TrackSelection) updateCursorPosition() {
cursorGap := int32(12)
if p.focusState < 0 || p.focusState >= len(p.audioFileTextures) {
return
}
item := &p.audioFileTextures[p.focusState]
p.triX = float32(item.X) - triangleSize - float32(cursorGap)
p.triCenterY = float32(item.Y) + float32(item.H)/2
}
func (p *TrackSelection) 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 >= 0 && p.focusState < n {
p.selectAudioFile(p.focusState)
p.updateCursorPosition()
}
case sdl.K_DOWN:
if p.focusState < n-1 {
p.focusState++
if p.focusState >= p.scrollOffset+p.maxVisible {
p.scrollOffset++
p.layout()
} else {
p.updateCursorPosition()
}
}
case sdl.K_UP:
if p.focusState > 0 {
p.focusState--
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_BACKSPACE:
p.Context.NavigateTo(AUDIO)
}
return nil
}
func (p *TrackSelection) Update(dt float32) error {
p.spinElapsed += dt
if p.spinElapsed >= 1.0 {
p.spinElapsed -= 1.0
}
return nil
}
func (p *TrackSelection) renderCursor() error {
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
model := mgl32.Ident4()
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
mvp := p.Projection.Mul4(model)
gl.UseProgram(p.solidShader.ProgramID)
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
gl.Uniform4f(
p.solidShader.LocColor,
float32(colors.White.R)/255.0,
float32(colors.White.G)/255.0,
float32(colors.White.B)/255.0,
float32(colors.White.A)/255.0,
)
gl.BindVertexArray(p.triVAO)
gl.DrawArrays(gl.TRIANGLES, 0, 3)
gl.BindVertexArray(0)
gl.UseProgram(0)
return nil
}
func (p *TrackSelection) 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 *TrackSelection) Render() error {
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
return fmt.Errorf("render title: %w", err)
}
if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil {
return fmt.Errorf("render prompt: %w", err)
}
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 {
w := p.audioFileTextures[i].W
if w > maxItemW {
maxItemW = w
}
}
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)
}
}
p.renderScrollbar()
if err := p.renderCursor(); err != nil {
return fmt.Errorf("render cursor: %w", err)
}
return nil
}
func (p *TrackSelection) Destroy() error {
gl.DeleteTextures(1, &p.title.ID)
gl.DeleteTextures(1, &p.prompt.ID)
gl.DeleteTextures(1, &p.sectionHeader.ID)
for i := range p.audioFileTextures {
gl.DeleteTextures(1, &p.audioFileTextures[i].ID)
}
gl.DeleteBuffers(1, &p.triVBO)
gl.DeleteVertexArrays(1, &p.triVAO)
return nil
}
+6
View File
@@ -13,6 +13,8 @@ const (
SETTINGS SETTINGS
WIFI WIFI
AUDIO AUDIO
TRACKS
AUDIO_DEVICES
PROCLAMATIONS PROCLAMATIONS
LICENSE_KEY LICENSE_KEY
EMAIL_ADDRESS EMAIL_ADDRESS
@@ -61,6 +63,10 @@ func (r *Router) NavigateTo(p pageType) {
r.ActivePage = &WiFiSetupPage{} r.ActivePage = &WiFiSetupPage{}
case AUDIO: case AUDIO:
r.ActivePage = &AudioOptions{} r.ActivePage = &AudioOptions{}
case TRACKS:
r.ActivePage = &TrackSelection{}
case AUDIO_DEVICES:
r.ActivePage = &AudioDevices{}
case PROCLAMATIONS: case PROCLAMATIONS:
r.ActivePage = &ProclamationOptions{} r.ActivePage = &ProclamationOptions{}
case LICENSE_KEY: case LICENSE_KEY: