Fix virtual mic routing: WirePlumber was restoring the stream to the speakers

WirePlumber's node.stream.restore-target remembers where each stream last
played and restores it, ignoring the default sink, so vois.rs output kept
going to the headphones. setup() now installs a wireplumber.conf.d override
(restore-target/props = false) and restarts WirePlumber on first use.
Also: virtual mic setup runs before any device queries, and keep_others_off
pulls our own stream back onto vois.rs if it drifts.
This commit is contained in:
loki5512344 2026-08-02 18:52:05 +02:00
parent aacaf7d9cf
commit 7e8972e716
4 changed files with 142 additions and 22 deletions

View file

@ -8,6 +8,27 @@ SINK_NAME="vois.rs"
SINK_DESC="vois.rs virtual mic" SINK_DESC="vois.rs virtual mic"
REMAP_NAME="vois.rs" REMAP_NAME="vois.rs"
CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/wireplumber/wireplumber.conf.d"
CONF_FILE="${CONF_DIR}/51-vois.conf"
# WirePlumber remembers stream targets and restores them, so streams that once
# played to the speakers keep going there even after the default sink changes.
# Disable that so the app's output follows the default (vois.rs).
if [ ! -f "${CONF_FILE}" ] || ! grep -q "restore-target = false" "${CONF_FILE}" 2>/dev/null; then
mkdir -p "${CONF_DIR}"
cat > "${CONF_FILE}" <<EOF
wireplumber.settings = {
node.stream.restore-target = false
node.stream.restore-props = false
}
EOF
echo "installed WirePlumber fix (${CONF_FILE})"
systemctl --user restart wireplumber || true
echo "restarted WirePlumber"
else
echo "WirePlumber fix already present"
fi
# Idempotency check: field 2 of `pactl list short <x>` is the object name. # Idempotency check: field 2 of `pactl list short <x>` is the object name.
if ! pactl list short sinks | awk -v n="${SINK_NAME}" '$2==n' | grep -q .; then if ! pactl list short sinks | awk -v n="${SINK_NAME}" '$2==n' | grep -q .; then
pactl load-module module-null-sink \ pactl load-module module-null-sink \

View file

