191 lines
8.4 KiB
Markdown
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.
|