mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:46 +01:00
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:
1 parent
be592ae75c
commit
c732f3b9bb
4 files changed
+42
-161
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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" } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user