From 792f2bd55a9212410265c61b36a1eda51c4e156f Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 21 Aug 2026 21:53:54 -0400 Subject: [PATCH] feat(cli): publish desktop audio exclusion --- README.md | 44 +++++++-- src/capabilities.rs | 59 ++++++++++++ src/cli.rs | 208 ++++++++++++++++++++++++++++++++++++++--- src/host/aec/mod.rs | 2 - src/host/audio.rs | 1 + src/host/audio_plan.rs | 75 +++++++++++++-- src/host/graph.rs | 7 +- src/host/mod.rs | 5 +- src/host/pipeline.rs | 1 + src/host/quality.rs | 1 + src/main.rs | 5 + 11 files changed, 367 insertions(+), 41 deletions(-) create mode 100644 src/capabilities.rs diff --git a/README.md b/README.md index 671eeb9..ebe505e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ Working: - VAAPI H.264 encode in GStreamer (RDNA3 confirmed; other VAAPI-capable GPUs should work), with a software x264 fallback via `--no-hwencode` - Audio capture of the default sink's monitor, with optional per-app - routing (`--app `) + routing (`--app `) and a guarded whole-desktop mode for parent + integrations (`--audio-mode=desktop-excluding`) - `--repair` cleanup of orphaned PipeWire state left by a crashed host - `--doctor` environment diagnostic (capture/encode deps, VA-API H.264, viewer player, relay reachability) — see [Diagnostics](#diagnostics) @@ -316,12 +317,34 @@ matches a built-in overrides that built-in. ## Audio By default pixelpass captures the default sink's monitor — the viewer -hears whatever the host hears. `--app ` narrows that to a single -application: pixelpass creates a per-PID null-sink and uses libpipewire to -reroute matching `Stream/Output/Audio` nodes (by `application.name`) into -it, so the viewer hears just that app instead of the whole desktop. In the -interactive menu you can pick the app from a list of what's currently -playing. +hears whatever the host hears. This is also available explicitly as +`--audio-mode=desktop-shared`. + +`--app ` narrows capture to a single application: pixelpass creates a +per-PID null-sink and uses libpipewire to reroute matching +`Stream/Output/Audio` nodes (by `application.name`) into it, so the viewer +hears just that app instead of the whole desktop. In the interactive menu +you can pick the app from a list of what's currently playing. + +Parent integrations can select guarded whole-desktop capture with +`--audio-mode=desktop-excluding`. This copies eligible playback streams into +a connection-owned capture sink while excluding PeerSpeak-tagged playback, +the exact configured echo-canceller, unsafe ancestry, and unsupported stream +formats. The mode requires an explicit AEC state so a missing integration +argument cannot silently mean "no AEC": + +```sh +pixelpass --host --audio-mode=desktop-excluding --aec=off +pixelpass --host --audio-mode=desktop-excluding --aec=pulse-module:536870919 +``` + +Integrations should use `pixelpass --capabilities`, not scrape `--help`. Its +versioned response advertises strict per-app audio and desktop exclusion as +independent capabilities: + +```json +{"schema_version":1,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":true}} +``` Microphone capture is intentionally out of scope — pixelpass is a screen-share tool meant to be paired with a dedicated voice app (Mumble, @@ -407,9 +430,10 @@ each take precedence over the chosen preset's value for that field. either one breaks playback (the first kills the demuxer, the second kills the H.264 decoder). pixelpass warns at player-launch time if either plugin isn't on disk. mpv doesn't share these dependencies. -- **Audio echo** if the host plays the stream through speakers and - captures system audio — expected, the mic / monitor picks up the - playback. Headphones bypass it. +- **Audio echo in `desktop-shared` mode** if the host plays the stream through + speakers while capturing system audio. The guarded `desktop-excluding` + mode requires a cooperating parent integration to tag its playback and + supply the active AEC identity. - **Late joiners see ~2 s of garbage** before the next keyframe lets their decoder lock. Expected behavior, not a bug. - **VAAPI driver must be package-tracked**, not an orphaned `.so` on diff --git a/src/capabilities.rs b/src/capabilities.rs new file mode 100644 index 0000000..8296484 --- /dev/null +++ b/src/capabilities.rs @@ -0,0 +1,59 @@ +//! Versioned machine-readable feature discovery for parent integrations. +//! +//! PeerSpeak may execute an older PixelPass from `PATH`, so recognizing a CLI +//! token in `--help` cannot be the primary protocol. This response advertises +//! strict per-app audio and desktop audio exclusion independently; neither can +//! accidentally imply the other. + +use anyhow::{Context, Result}; +use serde::Serialize; +use std::io::Write; + +const CAPABILITY_SCHEMA_VERSION: u32 = 1; + +#[derive(Serialize)] +struct CapabilityResponse { + schema_version: u32, + capabilities: CapabilitySet, +} + +#[derive(Serialize)] +struct CapabilitySet { + strict_app_audio: bool, + desktop_audio_exclusion: bool, +} + +fn response() -> CapabilityResponse { + CapabilityResponse { + schema_version: CAPABILITY_SCHEMA_VERSION, + capabilities: CapabilitySet { + strict_app_audio: true, + desktop_audio_exclusion: true, + }, + } +} + +fn write_response(mut writer: impl Write) -> Result<()> { + serde_json::to_writer(&mut writer, &response()).context("serialize capability response")?; + writeln!(writer).context("write capability response") +} + +pub fn run() -> Result<()> { + write_response(std::io::stdout().lock()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_one_wire_shape_is_exact_and_capabilities_are_independent() { + let mut output = Vec::new(); + write_response(&mut output).expect("serialize the capability golden"); + assert_eq!( + output, + br#"{"schema_version":1,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":true}} +"# + ); + } +} diff --git a/src/cli.rs b/src/cli.rs index 6949d50..8d97c61 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,8 @@ use anyhow::{Result, bail}; use clap::{Parser, ValueEnum}; +use crate::host::aec::{AecConfig, parse_aec_arg}; + #[derive(Parser, Debug)] #[command( name = "pixelpass", @@ -39,9 +41,36 @@ pub struct Cli { #[arg(long)] pub strict_audio: bool, - /// Internal phase-0d trigger for the not-yet-public desktop-excluding - /// capture plan. Phase 7 replaces this with the versioned public selector. - #[arg(long, hide = true, conflicts_with = "app")] + /// Select whole-desktop audio behavior. `desktop-shared` captures the + /// default output mix. `desktop-excluding` captures system audio while + /// excluding PeerSpeak-owned playback and the configured AEC identity. + #[arg( + long, + value_enum, + value_name = "MODE", + requires = "host", + conflicts_with_all = ["app", "internal_desktop_excluding"] + )] + pub audio_mode: Option, + + /// Echo-canceller identity for `--audio-mode=desktop-excluding`. + /// PeerSpeak passes `off` when no AEC is active, or the exact Pulse module + /// index returned by `pactl load-module` as `pulse-module:`. + #[arg( + long, + value_name = "off|pulse-module:", + requires = "host", + value_parser = parse_aec_cli + )] + pub aec: Option, + + /// Internal phase-0d trigger retained for deterministic compatibility and + /// mutation tests. Production integrations use `--audio-mode`. + #[arg( + long, + hide = true, + conflicts_with_all = ["app", "audio_mode"] + )] pub internal_desktop_excluding: bool, /// Override display server autodetection. @@ -107,6 +136,10 @@ pub struct Cli { #[arg(long, short)] pub verbose: bool, + /// Print the versioned machine-readable capability response, then exit. + #[arg(long)] + pub capabilities: bool, + /// Clean up orphaned PipeWire state from a crashed host run, then exit. #[arg(long)] pub repair: bool, @@ -162,6 +195,14 @@ pub enum OutputFormat { Json, } +/// Public values for the whole-desktop audio selector. These names are the +/// Phase-7 cross-repository CLI contract consumed by PeerSpeak. +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum AudioModeArg { + DesktopShared, + DesktopExcluding, +} + /// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps) /// tuple — resolution is a quality-per-bitrate knob, so the three only make /// sense together. `Auto` has no fixed tuple; it picks one of the others from @@ -180,9 +221,8 @@ pub enum Quality { Auto, } -/// Internal input to the typed audio-capture planner. `DesktopExcluding` is -/// deliberately reachable only through a hidden phase-0d trigger until the -/// public selector and capability contract land in phase 7. +/// Internal input to the typed audio-capture planner. The public `AudioModeArg` +/// is resolved to this type once, at the CLI boundary. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum CaptureMode { #[default] @@ -197,12 +237,12 @@ impl CaptureMode { } if app.is_some() { bail!( - "the internal desktop-excluding mode conflicts with --app; refusing to fall back to legacy per-app routing" + "desktop-excluding audio conflicts with --app; refusing to fall back to legacy per-app routing" ); } if legacy_null_sink { bail!( - "the internal desktop-excluding mode conflicts with PIXELPASS_AUDIO_VIA_NULL_SINK; refusing to load the legacy default-monitor loopback" + "desktop-excluding audio conflicts with PIXELPASS_AUDIO_VIA_NULL_SINK; refusing to load the legacy default-monitor loopback" ); } Ok(()) @@ -229,9 +269,12 @@ pub struct HostOpts { pub no_hwencode: bool, pub max_viewers: Option, pub interactive: bool, - /// Phase-0d internal selector. This is not a public capability or protocol - /// surface; phase 7 owns that promotion. + /// Resolved public or test-only selector. pub(crate) capture_mode: CaptureMode, + /// Explicit AEC state for desktop-excluding fan-out. Legacy invocations + /// resolve to `Off`; public desktop-excluding invocations must spell this + /// on argv so absence can never masquerade as "no AEC". + pub(crate) aec: AecConfig, /// Snapshot the legacy dogfood override during CLI resolution. Capture is /// lazy, so reading the process environment later would let it change modes /// between ticket creation and the first viewer. @@ -259,12 +302,25 @@ impl Cli { interactive: bool, legacy_null_sink: bool, ) -> Result { - let capture_mode = if self.internal_desktop_excluding { - CaptureMode::DesktopExcluding - } else { - CaptureMode::Legacy + let public_desktop_excluding = self.audio_mode == Some(AudioModeArg::DesktopExcluding); + let capture_mode = match self.audio_mode { + Some(AudioModeArg::DesktopExcluding) => CaptureMode::DesktopExcluding, + Some(AudioModeArg::DesktopShared) => CaptureMode::Legacy, + None if self.internal_desktop_excluding => CaptureMode::DesktopExcluding, + None => CaptureMode::Legacy, }; capture_mode.validate_inputs(self.app.as_deref(), legacy_null_sink)?; + let aec = match (capture_mode, self.aec) { + (CaptureMode::DesktopExcluding, Some(aec)) => aec, + (CaptureMode::DesktopExcluding, None) if public_desktop_excluding => { + bail!("--audio-mode=desktop-excluding requires --aec=off|pulse-module:"); + } + (CaptureMode::DesktopExcluding, None) => AecConfig::Off, + (CaptureMode::Legacy, Some(_)) => { + bail!("--aec requires --audio-mode=desktop-excluding"); + } + (CaptureMode::Legacy, None) => AecConfig::Off, + }; Ok(HostOpts { window: self.window, @@ -281,6 +337,7 @@ impl Cli { max_viewers: self.max_viewers, interactive, capture_mode, + aec, legacy_null_sink, relay: crate::common::endpoint::relay_override(self.relay.as_deref()), }) @@ -295,6 +352,14 @@ impl Cli { } } +fn parse_aec_cli(value: &str) -> std::result::Result { + parse_aec_arg(value).map_err(|_| { + format!( + "invalid AEC identity {value:?}; expected `off` or `pulse-module:`" + ) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -337,6 +402,121 @@ mod tests { .into_host_opts_with_legacy_override(false, false) .expect("non-conflicting hidden mode resolves"); assert_eq!(opts.capture_mode, CaptureMode::DesktopExcluding); + assert_eq!(opts.aec, AecConfig::Off); assert!(!opts.legacy_null_sink); } + + #[test] + fn phase_7_public_contract_is_visible_in_help() { + let help = Cli::command().render_long_help().to_string(); + assert!(help.contains("--audio-mode ")); + assert!(help.contains("desktop-shared")); + assert!(help.contains("desktop-excluding")); + assert!(help.contains("--aec >")); + assert!(help.contains("--capabilities")); + } + + #[test] + fn public_desktop_modes_resolve_to_the_typed_planner() { + let shared = Cli::try_parse_from(["pixelpass", "--host", "--audio-mode=desktop-shared"]) + .expect("public shared mode parses") + .into_host_opts_with_legacy_override(false, false) + .expect("public shared mode resolves"); + assert_eq!(shared.capture_mode, CaptureMode::Legacy); + assert_eq!(shared.aec, AecConfig::Off); + + let excluding = Cli::try_parse_from([ + "pixelpass", + "--host", + "--audio-mode=desktop-excluding", + "--aec=pulse-module:536870919", + ]) + .expect("public excluding mode parses") + .into_host_opts_with_legacy_override(false, false) + .expect("public excluding mode resolves"); + assert_eq!(excluding.capture_mode, CaptureMode::DesktopExcluding); + assert_eq!(excluding.aec, AecConfig::PulseModule(536_870_919)); + } + + #[test] + fn public_desktop_excluding_requires_explicit_aec_state() { + let cli = Cli::try_parse_from(["pixelpass", "--host", "--audio-mode=desktop-excluding"]) + .expect("mode token parses before semantic validation"); + let error = cli + .into_host_opts_with_legacy_override(false, false) + .expect_err("missing AEC state must fail the public excluding mode"); + assert!(error.to_string().contains("requires --aec")); + } + + #[test] + fn public_audio_protocol_flags_require_host_mode() { + for args in [ + ["pixelpass", "--audio-mode=desktop-shared"], + ["pixelpass", "--aec=off"], + ] { + let error = Cli::try_parse_from(args) + .expect_err("host-only audio protocol flag parsed without --host"); + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + } + + #[test] + fn aec_is_rejected_outside_desktop_excluding_and_malformed_at_parse_time() { + let cli = Cli::try_parse_from(["pixelpass", "--host", "--aec=off"]) + .expect("AEC token parses before mode validation"); + assert!( + cli.into_host_opts_with_legacy_override(false, false) + .expect_err("legacy capture must not silently ignore an AEC identity") + .to_string() + .contains("requires --audio-mode=desktop-excluding") + ); + + let malformed = Cli::try_parse_from([ + "pixelpass", + "--host", + "--audio-mode=desktop-excluding", + "--aec=module:7", + ]) + .expect_err("the public CLI must use the Phase-4 exact grammar"); + assert_eq!(malformed.kind(), clap::error::ErrorKind::ValueValidation); + } + + #[test] + fn old_peerspeak_host_argv_golden_is_byte_compatible_without_aec() { + let whole_desktop = Cli::try_parse_from(["pixelpass", "--host", "--output", "json"]) + .expect("new PixelPass must accept old whole-desktop argv unchanged") + .into_host_opts_with_legacy_override(false, false) + .expect("old whole-desktop argv resolves without new flags"); + assert_eq!(whole_desktop.capture_mode, CaptureMode::Legacy); + assert_eq!(whole_desktop.aec, AecConfig::Off); + assert!(whole_desktop.app.is_none()); + + // Exact argv emitted by PeerSpeak before the Phase-8 integration. + let cli = Cli::try_parse_from([ + "pixelpass", + "--host", + "--output", + "json", + "--app=Firefox", + "--strict-audio", + ]) + .expect("new PixelPass must accept old PeerSpeak argv unchanged"); + assert!(cli.host); + assert_eq!(cli.output, Some(OutputFormat::Json)); + assert_eq!(cli.app.as_deref(), Some("Firefox")); + assert!(cli.strict_audio); + assert!(cli.audio_mode.is_none()); + assert!(cli.aec.is_none()); + + let opts = cli + .into_host_opts_with_legacy_override(false, false) + .expect("old PeerSpeak argv resolves without new flags"); + assert_eq!(opts.capture_mode, CaptureMode::Legacy); + assert_eq!(opts.aec, AecConfig::Off); + assert_eq!(opts.app.as_deref(), Some("Firefox")); + assert!(opts.strict_audio); + } } diff --git a/src/host/aec/mod.rs b/src/host/aec/mod.rs index b91980d..398843c 100644 --- a/src/host/aec/mod.rs +++ b/src/host/aec/mod.rs @@ -46,8 +46,6 @@ //! adapter; this module consumes the already-parsed //! [`NodeProps::pulse_module_id`](crate::host::taint::snapshot::NodeProps). -#![allow(dead_code)] // Wired into `--aec` parsing + the recompute loop by later phases. - #[cfg(test)] mod tests; diff --git a/src/host/audio.rs b/src/host/audio.rs index 00541c8..f45a978 100644 --- a/src/host/audio.rs +++ b/src/host/audio.rs @@ -860,6 +860,7 @@ mod tests { max_viewers: None, interactive: false, capture_mode: crate::cli::CaptureMode::Legacy, + aec: crate::host::aec::AecConfig::Off, legacy_null_sink: false, relay: None, } diff --git a/src/host/audio_plan.rs b/src/host/audio_plan.rs index 5218afc..60f9fd5 100644 --- a/src/host/audio_plan.rs +++ b/src/host/audio_plan.rs @@ -10,12 +10,13 @@ //! has no module ledger or loopback constructor. //! //! Phase 6 feeds the last variant through retained, non-lingering native -//! PipeWire links. The selector remains the hidden phase-0d trigger until the -//! public capability and cross-repository protocol land in phases 7–8. +//! PipeWire links. Phase 7 exposes the typed selector and versioned capability; +//! Phase 8 is the PeerSpeak-side capability-gated integration. use anyhow::{Context, Result, bail}; use tokio::process::Command; +use super::aec::AecConfig; use super::audio::Routing; use super::graph::BareCaptureSink; use super::health; @@ -28,6 +29,7 @@ trait CapturePlanBackend { async fn start_bare_capture_sink( &mut self, health: health::Reporter, + aec: AecConfig, ) -> Result; } @@ -49,8 +51,9 @@ impl CapturePlanBackend for SystemCapturePlanBackend { async fn start_bare_capture_sink( &mut self, health: health::Reporter, + aec: AecConfig, ) -> Result { - BareCaptureSink::start(health).await + BareCaptureSink::start(health, aec).await } } @@ -160,7 +163,7 @@ impl CapturePlan { }), CapturePlanKind::DesktopExcluding => Ok(Self::DesktopExcluding { capture_sink: backend - .start_bare_capture_sink(health) + .start_bare_capture_sink(health, opts.aec) .await .context("desktop-excluding capture-sink setup failed")?, }), @@ -199,7 +202,6 @@ impl CapturePlan { mod tests { use super::*; use crate::cli::Quality; - use crate::host::aec::AecConfig; use crate::host::fanout::TestFanoutPolicy; use crate::host::observer::{Projection, Readiness}; use crate::host::taint::fixture::Graph; @@ -233,6 +235,7 @@ mod tests { max_viewers: None, interactive: false, capture_mode, + aec: AecConfig::Off, legacy_null_sink, relay: None, } @@ -291,6 +294,7 @@ mod tests { struct FailingDesktopBackend { failure: Option, calls: Vec, + seen_aec: Option, } impl CapturePlanBackend for FailingDesktopBackend { @@ -311,8 +315,10 @@ mod tests { async fn start_bare_capture_sink( &mut self, _health: health::Reporter, + aec: AecConfig, ) -> Result { self.calls.push(BackendCall::BareCaptureSink); + self.seen_aec = Some(aec); Err(self.failure.take().expect("one injected failure")) } } @@ -338,8 +344,13 @@ mod tests { async fn start_bare_capture_sink( &mut self, health: health::Reporter, + aec: AecConfig, ) -> Result { - BareCaptureSink::start_for_phase6_measurement(health, self.aec, self.policy).await + assert_eq!( + aec, self.aec, + "selector must carry the requested AEC config" + ); + BareCaptureSink::start_for_phase6_measurement(health, aec, self.policy).await } } @@ -352,6 +363,7 @@ mod tests { let mut backend = FailingDesktopBackend { failure: Some(failure), calls: Vec::new(), + seen_aec: None, }; let (health, _) = health::channel(); let error = match CapturePlan::start_with_backend(&opts, health, &mut backend).await { @@ -359,6 +371,7 @@ mod tests { Err(error) => error, }; assert_eq!(backend.calls, vec![BackendCall::BareCaptureSink]); + assert_eq!(backend.seen_aec, Some(AecConfig::Off)); let chain = format!("{error:#}"); assert!(chain.contains("desktop-excluding capture-sink setup failed")); assert!( @@ -400,6 +413,27 @@ mod tests { .await; } + #[tokio::test] + async fn public_aec_identity_reaches_the_bare_sink_constructor() { + let mut opts = opts(false, false, CaptureMode::DesktopExcluding, false); + opts.aec = AecConfig::PulseModule(536_870_919); + let mut backend = FailingDesktopBackend { + failure: Some(anyhow::anyhow!( + "stop after observing the constructor input" + )), + calls: Vec::new(), + seen_aec: None, + }; + let (health, _) = health::channel(); + let error = match CapturePlan::start_with_backend(&opts, health, &mut backend).await { + Ok(_) => panic!("injected backend unexpectedly constructed a plan"), + Err(error) => error, + }; + assert!(error.to_string().contains("desktop-excluding")); + assert_eq!(backend.calls, vec![BackendCall::BareCaptureSink]); + assert_eq!(backend.seen_aec, Some(opts.aec)); + } + fn pulse_source_exists(name: &str) -> bool { std::process::Command::new("pactl") .args(["get-source-volume", name]) @@ -978,6 +1012,7 @@ mod tests { label: &'static str, desktop_dbfs: f64, remote_dbfs: f64, + remote_floor_dbfs: f64, } struct PulseModuleGuard { @@ -1439,6 +1474,22 @@ mod tests { Ok(20.0 * amplitude.max(1.0e-12).log10()) } + fn local_floor_dbfs(raw: &[u8], frequency: f64) -> Result { + // A single coherent projection of stochastic sub-LSB noise can land + // in a deep null and is not a stable "control floor". Estimate this + // run's actual analysis resolution from nearby frequencies outside the + // Hann main lobe, then use the 90th percentile so one quiet bin cannot + // manufacture sensitivity the capture did not have. + let mut neighbors = Vec::new(); + for offset in (5..=25).step_by(2) { + neighbors.push(tone_dbfs(raw, frequency - f64::from(offset))?); + neighbors.push(tone_dbfs(raw, frequency + f64::from(offset))?); + } + neighbors.sort_by(f64::total_cmp); + let rank = (neighbors.len() * 9).div_ceil(10).saturating_sub(1); + Ok(neighbors[rank]) + } + fn any_echo_cancel_module_loaded() -> Result { let output = Command::new("pactl") .args(["list", "short", "modules"]) @@ -1458,7 +1509,8 @@ mod tests { let module_id = aec.id(); let (aec_playback_id, aec_playback_name) = wait_for_aec_playback(module_id).await?; let capture_sink_name = crate::repair::plan::sink_name_for(std::process::id()); - let opts = opts(false, false, CaptureMode::DesktopExcluding, false); + let mut opts = opts(false, false, CaptureMode::DesktopExcluding, false); + opts.aec = AecConfig::PulseModule(module_id); let (health, _) = health::channel(); let mut backend = Phase6MeasurementBackend { aec: AecConfig::PulseModule(module_id), @@ -1513,6 +1565,7 @@ mod tests { label: arm.label, desktop_dbfs: tone_dbfs(&raw, 440.0)?, remote_dbfs: tone_dbfs(&raw, 1_500.0)?, + remote_floor_dbfs: local_floor_dbfs(&raw, 1_500.0)?, }; if let Some(remote) = remote.as_mut() { @@ -1545,10 +1598,11 @@ mod tests { bail!("{} arm poisoned audio health: {fault}", arm.label); } eprintln!( - "Phase-6 {}: 440 Hz {:.2} dBFS, 1500 Hz {:.2} dBFS; AEC playback node {} ({})", + "Phase-6 {}: 440 Hz {:.2} dBFS, 1500 Hz {:.2} dBFS (local floor {:.2} dBFS); AEC playback node {} ({})", measurement.label, measurement.desktop_dbfs, measurement.remote_dbfs, + measurement.remote_floor_dbfs, aec_playback_name, aec_playback_id, ); @@ -1599,9 +1653,10 @@ mod tests { (guarded.desktop_dbfs - control.desktop_dbfs).abs() <= GUARDED_DESKTOP_DRIFT_DB, "guarded desktop level drifted from control: control={control:?}, guarded={guarded:?}" ); + let resolved_control_floor = control.remote_dbfs.max(control.remote_floor_dbfs); assert!( - guarded.remote_dbfs <= control.remote_dbfs + GUARDED_FLOOR_TOLERANCE_DB, - "guarded 1500 Hz energy rose above the control floor: control={control:?}, guarded={guarded:?}" + guarded.remote_dbfs <= resolved_control_floor + GUARDED_FLOOR_TOLERANCE_DB, + "guarded 1500 Hz energy rose above the locally resolved control floor ({resolved_control_floor:.2} dBFS): control={control:?}, guarded={guarded:?}" ); assert!( naive.remote_dbfs >= control.remote_dbfs + POSITIVE_CONTROL_MARGIN_DB diff --git a/src/host/graph.rs b/src/host/graph.rs index a1e7b44..057e257 100644 --- a/src/host/graph.rs +++ b/src/host/graph.rs @@ -160,11 +160,11 @@ pub(super) struct BareCaptureSink { } impl BareCaptureSink { - pub(super) async fn start(health: health::Reporter) -> Result { - Self::start_with_controller(health, |capture_sink, status_tx| { + pub(super) async fn start(health: health::Reporter, aec: AecConfig) -> Result { + Self::start_with_controller(health, move |capture_sink, status_tx| { Box::new(FanoutController::with_status_sender( capture_sink, - AecConfig::Off, + aec, status_tx, )) }) @@ -1482,6 +1482,7 @@ mod tests { max_viewers: None, interactive: false, capture_mode: crate::cli::CaptureMode::Legacy, + aec: crate::host::aec::AecConfig::Off, legacy_null_sink: false, relay: None, } diff --git a/src/host/mod.rs b/src/host/mod.rs index 78e43fc..e847bf7 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -568,7 +568,7 @@ fn copy_to_clipboard(text: &str) -> bool { fn capture_summary(opts: &HostOpts) -> String { let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()]; if opts.capture_mode == crate::cli::CaptureMode::DesktopExcluding { - bits.push("desktop-excluding-audio (internal)".to_string()); + bits.push("desktop-excluding-audio".to_string()); } else if let Some(app) = &opts.app { if opts.strict_audio { bits.push(format!("app-audio={app} (strict)")); @@ -600,6 +600,7 @@ mod tests { max_viewers: None, interactive: false, capture_mode: crate::cli::CaptureMode::Legacy, + aec: crate::host::aec::AecConfig::Off, legacy_null_sink: false, relay: None, } @@ -629,7 +630,7 @@ mod tests { desktop_excluding.capture_mode = crate::cli::CaptureMode::DesktopExcluding; assert_eq!( capture_summary(&desktop_excluding), - "fullscreen + desktop-excluding-audio (internal)" + "fullscreen + desktop-excluding-audio" ); } diff --git a/src/host/pipeline.rs b/src/host/pipeline.rs index 5700fcb..525ecdd 100644 --- a/src/host/pipeline.rs +++ b/src/host/pipeline.rs @@ -418,6 +418,7 @@ mod tests { max_viewers: None, interactive: false, capture_mode: CaptureMode::Legacy, + aec: crate::host::aec::AecConfig::Off, legacy_null_sink: false, relay: None, } diff --git a/src/host/quality.rs b/src/host/quality.rs index d360c43..9985a38 100644 --- a/src/host/quality.rs +++ b/src/host/quality.rs @@ -215,6 +215,7 @@ mod tests { max_viewers, interactive: false, capture_mode: crate::cli::CaptureMode::Legacy, + aec: crate::host::aec::AecConfig::Off, legacy_null_sink: false, relay: None, } diff --git a/src/main.rs b/src/main.rs index 01e8d99..7857d1d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod capabilities; mod cli; mod common; mod doctor; @@ -19,6 +20,10 @@ async fn main() -> Result<()> { let cli = Cli::parse(); init_tracing(cli.verbose); + if cli.capabilities { + return capabilities::run(); + } + if matches!(cli.output, Some(cli::OutputFormat::Json)) { common::output::set_json(true); }