Merge pull request 'Add audio output devices' (#40) from dev into main

Reviewed-on: https://codeberg.org/RhemaWave-Technologies/RhemaWave/pulls/40
This commit is contained in:
NeilClack
2026-06-24 00:22:25 +02:00
3 changed files with 208 additions and 25 deletions
+41 -7
View File
@@ -38,17 +38,51 @@ dev_scripts/ # developer utilities
### Runtime Data ### Runtime Data
Stored in `~/.local/share/rhemawave/`: Stored in `~/.local/share/rhemawave/`:
- `data.db` — SQLite database (config, proclamations, logs) - `data.db` — SQLite database (config, proclamations, logs)
- `log.txt` — application log - `log.txt` — application log
- `license_key` — license key file - `license_key` — license key file
### Development Rules ### Development Rules
These rules must be followed during the development of RhemaWave to retain a
maintainable application. These rules must be followed during the development of RhemaWave to retain a
1. The Application package is soley responsible for managing the database handler, and the maintainable application.
configuration cache. Internal packages may update the configuration within the database, and
then signal the application to refresh the cache via a callback function `refreshCache()`. 1. The Application package is soley responsible for managing the database handler, and the
configuration cache. Internal packages may update the configuration within the database, and
then signal the application to refresh the cache via a callback function `refreshCache()`.
## Deployment ## Deployment
Deployed to kiosk hardware via NixOS-Anywhere. See `packaging/` for kiosk system configs. Deployed to kiosk hardware via NixOS.
## Notes
### Deployment Pipeline
A manual walkthough of deploying Radiant Wave updates.
For reference later when writing automated actions.
1. Create a new branch for issue being worked on
2. Perform work
3. Merge to dev when done
4. Build dev with `nix build` to verify no errors
5. Checkout KioskConfig dev
6. Update rhemawave-dev with `nix flake update rhemawave-dev`
7. Build rhemawavekiosk-dev virtual machine
8. Test virtual machine for problems
9. Build dev-iso
10. Install dev-iso to a machine and check for problems
11. Checkout `main`
12. Merge `dev`
13. `nix flake update rhemawave`
14. Build virtual machine
15. Build iso image
16. Build commercial virtual machine
17. Build commercial iso image
18. Build Raspberry Pi img
19. Approve all virtual machines and iso images
20. Approve all machines update on their own
21. Rename all iso image to "radiantwavekiosk-iso-{system}-{MM-DD-YYYY}-{radiantwave-git-hash}.iso"
22. Upload iso images to file server
23. Move old iso images to `archives/` on the fileserver
-2
View File
@@ -385,8 +385,6 @@ func ListDevices() []string {
// 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 {
mu.Lock()
defer mu.Unlock()
return deviceName return deviceName
} }
+167 -16
View File
@@ -15,6 +15,10 @@ import (
"rhemawavetech.com/rhemawave/internal/shaderManager" "rhemawavetech.com/rhemawave/internal/shaderManager"
) )
// focusToggle is the sentinel focusState value meaning the audio on/off toggle
// row has keyboard focus. Device indices (0..n-1) are used for everything below.
const focusToggle = -1
// AudioFileList is kept as the canonical seed data for the audio_files table. // AudioFileList is kept as the canonical seed data for the audio_files table.
// Track selection UI no longer lives on this page, but other parts of the // Track selection UI no longer lives on this page, but other parts of the
// app still read these rows from the database. // app still read these rows from the database.
@@ -37,20 +41,37 @@ var AudioFileList = []db.AudioFile{
const triangleSize = float32(16) const triangleSize = float32(16)
type AudioDevice struct {
Label string
ID uint32
index int
}
type AudioOptions struct { type AudioOptions struct {
// Page Attributes
Base Base
font *fontManager.FontEntry font *fontManager.FontEntry
textShader *shaderManager.Shader textShader *shaderManager.Shader
solidShader *shaderManager.Shader solidShader *shaderManager.Shader
title StringItem CfgPtr *db.AppConfig
prompt StringItem
// Page Textures
title StringItem
prompt StringItem
// Layout
verticalMargin int32 verticalMargin int32
ItemScale float32 ItemScale float32
CfgPtr *db.AppConfig
// Audio toggle // Audio toggle
AudioStatusToggle StringItem AudioStatusToggle StringItem
// Audio Devices
activeDeviceIndex int
NumAudioDevices int
AudioDevices []AudioDevice
AudioDeviceTextures []StringItem
// Volume row // Volume row
VolumeLabel StringItem VolumeLabel StringItem
volumePercent StringItem volumePercent StringItem
@@ -65,6 +86,9 @@ type AudioOptions struct {
triX float32 triX float32
triCenterY float32 triCenterY float32
spinElapsed float32 spinElapsed float32
// Focus: focusToggle (-1) = toggle row, 0..n-1 = device at that index
focusState int
} }
// ── Init ────────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────────
@@ -72,6 +96,7 @@ type AudioOptions struct {
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.focusState = focusToggle
// Font // Font
fm := fontManager.Get() fm := fontManager.Get()
@@ -108,7 +133,10 @@ func (p *AudioOptions) Init(ctx *PageCtx) error {
p.title.Scale(titleScale) p.title.Scale(titleScale)
// Prompt // Prompt
p.prompt, err = NewStringItem("Enter to toggle audio -- Left/Right to change volume -- Backspace to go back", p.font, colors.White) p.prompt, err = NewStringItem(
"Enter to select -- Up/Down to navigate -- Left/Right to change volume -- Backspace to go back",
p.font, colors.White,
)
if err != nil { if err != nil {
return fmt.Errorf("creating prompt: %w", err) return fmt.Errorf("creating prompt: %w", err)
} }
@@ -129,9 +157,21 @@ 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()")
} }
// Make sure the audio_files table is populated. This page no longer // Audio output devices
// presents any track selection UI, but other parts of the app still if err = p.GetAudioDevices(); err != nil {
// read from this table. logger.Error("failed to enumerate audio devices: %v", err)
}
// Set active device index based on mixer's current device
currentName := mixer.CurrentDevice()
for i, dev := range p.AudioDevices {
if dev.Label == currentName {
p.activeDeviceIndex = i
break
}
}
// Make sure the audio_files table is populated.
if err = p.seedAudioFiles(); err != nil { if err = p.seedAudioFiles(); err != nil {
logger.Error("failed to seed audio files: %v", err) logger.Error("failed to seed audio files: %v", err)
} }
@@ -187,6 +227,50 @@ func (p *AudioOptions) CreateAudioStatusItem() error {
return nil return nil
} }
// ── Audio Output Devices ──────────────────────────────────────────────────────
func (p *AudioOptions) GetAudioDevices() error {
numAudioDevices := sdl.GetNumAudioDevices(false)
if numAudioDevices == 0 {
return nil
}
// Pre-allocate with zero length so append doesn't produce leading zero-value
// elements (the old code used make([]AudioDevice, n) then appended, which
// doubled the slice length with n zero-value entries at the front).
audioDevices := make([]AudioDevice, 0, numAudioDevices)
for i := range numAudioDevices {
label := sdl.GetAudioDeviceName(i, false)
audioDevices = append(audioDevices, AudioDevice{Label: label, ID: uint32(i)})
}
p.AudioDevices = audioDevices
for i := range audioDevices {
item, err := NewStringItem(p.AudioDevices[i].Label, p.font, colors.White)
if err != nil {
return err
}
item.Scale(p.ItemScale)
p.AudioDeviceTextures = append(p.AudioDeviceTextures, item)
}
p.NumAudioDevices = numAudioDevices
return nil
}
// SelectAudioDevice is called when the user confirms a device from the list.
// The device-switching implementation goes here; for now we just log the choice.
func (p *AudioOptions) SelectAudioDevice(deviceIndex int) error {
if deviceIndex < 0 || deviceIndex >= len(p.AudioDevices) {
return fmt.Errorf("invalid device index: %d", deviceIndex)
}
device := p.AudioDevices[deviceIndex]
if err := mixer.SwitchDevice(device.Label); err != nil {
return fmt.Errorf("failed to switch to device %s: %w", device.Label, err)
}
p.activeDeviceIndex = deviceIndex
return nil
}
// ── Volume ──────────────────────────────────────────────────────────────────── // ── Volume ────────────────────────────────────────────────────────────────────
func (p *AudioOptions) CreateVolumePercentItem() error { func (p *AudioOptions) CreateVolumePercentItem() error {
@@ -257,10 +341,36 @@ 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
// Triangle cursor — just outside the left edge of the toggle, vertically centred on it // Device list — each item centred and stacked below the toggle
nextY := p.AudioStatusToggle.Y - p.verticalMargin
for i := range p.AudioDeviceTextures {
item := &p.AudioDeviceTextures[i]
item.X = p.ScreenCenterX - item.W/2
item.Y = nextY - item.H
nextY = item.Y - p.verticalMargin
}
// Reposition the triangle cursor for whatever row currently has focus
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) cursorGap := int32(12)
p.triX = float32(p.AudioStatusToggle.X) - float32(triangleSize) - float32(cursorGap)
p.triCenterY = float32(p.AudioStatusToggle.Y) + float32(p.AudioStatusToggle.H)/2 var item *StringItem
switch {
case p.focusState == focusToggle:
item = &p.AudioStatusToggle
case p.focusState >= 0 && p.focusState < len(p.AudioDeviceTextures):
item = &p.AudioDeviceTextures[p.focusState]
default:
return
}
p.triX = float32(item.X) - triangleSize - float32(cursorGap)
p.triCenterY = float32(item.Y) + float32(item.H)/2
} }
// ── Toggle ──────────────────────────────────────────────────────────────────── // ── Toggle ────────────────────────────────────────────────────────────────────
@@ -283,7 +393,33 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
switch e.Keysym.Sym { switch e.Keysym.Sym {
case sdl.K_RETURN: case sdl.K_RETURN:
p.ToggleAudio() if p.focusState == focusToggle {
p.ToggleAudio()
} else if p.focusState >= 0 && p.focusState < len(p.AudioDevices) {
if err := p.SelectAudioDevice(p.focusState); err != nil {
logger.Error("SelectAudioDevice: %v", err)
}
}
case sdl.K_DOWN:
if p.focusState == focusToggle {
if len(p.AudioDeviceTextures) > 0 {
p.focusState = 0
p.updateCursorPosition()
}
} else if p.focusState < len(p.AudioDeviceTextures)-1 {
p.focusState++
p.updateCursorPosition()
}
case sdl.K_UP:
if p.focusState == 0 {
p.focusState = focusToggle
p.updateCursorPosition()
} else if p.focusState > 0 {
p.focusState--
p.updateCursorPosition()
}
case sdl.K_RIGHT: case sdl.K_RIGHT:
p.UpdateVolume(10) p.UpdateVolume(10)
@@ -335,7 +471,7 @@ func (p *AudioOptions) renderVolumeBar() error {
{{bx, by}, {bw, thickness}}, {{bx, by}, {bw, thickness}},
{{bx, by + bh - thickness}, {bw, thickness}}, {{bx, by + bh - thickness}, {bw, thickness}},
{{bx, by}, {thickness, bh}}, {{bx, by}, {thickness, bh}},
{{bx + bw - thickness}, {thickness, bh}}, {{bx + bw - thickness, by}, {thickness, bh}}, // fix: was missing `by` as Y component
} }
for _, r := range outlineRects { for _, r := range outlineRects {
if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil { if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil {
@@ -345,7 +481,7 @@ func (p *AudioOptions) renderVolumeBar() error {
return nil return nil
} }
// renderCursor draws the spinning triangle cursor. // renderCursor draws the spinning triangle cursor next to the focused row.
func (p *AudioOptions) renderCursor() error { func (p *AudioOptions) renderCursor() error {
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed))) scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
@@ -398,6 +534,18 @@ func (p *AudioOptions) Render() error {
return fmt.Errorf("render audio toggle: %w", err) return fmt.Errorf("render audio toggle: %w", err)
} }
// Device list
for i := range p.AudioDeviceTextures {
item := &p.AudioDeviceTextures[i]
color := colors.White
if i == p.activeDeviceIndex {
color = colors.Green
}
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), color); err != nil {
return fmt.Errorf("render audio device %d: %w", i, err)
}
}
// Spinning triangle cursor — always on top // Spinning triangle cursor — always on top
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)
@@ -414,6 +562,9 @@ 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)
for i := range p.AudioDeviceTextures {
gl.DeleteTextures(1, &p.AudioDeviceTextures[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