8.4 KiB
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):
wireplumber0.5.15 →wpctl(status, list, set-default, set-volume, set-mute, settings, inspect). Installed in kiosksystemPackages.pipewire1.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,SwitchDeviceandListDeviceshave no callers — the feature is unwired. OnlyCurrentDevice()is read, and its result is discarded inscrollerPage.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
- Operator picks a device in the UI.
wpctl set-default <ID>(runs as the kiosk user in the same PipeWire session; no sudo).- Reopen SDL's default device:
mix.CloseAudio()thenmix.OpenAudioDevice(""). The stream now follows the new default sink. - Preserve volume and resume the current track/loop — reuse the existing
SwitchDeviceteardown/restore logic. - 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:
wpctl list audio sinks→ buildID ↔ descriptionmap.- Match the stored description (exact, then case-insensitive substring —
same policy as
matchDevice). - 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. - 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:
// 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 descriptionswpctl listreports.- If keeping the SDL-based
SwitchDevicearound, document it as superseded bySetDefault; onmainit has no callers.
Suggested layout:
internal/mixer/device.go— Device type, wpctl-backed enumeration, humanization (edit in place).internal/mixer/devicectl_unix.go—exechelpers:listSinks(),setDefault(id), wrapped withwpctlresolved from PATH.internal/mixer/devicectl_other.go— stub returningerrors.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 ofwpctl list audio sinksoutput (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 inAudioOptions.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 (mirrorAudioOptions.HandleEvent).Update/Render/DestroymirrorAudioOptions; the page is simple enough to copy the pattern wholesale.
Wiring:
- Add an "Output Device" entry to the Audio Options page (either a new
StringIteminAudioOptions.gothat navigates toAudioDevices, or a section header like the existing"Audio Track"section). AudioOptions.HandleEventalready routesEnterperfocusState; extend it to route the new entry, mirroringp.Context.NavigateTo(SETTINGS).
Packaging
wpctl currently only exists because kioskconfig puts pkgs.wireplumber in
systemPackages. Make the app self-contained:
- Add
wireplumberto the package's runtime PATH inflake.nix(e.g. via the existingmakeWrapperthat already providesSSL_CERT_FILE), so the app builds and runs correctly on any host, not just the kiosk. - Keep
pkgs.wireplumberin kioskconfigsystemPackages(it also backs theradiantwave-audio-initservice) — 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 sinksoutput; verifySetDefaultinvokeswpctl set-default(fakewpctlon PATH). - Kiosk smoke test: build + deploy,
wpctl statusbefore/after; pick the HDMI sink in the UI, confirm the stream link target viapw-dump/pw-topand that volume reads 100% on the new sink.