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)
This commit is contained in:
Ryan Yin authored and ryan4yin | 二花 committed 2026-11-01 11:55:14 -06:00
1 parent be592ae75c
commit c732f3b9bb
4 files changed
+42 -161

No files matched your search

+2 -12
View File
@@ -366,26 +366,16 @@ game:
del-failed: del-failed:
kubectl delete pod --all-namespaces --field-selector="status.phase==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] [linux]
[group('vr')] [group('vr')]
vr: vr:
systemctl --user start wivrn.service 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. # Stop PC VR when finished.
[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.
[linux] [linux]
[group('vr')] [group('vr')]
vr-stop: vr-stop:
systemctl --user stop wivrn-audio.service | complete | ignore
nu {{absolute_path("scripts/wivrn-audio.nu")}} restore
systemctl --user stop wivrn.service systemctl --user stop wivrn.service
# Show PC VR service status. # Show PC VR service status.
+4 -7
View File
@@ -42,11 +42,7 @@ configurations blindly.
just vr-status just vr-status
``` ```
`just vr` returns immediately and leaves `wivrn-audio.service` waiting up to ten minutes for the `just vr-stop` stops it again. Starting the service does not enable it for future logins.
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.
4. Allow a new headset to pair for two minutes: 4. Allow a new headset to pair for two minutes:
@@ -59,8 +55,9 @@ configurations blindly.
command again. command again.
5. Audio follows the headset: WiVRn's `wivrn.sink` and `wivrn.source` exist only while a headset is 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 connected, and a WirePlumber hook in [`ai/vr.nix`](./ai/vr.nix) makes them the defaults for
`pactl get-default-sink` and `pactl get-default-source`. 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 Manage paired headsets with `wivrnctl list-paired`, `wivrnctl rename`, or `wivrnctl unpair`. Consult
`wivrnctl --help` and the subcommand's `--help` for arguments. `wivrnctl --help` and the subcommand's `--help` for arguments.
+36
View File
@@ -30,6 +30,42 @@
steam.importOXRRuntimes = true; 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. # Wait for the control interface used by wivrnctl before reporting success.
systemd.user.services.wivrn.serviceConfig = { systemd.user.services.wivrn.serviceConfig = {
Type = "dbus"; Type = "dbus";
-142
View File
@@ -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" } }
}
}