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

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):

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

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:

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