fix(host): let the sharer hear the app they're sharing (local monitor)
In strict per-app mode the stream router *moves* the chosen app's output off the sharer's speakers into the private capture null-sink, so the viewer heard it but the sharer went silent — you couldn't watch a video together because only the remote side had audio. Add a "local monitor" loopback (null-sink.monitor → @DEFAULT_SINK@) that mirrors the routed app back to the sharer's own speakers. It carries only the chosen app (never the desktop/voice call), so it can't echo into the capture, and it's loaded on the first routed stream — after the default loopback is unloaded — so the two are never live at once (no feedback). Unloaded when the app stops and torn down before the null-sink on cleanup. Extend `--repair` to recognise this loopback by its `source=` arg (it targets @DEFAULT_SINK@, not a pixelpass name) so a crashed host's local monitor is swept too. New pure `loopback_capture_pid` + 3 unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,15 @@
|
|||||||
//! filtered audio twice (once via the routed stream, once via the
|
//! filtered audio twice (once via the routed stream, once via the
|
||||||
//! default-sink monitor loopback).
|
//! 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
|
//! pactl is the right tool for the one-shot null-sink/loopback graph
|
||||||
//! mutations. libpipewire is dragged in only when per-stream filtering
|
//! mutations. libpipewire is dragged in only when per-stream filtering
|
||||||
//! is requested, because that needs registry-event subscription.
|
//! is requested, because that needs registry-event subscription.
|
||||||
@@ -40,6 +49,11 @@ pub struct Routing {
|
|||||||
/// first successful route. `Routing::shutdown` unloads whatever
|
/// first successful route. `Routing::shutdown` unloads whatever
|
||||||
/// remains.
|
/// remains.
|
||||||
loopback_module: Arc<Mutex<Option<u32>>>,
|
loopback_module: Arc<Mutex<Option<u32>>>,
|
||||||
|
/// 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<Mutex<Option<u32>>>,
|
||||||
sink_name: String,
|
sink_name: String,
|
||||||
stream_router: Option<StreamRouter>,
|
stream_router: Option<StreamRouter>,
|
||||||
event_task: Option<tokio::task::JoinHandle<()>>,
|
event_task: Option<tokio::task::JoinHandle<()>>,
|
||||||
@@ -87,9 +101,11 @@ impl Routing {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let loopback_arc = Arc::new(Mutex::new(loopback_module));
|
let loopback_arc = Arc::new(Mutex::new(loopback_module));
|
||||||
|
let local_monitor_arc = Arc::new(Mutex::new(None));
|
||||||
let mut routing = Self {
|
let mut routing = Self {
|
||||||
sink_module: Some(sink_module),
|
sink_module: Some(sink_module),
|
||||||
loopback_module: Arc::clone(&loopback_arc),
|
loopback_module: Arc::clone(&loopback_arc),
|
||||||
|
local_monitor_module: Arc::clone(&local_monitor_arc),
|
||||||
sink_name: sink_name.clone(),
|
sink_name: sink_name.clone(),
|
||||||
stream_router: None,
|
stream_router: None,
|
||||||
event_task: None,
|
event_task: None,
|
||||||
@@ -98,6 +114,7 @@ impl Routing {
|
|||||||
if let Some(app) = &opts.app {
|
if let Some(app) = &opts.app {
|
||||||
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
|
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
|
||||||
let loopback_for_task = Arc::clone(&loopback_arc);
|
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 sink_name_for_task = sink_name.clone();
|
||||||
let strict = opts.strict_audio;
|
let strict = opts.strict_audio;
|
||||||
let event_task = tokio::spawn(async move {
|
let event_task = tokio::spawn(async move {
|
||||||
@@ -112,6 +129,32 @@ impl Routing {
|
|||||||
);
|
);
|
||||||
unload_module(id);
|
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.
|
// Tell the front-end the chosen app's audio is live.
|
||||||
output::emit(output::Event::AppAudio {
|
output::emit(output::Event::AppAudio {
|
||||||
state: AppAudioState::Routed,
|
state: AppAudioState::Routed,
|
||||||
@@ -123,6 +166,16 @@ impl Routing {
|
|||||||
output::emit(output::Event::AppAudio {
|
output::emit(output::Event::AppAudio {
|
||||||
state: AppAudioState::Lost,
|
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 {
|
if strict {
|
||||||
// Strict mode: do NOT restore the whole-desktop
|
// Strict mode: do NOT restore the whole-desktop
|
||||||
// loopback. Viewers hear silence until the app
|
// loopback. Viewers hear silence until the app
|
||||||
@@ -198,6 +251,11 @@ impl Routing {
|
|||||||
if let Some(id) = self.loopback_module.lock().unwrap().take() {
|
if let Some(id) = self.loopback_module.lock().unwrap().take() {
|
||||||
unload_module(id);
|
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() {
|
if let Some(id) = self.sink_module.take() {
|
||||||
unload_module(id);
|
unload_module(id);
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-7
@@ -50,13 +50,11 @@ pub async fn run() -> Result<()> {
|
|||||||
if m.name != "module-loopback" {
|
if m.name != "module-loopback" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(sink) = extract_kv(&m.args, "sink") else {
|
// A pixelpass loopback references a capture sink either as its
|
||||||
continue;
|
// destination (`sink=pixelpass_capture_<pid>` — the default→null
|
||||||
};
|
// mirror) or as its source (`source=pixelpass_capture_<pid>.monitor`
|
||||||
let Some(pid_str) = sink.strip_prefix(SINK_NAME_PREFIX) else {
|
// — the local monitor that lets the sharer hear the app). Match both.
|
||||||
continue;
|
let Some(pid) = loopback_capture_pid(&m.args) else {
|
||||||
};
|
|
||||||
let Ok(pid) = pid_str.parse::<u32>() else {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if dead_pids.contains(&pid) {
|
if dead_pids.contains(&pid) {
|
||||||
@@ -166,6 +164,17 @@ fn list_modules() -> Result<Vec<Module>> {
|
|||||||
Ok(modules)
|
Ok(modules)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `pixelpass_capture_<pid>` PID a loopback references, whether the capture
|
||||||
|
/// sink is its destination (`sink=pixelpass_capture_<pid>`) or its source
|
||||||
|
/// (`source=pixelpass_capture_<pid>.monitor`). `None` for unrelated loopbacks.
|
||||||
|
fn loopback_capture_pid(args: &str) -> Option<u32> {
|
||||||
|
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::<u32>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_kv<'a>(args: &'a str, key: &str) -> Option<&'a str> {
|
fn extract_kv<'a>(args: &'a str, key: &str) -> Option<&'a str> {
|
||||||
for token in args.split_whitespace() {
|
for token in args.split_whitespace() {
|
||||||
if let Some(rest) = token.strip_prefix(key)
|
if let Some(rest) = token.strip_prefix(key)
|
||||||
@@ -195,3 +204,31 @@ fn unload_module(id: u32) -> Result<()> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user