diff --git a/scripts/setup_virtual_mic.sh b/scripts/setup_virtual_mic.sh index 12670da..7625954 100644 --- a/scripts/setup_virtual_mic.sh +++ b/scripts/setup_virtual_mic.sh @@ -8,6 +8,27 @@ SINK_NAME="vois.rs" SINK_DESC="vois.rs virtual mic" 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}" <` is the object name. if ! pactl list short sinks | awk -v n="${SINK_NAME}" '$2==n' | grep -q .; then pactl load-module module-null-sink \ diff --git a/src/audio/mod.rs b/src/audio/mod.rs index b477ed8..de21daa 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -47,6 +47,22 @@ fn pick_config( pub fn run(cfg: &AppConfig) -> Result<()> { 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 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::::new(ring_cap).split(); let (out_prod, out_cons) = HeapRb::::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 output_stream = playback::start(&output_device, &out_supported, out_cons)?; diff --git a/src/audio/virtual_mic.rs b/src/audio/virtual_mic.rs index 0d4472c..e0d3deb 100644 --- a/src/audio/virtual_mic.rs +++ b/src/audio/virtual_mic.rs @@ -14,6 +14,20 @@ use std::time::Duration; 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 { pub fn setup() -> Result> { 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())) } + /// 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. pub fn get_default_sink() -> Option { let out = Command::new("pactl") @@ -95,13 +154,26 @@ impl VirtualMic { .output(); } - /// Keep every sink-input that is NOT this app's (client name "vois") away - /// from the `vois.rs` sink, moving it to `prev_sink`. Runs until `stop`. + /// Wait (up to 2 s) until the default sink is actually `name`. WirePlumber + /// 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 - /// runs with `vois.rs` as default, other apps' audio would leak into the - /// virtual mic. Moving their streams explicitly sticks, so this thread - /// parks them on the real output. + /// WirePlumber routes ALSA streams to the default sink, so if our output + /// node is created before the default is switched, it can end up on the + /// speakers; this thread pulls it back onto the virtual mic. Moving + /// streams explicitly sticks, so other apps' audio never leaks into it. pub fn keep_others_off(stop: Arc, prev_sink: String) -> thread::JoinHandle<()> { thread::spawn(move || { while !stop.load(Ordering::Relaxed) { @@ -111,7 +183,13 @@ impl VirtualMic { if let (Ok(names), Ok(inputs)) = (client_names(), sink_inputs()) { for (id, sink, client) in &inputs { 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") .args(["move-sink-input", id, &pv]) .output(); diff --git a/src/ui/tui.rs b/src/ui/tui.rs index 81c8fa7..3b60cb4 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -816,7 +816,26 @@ pub fn run(control: Arc>, stats: Arc>, info: Session 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 mut terminal = match Terminal::new(CrosstermBackend::new(stdout())) { Ok(t) => t,