From c732f3b9bb160a9b1c685ba09b42b4b4e1ae6df3 Mon Sep 17 00:00:00 2001 From: Ryan Yin Date: Mon, 2 Nov 2026 01:15:44 +0800 Subject: [PATCH] refactor(vr): route headset audio with a WirePlumber hook A default-node hook selects wivrn.sink/wivrn.source whenever they exist, so the polling wivrn-audio.nu unit, its saved state and `just vr-audio` go away. Streams follow via linking.follow-default-target. Assisted-by: Claude Code (claude-opus-5-5) --- Justfile | 14 +--- hosts/idols-ai/VR.md | 11 ++- hosts/idols-ai/ai/vr.nix | 36 ++++++++++ scripts/wivrn-audio.nu | 142 --------------------------------------- 4 files changed, 42 insertions(+), 161 deletions(-) delete mode 100644 scripts/wivrn-audio.nu diff --git a/Justfile b/Justfile index b3f5f370..b26a741c 100644 --- a/Justfile +++ b/Justfile @@ -366,26 +366,16 @@ game: del-failed: kubectl delete pod --all-namespaces --field-selector="status.phase==Failed" -# Start PC VR on idols-ai and route desktop audio to the headset. +# Start PC VR on idols-ai; WirePlumber routes audio to the headset once it connects. [linux] [group('vr')] vr: systemctl --user start wivrn.service - systemctl --user stop wivrn-audio.service | complete | ignore - systemd-run --user --collect --unit=wivrn-audio nu {{absolute_path("scripts/wivrn-audio.nu")}} switch --timeout 600 -# Route audio to a headset that is connected right now. -[linux] -[group('vr')] -vr-audio: - nu {{absolute_path("scripts/wivrn-audio.nu")}} switch --timeout 60 - -# Stop PC VR when finished and restore the previous audio devices. +# Stop PC VR when finished. [linux] [group('vr')] vr-stop: - systemctl --user stop wivrn-audio.service | complete | ignore - nu {{absolute_path("scripts/wivrn-audio.nu")}} restore systemctl --user stop wivrn.service # Show PC VR service status. diff --git a/hosts/idols-ai/VR.md b/hosts/idols-ai/VR.md index 03bd6199..8868131e 100644 --- a/hosts/idols-ai/VR.md +++ b/hosts/idols-ai/VR.md @@ -42,11 +42,7 @@ configurations blindly. just vr-status ``` - `just vr` returns immediately and leaves `wivrn-audio.service` waiting up to ten minutes for the - headset. When it connects, that unit makes WiVRn's speaker and microphone the defaults and moves - any stream that is already playing. `just vr-audio` does the same switch in the foreground when - the headset is already connected, and `just vr-stop` cancels the wait and restores the saved - devices. Starting the service does not enable it for future logins. + `just vr-stop` stops it again. Starting the service does not enable it for future logins. 4. Allow a new headset to pair for two minutes: @@ -59,8 +55,9 @@ configurations blindly. command again. 5. Audio follows the headset: WiVRn's `wivrn.sink` and `wivrn.source` exist only while a headset is - connected, so the switch happens then, not at `just vr` time. Check the current defaults with - `pactl get-default-sink` and `pactl get-default-source`. + connected, and a WirePlumber hook in [`ai/vr.nix`](./ai/vr.nix) makes them the defaults for + exactly that time; playing streams follow. Your own default devices stay saved and return when + the headset disconnects. Check with `pactl get-default-sink` and `pactl get-default-source`. Manage paired headsets with `wivrnctl list-paired`, `wivrnctl rename`, or `wivrnctl unpair`. Consult `wivrnctl --help` and the subcommand's `--help` for arguments. diff --git a/hosts/idols-ai/ai/vr.nix b/hosts/idols-ai/ai/vr.nix index b6757123..22849967 100644 --- a/hosts/idols-ai/ai/vr.nix +++ b/hosts/idols-ai/ai/vr.nix @@ -30,6 +30,42 @@ steam.importOXRRuntimes = true; }; + # Make the headset's speaker and microphone the defaults while it is connected. + services.pipewire.wireplumber = { + extraScripts."wivrn-default.lua" = '' + local wanted = { ["audio.sink"] = "wivrn.sink", ["audio.source"] = "wivrn.source" } + + SimpleEventHook { + name = "custom/wivrn-default", + after = { "default-nodes/find-best-default-node" }, + before = { "default-nodes/apply-default-node" }, + interests = { + EventInterest { Constraint { "event.type", "=", "select-default-node" } }, + }, + execute = function (event) + local name = wanted [event:get_properties () ["default-node.type"]] + local nodes = event:get_data ("available-nodes") + for _, props in ipairs (nodes and nodes:parse () or {}) do + if props ["node.name"] == name then + event:set_data ("selected-node", name) + return + end + end + end + }:register () + ''; + extraConfig.wivrn-default = { + "wireplumber.components" = [ + { + name = "wivrn-default.lua"; + type = "script/lua"; + provides = "custom.wivrn-default"; + } + ]; + "wireplumber.profiles".main."custom.wivrn-default" = "required"; + }; + }; + # Wait for the control interface used by wivrnctl before reporting success. systemd.user.services.wivrn.serviceConfig = { Type = "dbus"; diff --git a/scripts/wivrn-audio.nu b/scripts/wivrn-audio.nu deleted file mode 100644 index d37c7440..00000000 --- a/scripts/wivrn-audio.nu +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env nu -# -# Route desktop audio to the WiVRn headset, and back again. -# -# WiVRn creates its speaker (wivrn.sink) and microphone (wivrn.source) only -# while a headset is connected, so this script waits (bounded) for those nodes -# before it switches the default devices and moves the streams that are already -# playing. The previous defaults are saved under $XDG_RUNTIME_DIR, so they last -# for the login session only; WirePlumber's persisted defaults stay untouched. -# -# just vr start the service, then route audio to the headset -# just vr-audio route audio after a headset connected later -# just vr-stop restore the previous devices, then stop the service -# -# See hosts/idols-ai/VR.md. - -# WiVRn's PipeWire node names (server/driver/wivrn_session.cpp). -const SINK = "wivrn.sink" -const SOURCE = "wivrn.source" - -# 'pactl list' output for a target ("sinks", "sources", "sink-inputs", ...). -def list-devices [target: string] { - (^pactl --format=json list $target | complete | get stdout | from json) -} - -# The device called $name, or null. -def find-device [target: string, name: string] { - let matches = (list-devices $target | where name == $name) - if ($matches | is-empty) { null } else { $matches | get 0 } -} - -# Wait for $name to appear, up to $seconds; returns null on timeout. -def wait-for-device [target: string, name: string, seconds: int] { - let deadline = (date now) + ($seconds * 1sec) - mut waited = 0 - loop { - let device = (find-device $target $name) - if $device != null { return $device } - if (date now) > $deadline { return null } - sleep 2sec - $waited += 2 - if ($waited mod 30) == 0 { print $"audio: still waiting for the headset (($waited)s)" } - } -} - -# Where the previous defaults are remembered (per login session). -def state-path [] { - let runtime = ($env.XDG_RUNTIME_DIR? | default $"/run/user/(^id -u | str trim)") - $runtime | path join "wivrn-audio.json" -} - -# The current default sink or source name. -def default-device [kind: string] { - (^pactl $"get-default-($kind)" | complete | get stdout | str trim) -} - -# Point the default sink or source at $name. -def set-default-device [kind: string, name: string] { - ^pactl $"set-default-($kind)" $name | complete | ignore -} - -# Move every playback stream to $sink. -def move-sink-inputs [sink: string] { - for input in (list-devices "sink-inputs") { - ^pactl move-sink-input ($input.index | into string) $sink | complete | ignore - } -} - -# Move every recording stream to $source. -def move-source-outputs [source: string] { - for output in (list-devices "source-outputs") { - ^pactl move-source-output ($output.index | into string) $source | complete | ignore - } -} - -# Point the default devices at the headset and follow with current streams. -def switch [seconds: int] { - print $"audio: waiting up to ($seconds)s for the headset to connect" - let sink = (wait-for-device "sinks" $SINK $seconds) - if $sink == null { - print $"audio: the headset did not connect within ($seconds)s; run 'just vr-audio' once it does" - return - } - - let state = (state-path) - if (default-device "sink") != $SINK { - { sink: (default-device "sink"), source: (default-device "source") } | to json | save -f $state - } - - set-default-device "sink" $SINK - move-sink-inputs $SINK - print $"audio: playback -> ($SINK), previous default saved in ($state)" - - let source = (find-device "sources" $SOURCE) - if $source != null { - set-default-device "source" $SOURCE - move-source-outputs $SOURCE - print $"audio: microphone -> ($SOURCE)" - } else { - print $"audio: no ($SOURCE) yet, microphone left alone" - } -} - -# Put the remembered default devices back. -def restore [] { - let state = (state-path) - if not ($state | path exists) { - print "audio: no saved defaults, nothing to restore" - return - } - - let previous = (open --raw $state | from json) - rm $state - - if (find-device "sinks" $previous.sink) == null { - print $"audio: ($previous.sink) is gone, leaving the default to WirePlumber" - } else { - set-default-device "sink" $previous.sink - move-sink-inputs $previous.sink - print $"audio: playback -> ($previous.sink)" - } - - if (find-device "sources" $previous.source) == null { - print $"audio: ($previous.source) is gone, leaving the default to WirePlumber" - } else { - set-default-device "source" $previous.source - move-source-outputs $previous.source - print $"audio: microphone -> ($previous.source)" - } -} - -# Entry point: 'nu wivrn-audio.nu switch' / '... restore'. -def main [ - action: string = "switch" # switch to the headset, or restore the previous devices - --timeout: int = 120 # seconds to wait for the headset audio devices -] { - match $action { - "switch" => { switch $timeout } - "restore" => { restore } - _ => { error make { msg: $"unknown action '($action)'; expected switch or restore" } } - } -}