diff --git a/src/host/audio.rs b/src/host/audio.rs index 214e9b5..8d6b6ac 100644 --- a/src/host/audio.rs +++ b/src/host/audio.rs @@ -17,6 +17,15 @@ //! filtered audio twice (once via the routed stream, once via the //! default-sink monitor loopback). //! +//! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves* +//! the chosen app off the sharer's speakers into the null-sink, so without +//! this the sharer would go deaf to the very content they're sharing. We +//! mirror the null-sink's monitor back to `@DEFAULT_SINK@` so the sharer +//! hears it too. Only the chosen app is in the null-sink — never the +//! desktop/call — so this can't echo back into the capture. It is loaded on +//! the first routed stream (after the default-sink loopback is gone, so the +//! two never coexist and feed back) and unloaded when the app stops. +//! //! pactl is the right tool for the one-shot null-sink/loopback graph //! mutations. libpipewire is dragged in only when per-stream filtering //! is requested, because that needs registry-event subscription. @@ -40,6 +49,11 @@ pub struct Routing { /// first successful route. `Routing::shutdown` unloads whatever /// remains. loopback_module: Arc>>, + /// The `null-sink.monitor → @DEFAULT_SINK@` loopback that lets the sharer + /// hear the routed app. Shared with the event task, which loads it on the + /// first routed stream and unloads it when the app stops. `None` outside + /// app mode and whenever no app is currently routed. + local_monitor_module: Arc>>, sink_name: String, stream_router: Option, event_task: Option>, @@ -87,9 +101,11 @@ impl Routing { ); let loopback_arc = Arc::new(Mutex::new(loopback_module)); + let local_monitor_arc = Arc::new(Mutex::new(None)); let mut routing = Self { sink_module: Some(sink_module), loopback_module: Arc::clone(&loopback_arc), + local_monitor_module: Arc::clone(&local_monitor_arc), sink_name: sink_name.clone(), stream_router: None, event_task: None, @@ -98,6 +114,7 @@ impl Routing { if let Some(app) = &opts.app { let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?; let loopback_for_task = Arc::clone(&loopback_arc); + let local_monitor_for_task = Arc::clone(&local_monitor_arc); let sink_name_for_task = sink_name.clone(); let strict = opts.strict_audio; let event_task = tokio::spawn(async move { @@ -112,6 +129,32 @@ impl Routing { ); unload_module(id); } + // Mirror the routed app back to the sharer's own + // speakers so they hear the content they're sharing. + // Loaded *after* the default-sink loopback is gone so + // the two never coexist (which would feed back), and + // sourced from the null-sink monitor — the chosen app + // only, never the desktop/call — so it can't echo into + // the capture. + if local_monitor_for_task.lock().unwrap().is_none() { + match load_module(&[ + "module-loopback", + &format!("source={sink_name_for_task}.monitor"), + "sink=@DEFAULT_SINK@", + "latency_msec=20", + ]) { + Ok(id) => { + tracing::info!( + module = id, + "audio routing: local monitor loaded (sharer hears the shared app)" + ); + *local_monitor_for_task.lock().unwrap() = Some(id); + } + Err(e) => tracing::warn!( + "audio routing: failed to load local monitor loopback: {e:#}" + ), + } + } // Tell the front-end the chosen app's audio is live. output::emit(output::Event::AppAudio { state: AppAudioState::Routed, @@ -123,6 +166,16 @@ impl Routing { output::emit(output::Event::AppAudio { state: AppAudioState::Lost, }); + // The shared app is gone, so its null-sink is silent: + // stop mirroring it to the sharer's speakers. Re-loads + // on the next FirstRoutedStream if the app resumes. + if let Some(id) = local_monitor_for_task.lock().unwrap().take() { + tracing::info!( + module = id, + "audio routing: last routed stream gone → unloading local monitor" + ); + unload_module(id); + } if strict { // Strict mode: do NOT restore the whole-desktop // loopback. Viewers hear silence until the app @@ -198,6 +251,11 @@ impl Routing { if let Some(id) = self.loopback_module.lock().unwrap().take() { unload_module(id); } + // Unload the local monitor before the null-sink it reads from, so the + // sink has no active loopback reader when it's destroyed. + if let Some(id) = self.local_monitor_module.lock().unwrap().take() { + unload_module(id); + } if let Some(id) = self.sink_module.take() { unload_module(id); } diff --git a/src/repair.rs b/src/repair.rs index 0bc9d23..aa6d81e 100644 --- a/src/repair.rs +++ b/src/repair.rs @@ -50,13 +50,11 @@ pub async fn run() -> Result<()> { if m.name != "module-loopback" { continue; } - let Some(sink) = extract_kv(&m.args, "sink") else { - continue; - }; - let Some(pid_str) = sink.strip_prefix(SINK_NAME_PREFIX) else { - continue; - }; - let Ok(pid) = pid_str.parse::() else { + // A pixelpass loopback references a capture sink either as its + // destination (`sink=pixelpass_capture_` — the default→null + // mirror) or as its source (`source=pixelpass_capture_.monitor` + // — the local monitor that lets the sharer hear the app). Match both. + let Some(pid) = loopback_capture_pid(&m.args) else { continue; }; if dead_pids.contains(&pid) { @@ -166,6 +164,17 @@ fn list_modules() -> Result> { Ok(modules) } +/// The `pixelpass_capture_` PID a loopback references, whether the capture +/// sink is its destination (`sink=pixelpass_capture_`) or its source +/// (`source=pixelpass_capture_.monitor`). `None` for unrelated loopbacks. +fn loopback_capture_pid(args: &str) -> Option { + let from_sink = extract_kv(args, "sink").and_then(|v| v.strip_prefix(SINK_NAME_PREFIX)); + let from_source = extract_kv(args, "source") + .and_then(|v| v.strip_prefix(SINK_NAME_PREFIX)) + .and_then(|rest| rest.strip_suffix(".monitor")); + from_sink.or(from_source).and_then(|pid| pid.parse::().ok()) +} + fn extract_kv<'a>(args: &'a str, key: &str) -> Option<&'a str> { for token in args.split_whitespace() { if let Some(rest) = token.strip_prefix(key) @@ -195,3 +204,31 @@ fn unload_module(id: u32) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loopback_pid_matches_default_null_mirror_by_sink() { + // The default→null loopback: capture sink is the destination. + let args = "source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=20"; + assert_eq!(loopback_capture_pid(args), Some(4242)); + } + + #[test] + fn loopback_pid_matches_local_monitor_by_source() { + // The local monitor: capture sink's monitor is the source, and the + // destination is the real default sink (not a pixelpass name). + let args = "source=pixelpass_capture_4242.monitor sink=@DEFAULT_SINK@ latency_msec=20"; + assert_eq!(loopback_capture_pid(args), Some(4242)); + } + + #[test] + fn loopback_pid_ignores_unrelated_loopback() { + assert_eq!( + loopback_capture_pid("source=alsa_output.pci.monitor sink=some_other_sink"), + None + ); + } +}