Files
radiantwave-legacy/audio-device-picker.md

191 lines
8.4 KiB
Markdown

# 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.