@ -47,6 +47,22 @@ fn pick_config(
pub fn run(cfg: &AppConfig) -> Result<()> { pub fn run(cfg: &AppConfig) -> Result<()> {
let host = cpal::default_host(); let host = cpal::default_host();
// Virtual mic FIRST: point the default sink at `vois.rs` before ANY audio
// device is opened, so the output node is created with vois.rs as its
// target (otherwise it latches onto the current default = your speakers).
let prev_sink = if cfg.virtual_mic {
virtual_mic::VirtualMic::setup()?;
virtual_mic::VirtualMic::get_default_sink()
} else {
None
};
if prev_sink.is_some() {
virtual_mic::VirtualMic::set_default_sink("vois.rs");
// Wait for the session manager to apply the new default before the
// output node is created, so it latches onto vois.rs and not speakers.
virtual_mic::VirtualMic::wait_default_sink("vois.rs");
}
let input_device = capture::find_input(&host, cfg.input.as_deref())?; let input_device = capture::find_input(&host, cfg.input.as_deref())?;
let output_device = playback::find_output(&host, cfg.output.as_deref())?; let output_device = playback::find_output(&host, cfg.output.as_deref())?;
@ -120,20 +136,6 @@ pub fn run(cfg: &AppConfig) -> Result<()> {
let (in_prod, in_cons) = HeapRb::<f32>::new(ring_cap).split(); let (in_prod, in_cons) = HeapRb::<f32>::new(ring_cap).split();
let (out_prod, out_cons) = HeapRb::<f32>::new(ring_cap).split(); let (out_prod, out_cons) = HeapRb::<f32>::new(ring_cap).split();
// Virtual mic: keep `vois.rs` as the default sink for the whole run so the
// app's output lands in the virtual mic. A helper thread parks other
// apps' streams back on the real sink so their audio doesn't leak into the
// mic. The default sink is restored on exit.
let prev_sink = if cfg.virtual_mic {
virtual_mic::VirtualMic::setup()?;
virtual_mic::VirtualMic::get_default_sink()
} else {
None
};
if prev_sink.is_some() {
virtual_mic::VirtualMic::set_default_sink("vois.rs");
}
let input_stream = capture::start(&input_device, &in_supported, in_prod)?; let input_stream = capture::start(&input_device, &in_supported, in_prod)?;
let output_stream = playback::start(&output_device, &out_supported, out_cons)?; let output_stream = playback::start(&output_device, &out_supported, out_cons)?;

View file

@ -14,6 +14,20 @@ use std::time::Duration;
pub struct VirtualMic; pub struct VirtualMic;
/// WirePlumber config that stops it from pinning streams to remembered sinks.
fn wireplumber_conf() -> std::path::PathBuf {
let base = std::env::var("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
std::env::var("HOME")
.map(|h| std::path::PathBuf::from(h).join(".config"))
.unwrap_or_else(|_| std::path::PathBuf::from("."))
});
base.join("wireplumber")
.join("wireplumber.conf.d")
.join("51-vois.conf")
}
impl VirtualMic { impl VirtualMic {
pub fn setup() -> Result<Option<String>> { pub fn setup() -> Result<Option<String>> {
let pactl = "pactl"; let pactl = "pactl";
@ -72,9 +86,54 @@ impl VirtualMic {
} }
} }
if Self::ensure_wireplumber_fix() {
// First run: the fix needs a WirePlumber restart to apply.
Self::restart_wireplumber();
}
Ok(Some("vois.rs".to_string())) Ok(Some("vois.rs".to_string()))
} }
/// WirePlumber remembers the target of each stream and restores it, so a
/// stream that once played to the speakers keeps going there even after the
/// default sink is switched. Disabling that makes ALSA streams follow the
/// default sink again. Returns true if a config was (re)written.
pub fn ensure_wireplumber_fix() -> bool {
let conf = wireplumber_conf();
let conf_text = "wireplumber.settings = {\n\
\x20 node.stream.restore-target = false\n\
\x20 node.stream.restore-props = false\n\
}\n";
if let Ok(existing) = std::fs::read_to_string(&conf) {
if existing.contains("restore-target = false") {
return false;
}
}
if let Some(dir) = conf.parent() {
let _ = std::fs::create_dir_all(dir);
}
std::fs::write(&conf, conf_text).is_ok()
}
/// Restart WirePlumber so a newly written config takes effect, and wait
/// until the audio server responds again.
pub fn restart_wireplumber() {
let _ = Command::new("systemctl")
.args(["--user", "restart", "wireplumber"])
.output();
for _ in 0..40 {
if Command::new("pactl")
.args(["info"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
return;
}
thread::sleep(Duration::from_millis(100));
}
}
/// Current default sink name, if any. /// Current default sink name, if any.
pub fn get_default_sink() -> Option<String> { pub fn get_default_sink() -> Option<String> {
let out = Command::new("pactl") let out = Command::new("pactl")
@ -95,13 +154,26 @@ impl VirtualMic {
.output(); .output();
} }
/// Keep every sink-input that is NOT this app's (client name "vois") away /// Wait (up to 2 s) until the default sink is actually `name`. WirePlumber
/// from the `vois.rs` sink, moving it to `prev_sink`. Runs until `stop`. /// applies `set-default-sink` asynchronously; if the app creates its output
/// node before that, the node latches onto the old default.
pub fn wait_default_sink(name: &str) {
for _ in 0..40 {
if Self::get_default_sink().as_deref() == Some(name) {
return;
}
thread::sleep(Duration::from_millis(50));
}
}
/// Keep this app's streams (client name "vois") on the `vois.rs` sink and
/// every other app's streams off it (parked on `prev_sink`). Runs until
/// `stop`.
/// ///
/// WirePlumber routes ALSA streams to the default sink, so while the app /// WirePlumber routes ALSA streams to the default sink, so if our output
/// runs with `vois.rs` as default, other apps' audio would leak into the /// node is created before the default is switched, it can end up on the
/// virtual mic. Moving their streams explicitly sticks, so this thread /// speakers; this thread pulls it back onto the virtual mic. Moving
/// parks them on the real output. /// streams explicitly sticks, so other apps' audio never leaks into it.
pub fn keep_others_off(stop: Arc<AtomicBool>, prev_sink: String) -> thread::JoinHandle<()> { pub fn keep_others_off(stop: Arc<AtomicBool>, prev_sink: String) -> thread::JoinHandle<()> {
thread::spawn(move || { thread::spawn(move || {
while !stop.load(Ordering::Relaxed) { while !stop.load(Ordering::Relaxed) {
@ -111,7 +183,13 @@ impl VirtualMic {
if let (Ok(names), Ok(inputs)) = (client_names(), sink_inputs()) { if let (Ok(names), Ok(inputs)) = (client_names(), sink_inputs()) {
for (id, sink, client) in &inputs { for (id, sink, client) in &inputs {
let ours = names.get(client).map(|n| n == "vois").unwrap_or(false); let ours = names.get(client).map(|n| n == "vois").unwrap_or(false);
if sink == &lv && !ours { if ours && sink != &lv {
// Our own stream drifted to the speakers.
let _ = Command::new("pactl")
.args(["move-sink-input", id, "vois.rs"])
.output();
} else if !ours && sink == &lv {
// Other app leaked into the virtual mic.
let _ = Command::new("pactl") let _ = Command::new("pactl")
.args(["move-sink-input", id, &pv]) .args(["move-sink-input", id, &pv])
.output(); .output();

View file

@ -816,7 +816,26 @@ pub fn run(control: Arc<Mutex<Control>>, stats: Arc<Mutex<Stats>>, info: Session
return; return;
} }
enable_raw_mode().expect("failed to enable raw mode"); if enable_raw_mode().is_err() {
// No real terminal: keep the audio running headless.
let started = Instant::now();
loop {
let done = control
.lock()
.map(|c| {
c.quit || run_seconds > 0 && started.elapsed().as_secs() >= run_seconds as u64
})
.unwrap_or(true);
if done {
if let Ok(mut c) = control.lock() {
c.quit = true;
}
break;
}
std::thread::sleep(Duration::from_millis(50));
}
return;
}
let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture); let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture);
let mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) { let mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) {
Ok(t) => t, Ok(t) => t,