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