Add audio device switching UI
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user