feat(cli): publish desktop audio exclusion

This commit is contained in:
2026-08-21 21:53:54 -04:00
parent e027bc65e5
commit 792f2bd55a
11 changed files with 367 additions and 41 deletions
+34 -10
View File
@@ -21,7 +21,8 @@ Working:
- VAAPI H.264 encode in GStreamer (RDNA3 confirmed; other VAAPI-capable - VAAPI H.264 encode in GStreamer (RDNA3 confirmed; other VAAPI-capable
GPUs should work), with a software x264 fallback via `--no-hwencode` GPUs should work), with a software x264 fallback via `--no-hwencode`
- Audio capture of the default sink's monitor, with optional per-app - Audio capture of the default sink's monitor, with optional per-app
routing (`--app <name>`) routing (`--app <name>`) and a guarded whole-desktop mode for parent
integrations (`--audio-mode=desktop-excluding`)
- `--repair` cleanup of orphaned PipeWire state left by a crashed host - `--repair` cleanup of orphaned PipeWire state left by a crashed host
- `--doctor` environment diagnostic (capture/encode deps, VA-API H.264, - `--doctor` environment diagnostic (capture/encode deps, VA-API H.264,
viewer player, relay reachability) — see [Diagnostics](#diagnostics) viewer player, relay reachability) — see [Diagnostics](#diagnostics)
@@ -316,12 +317,34 @@ matches a built-in overrides that built-in.
## Audio ## Audio
By default pixelpass captures the default sink's monitor — the viewer By default pixelpass captures the default sink's monitor — the viewer
hears whatever the host hears. `--app <name>` narrows that to a single hears whatever the host hears. This is also available explicitly as
application: pixelpass creates a per-PID null-sink and uses libpipewire to `--audio-mode=desktop-shared`.
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 `--app <name>` narrows capture to a single application: pixelpass creates a
interactive menu you can pick the app from a list of what's currently per-PID null-sink and uses libpipewire to reroute matching
playing. `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 Microphone capture is intentionally out of scope — pixelpass is a
screen-share tool meant to be paired with a dedicated voice app (Mumble, 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 either one breaks playback (the first kills the demuxer, the second
kills the H.264 decoder). pixelpass warns at player-launch time if kills the H.264 decoder). pixelpass warns at player-launch time if
either plugin isn't on disk. mpv doesn't share these dependencies. either plugin isn't on disk. mpv doesn't share these dependencies.
- **Audio echo** if the host plays the stream through speakers and - **Audio echo in `desktop-shared` mode** if the host plays the stream through
captures system audio — expected, the mic / monitor picks up the speakers while capturing system audio. The guarded `desktop-excluding`
playback. Headphones bypass it. 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 - **Late joiners see ~2 s of garbage** before the next keyframe lets
their decoder lock. Expected behavior, not a bug. their decoder lock. Expected behavior, not a bug.
- **VAAPI driver must be package-tracked**, not an orphaned `.so` on - **VAAPI driver must be package-tracked**, not an orphaned `.so` on
+59
View File
@@ -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}}
"#
);
}
}
+194 -14
View File
@@ -1,6 +1,8 @@
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use crate::host::aec::{AecConfig, parse_aec_arg};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command( #[command(
name = "pixelpass", name = "pixelpass",
@@ -39,9 +41,36 @@ pub struct Cli {
#[arg(long)] #[arg(long)]
pub strict_audio: bool, pub strict_audio: bool,
/// Internal phase-0d trigger for the not-yet-public desktop-excluding /// Select whole-desktop audio behavior. `desktop-shared` captures the
/// capture plan. Phase 7 replaces this with the versioned public selector. /// default output mix. `desktop-excluding` captures system audio while
#[arg(long, hide = true, conflicts_with = "app")] /// 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<AudioModeArg>,
/// 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:<idx>`.
#[arg(
long,
value_name = "off|pulse-module:<idx>",
requires = "host",
value_parser = parse_aec_cli
)]
pub aec: Option<AecConfig>,
/// 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, pub internal_desktop_excluding: bool,
/// Override display server autodetection. /// Override display server autodetection.
@@ -107,6 +136,10 @@ pub struct Cli {
#[arg(long, short)] #[arg(long, short)]
pub verbose: bool, 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. /// Clean up orphaned PipeWire state from a crashed host run, then exit.
#[arg(long)] #[arg(long)]
pub repair: bool, pub repair: bool,
@@ -162,6 +195,14 @@ pub enum OutputFormat {
Json, 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) /// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps)
/// tuple — resolution is a quality-per-bitrate knob, so the three only make /// 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 /// sense together. `Auto` has no fixed tuple; it picks one of the others from
@@ -180,9 +221,8 @@ pub enum Quality {
Auto, Auto,
} }
/// Internal input to the typed audio-capture planner. `DesktopExcluding` is /// Internal input to the typed audio-capture planner. The public `AudioModeArg`
/// deliberately reachable only through a hidden phase-0d trigger until the /// is resolved to this type once, at the CLI boundary.
/// public selector and capability contract land in phase 7.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum CaptureMode { pub(crate) enum CaptureMode {
#[default] #[default]
@@ -197,12 +237,12 @@ impl CaptureMode {
} }
if app.is_some() { if app.is_some() {
bail!( 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 { if legacy_null_sink {
bail!( 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(()) Ok(())
@@ -229,9 +269,12 @@ pub struct HostOpts {
pub no_hwencode: bool, pub no_hwencode: bool,
pub max_viewers: Option<u32>, pub max_viewers: Option<u32>,
pub interactive: bool, pub interactive: bool,
/// Phase-0d internal selector. This is not a public capability or protocol /// Resolved public or test-only selector.
/// surface; phase 7 owns that promotion.
pub(crate) capture_mode: CaptureMode, 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 /// Snapshot the legacy dogfood override during CLI resolution. Capture is
/// lazy, so reading the process environment later would let it change modes /// lazy, so reading the process environment later would let it change modes
/// between ticket creation and the first viewer. /// between ticket creation and the first viewer.
@@ -259,12 +302,25 @@ impl Cli {
interactive: bool, interactive: bool,
legacy_null_sink: bool, legacy_null_sink: bool,
) -> Result<HostOpts> { ) -> Result<HostOpts> {
let capture_mode = if self.internal_desktop_excluding { let public_desktop_excluding = self.audio_mode == Some(AudioModeArg::DesktopExcluding);
CaptureMode::DesktopExcluding let capture_mode = match self.audio_mode {
} else { Some(AudioModeArg::DesktopExcluding) => CaptureMode::DesktopExcluding,
CaptureMode::Legacy 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)?; 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:<idx>");
}
(CaptureMode::DesktopExcluding, None) => AecConfig::Off,
(CaptureMode::Legacy, Some(_)) => {
bail!("--aec requires --audio-mode=desktop-excluding");
}
(CaptureMode::Legacy, None) => AecConfig::Off,
};
Ok(HostOpts { Ok(HostOpts {
window: self.window, window: self.window,
@@ -281,6 +337,7 @@ impl Cli {
max_viewers: self.max_viewers, max_viewers: self.max_viewers,
interactive, interactive,
capture_mode, capture_mode,
aec,
legacy_null_sink, legacy_null_sink,
relay: crate::common::endpoint::relay_override(self.relay.as_deref()), 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<AecConfig, String> {
parse_aec_arg(value).map_err(|_| {
format!(
"invalid AEC identity {value:?}; expected `off` or `pulse-module:<unsigned decimal>`"
)
})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -337,6 +402,121 @@ mod tests {
.into_host_opts_with_legacy_override(false, false) .into_host_opts_with_legacy_override(false, false)
.expect("non-conflicting hidden mode resolves"); .expect("non-conflicting hidden mode resolves");
assert_eq!(opts.capture_mode, CaptureMode::DesktopExcluding); assert_eq!(opts.capture_mode, CaptureMode::DesktopExcluding);
assert_eq!(opts.aec, AecConfig::Off);
assert!(!opts.legacy_null_sink); 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 <MODE>"));
assert!(help.contains("desktop-shared"));
assert!(help.contains("desktop-excluding"));
assert!(help.contains("--aec <off|pulse-module:<idx>>"));
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);
}
} }
-2
View File
@@ -46,8 +46,6 @@
//! adapter; this module consumes the already-parsed //! adapter; this module consumes the already-parsed
//! [`NodeProps::pulse_module_id`](crate::host::taint::snapshot::NodeProps). //! [`NodeProps::pulse_module_id`](crate::host::taint::snapshot::NodeProps).
#![allow(dead_code)] // Wired into `--aec` parsing + the recompute loop by later phases.
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
+1
View File
@@ -860,6 +860,7 @@ mod tests {
max_viewers: None, max_viewers: None,
interactive: false, interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy, capture_mode: crate::cli::CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false, legacy_null_sink: false,
relay: None, relay: None,
} }
+65 -10
View File
@@ -10,12 +10,13 @@
//! has no module ledger or loopback constructor. //! has no module ledger or loopback constructor.
//! //!
//! Phase 6 feeds the last variant through retained, non-lingering native //! Phase 6 feeds the last variant through retained, non-lingering native
//! PipeWire links. The selector remains the hidden phase-0d trigger until the //! PipeWire links. Phase 7 exposes the typed selector and versioned capability;
//! public capability and cross-repository protocol land in phases 78. //! Phase 8 is the PeerSpeak-side capability-gated integration.
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use tokio::process::Command; use tokio::process::Command;
use super::aec::AecConfig;
use super::audio::Routing; use super::audio::Routing;
use super::graph::BareCaptureSink; use super::graph::BareCaptureSink;
use super::health; use super::health;
@@ -28,6 +29,7 @@ trait CapturePlanBackend {
async fn start_bare_capture_sink( async fn start_bare_capture_sink(
&mut self, &mut self,
health: health::Reporter, health: health::Reporter,
aec: AecConfig,
) -> Result<BareCaptureSink>; ) -> Result<BareCaptureSink>;
} }
@@ -49,8 +51,9 @@ impl CapturePlanBackend for SystemCapturePlanBackend {
async fn start_bare_capture_sink( async fn start_bare_capture_sink(
&mut self, &mut self,
health: health::Reporter, health: health::Reporter,
aec: AecConfig,
) -> Result<BareCaptureSink> { ) -> Result<BareCaptureSink> {
BareCaptureSink::start(health).await BareCaptureSink::start(health, aec).await
} }
} }
@@ -160,7 +163,7 @@ impl CapturePlan {
}), }),
CapturePlanKind::DesktopExcluding => Ok(Self::DesktopExcluding { CapturePlanKind::DesktopExcluding => Ok(Self::DesktopExcluding {
capture_sink: backend capture_sink: backend
.start_bare_capture_sink(health) .start_bare_capture_sink(health, opts.aec)
.await .await
.context("desktop-excluding capture-sink setup failed")?, .context("desktop-excluding capture-sink setup failed")?,
}), }),
@@ -199,7 +202,6 @@ impl CapturePlan {
mod tests { mod tests {
use super::*; use super::*;
use crate::cli::Quality; use crate::cli::Quality;
use crate::host::aec::AecConfig;
use crate::host::fanout::TestFanoutPolicy; use crate::host::fanout::TestFanoutPolicy;
use crate::host::observer::{Projection, Readiness}; use crate::host::observer::{Projection, Readiness};
use crate::host::taint::fixture::Graph; use crate::host::taint::fixture::Graph;
@@ -233,6 +235,7 @@ mod tests {
max_viewers: None, max_viewers: None,
interactive: false, interactive: false,
capture_mode, capture_mode,
aec: AecConfig::Off,
legacy_null_sink, legacy_null_sink,
relay: None, relay: None,
} }
@@ -291,6 +294,7 @@ mod tests {
struct FailingDesktopBackend { struct FailingDesktopBackend {
failure: Option<anyhow::Error>, failure: Option<anyhow::Error>,
calls: Vec<BackendCall>, calls: Vec<BackendCall>,
seen_aec: Option<AecConfig>,
} }
impl CapturePlanBackend for FailingDesktopBackend { impl CapturePlanBackend for FailingDesktopBackend {
@@ -311,8 +315,10 @@ mod tests {
async fn start_bare_capture_sink( async fn start_bare_capture_sink(
&mut self, &mut self,
_health: health::Reporter, _health: health::Reporter,
aec: AecConfig,
) -> Result<BareCaptureSink> { ) -> Result<BareCaptureSink> {
self.calls.push(BackendCall::BareCaptureSink); self.calls.push(BackendCall::BareCaptureSink);
self.seen_aec = Some(aec);
Err(self.failure.take().expect("one injected failure")) Err(self.failure.take().expect("one injected failure"))
} }
} }
@@ -338,8 +344,13 @@ mod tests {
async fn start_bare_capture_sink( async fn start_bare_capture_sink(
&mut self, &mut self,
health: health::Reporter, health: health::Reporter,
aec: AecConfig,
) -> Result<BareCaptureSink> { ) -> Result<BareCaptureSink> {
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 { let mut backend = FailingDesktopBackend {
failure: Some(failure), failure: Some(failure),
calls: Vec::new(), calls: Vec::new(),
seen_aec: None,
}; };
let (health, _) = health::channel(); let (health, _) = health::channel();
let error = match CapturePlan::start_with_backend(&opts, health, &mut backend).await { let error = match CapturePlan::start_with_backend(&opts, health, &mut backend).await {
@@ -359,6 +371,7 @@ mod tests {
Err(error) => error, Err(error) => error,
}; };
assert_eq!(backend.calls, vec![BackendCall::BareCaptureSink]); assert_eq!(backend.calls, vec![BackendCall::BareCaptureSink]);
assert_eq!(backend.seen_aec, Some(AecConfig::Off));
let chain = format!("{error:#}"); let chain = format!("{error:#}");
assert!(chain.contains("desktop-excluding capture-sink setup failed")); assert!(chain.contains("desktop-excluding capture-sink setup failed"));
assert!( assert!(
@@ -400,6 +413,27 @@ mod tests {
.await; .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 { fn pulse_source_exists(name: &str) -> bool {
std::process::Command::new("pactl") std::process::Command::new("pactl")
.args(["get-source-volume", name]) .args(["get-source-volume", name])
@@ -978,6 +1012,7 @@ mod tests {
label: &'static str, label: &'static str,
desktop_dbfs: f64, desktop_dbfs: f64,
remote_dbfs: f64, remote_dbfs: f64,
remote_floor_dbfs: f64,
} }
struct PulseModuleGuard { struct PulseModuleGuard {
@@ -1439,6 +1474,22 @@ mod tests {
Ok(20.0 * amplitude.max(1.0e-12).log10()) Ok(20.0 * amplitude.max(1.0e-12).log10())
} }
fn local_floor_dbfs(raw: &[u8], frequency: f64) -> Result<f64> {
// 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<bool> { fn any_echo_cancel_module_loaded() -> Result<bool> {
let output = Command::new("pactl") let output = Command::new("pactl")
.args(["list", "short", "modules"]) .args(["list", "short", "modules"])
@@ -1458,7 +1509,8 @@ mod tests {
let module_id = aec.id(); let module_id = aec.id();
let (aec_playback_id, aec_playback_name) = wait_for_aec_playback(module_id).await?; 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 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 (health, _) = health::channel();
let mut backend = Phase6MeasurementBackend { let mut backend = Phase6MeasurementBackend {
aec: AecConfig::PulseModule(module_id), aec: AecConfig::PulseModule(module_id),
@@ -1513,6 +1565,7 @@ mod tests {
label: arm.label, label: arm.label,
desktop_dbfs: tone_dbfs(&raw, 440.0)?, desktop_dbfs: tone_dbfs(&raw, 440.0)?,
remote_dbfs: tone_dbfs(&raw, 1_500.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() { if let Some(remote) = remote.as_mut() {
@@ -1545,10 +1598,11 @@ mod tests {
bail!("{} arm poisoned audio health: {fault}", arm.label); bail!("{} arm poisoned audio health: {fault}", arm.label);
} }
eprintln!( 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.label,
measurement.desktop_dbfs, measurement.desktop_dbfs,
measurement.remote_dbfs, measurement.remote_dbfs,
measurement.remote_floor_dbfs,
aec_playback_name, aec_playback_name,
aec_playback_id, aec_playback_id,
); );
@@ -1599,9 +1653,10 @@ mod tests {
(guarded.desktop_dbfs - control.desktop_dbfs).abs() <= GUARDED_DESKTOP_DRIFT_DB, (guarded.desktop_dbfs - control.desktop_dbfs).abs() <= GUARDED_DESKTOP_DRIFT_DB,
"guarded desktop level drifted from control: control={control:?}, guarded={guarded:?}" "guarded desktop level drifted from control: control={control:?}, guarded={guarded:?}"
); );
let resolved_control_floor = control.remote_dbfs.max(control.remote_floor_dbfs);
assert!( assert!(
guarded.remote_dbfs <= control.remote_dbfs + GUARDED_FLOOR_TOLERANCE_DB, guarded.remote_dbfs <= resolved_control_floor + GUARDED_FLOOR_TOLERANCE_DB,
"guarded 1500 Hz energy rose above the control floor: control={control:?}, guarded={guarded:?}" "guarded 1500 Hz energy rose above the locally resolved control floor ({resolved_control_floor:.2} dBFS): control={control:?}, guarded={guarded:?}"
); );
assert!( assert!(
naive.remote_dbfs >= control.remote_dbfs + POSITIVE_CONTROL_MARGIN_DB naive.remote_dbfs >= control.remote_dbfs + POSITIVE_CONTROL_MARGIN_DB
+4 -3
View File
@@ -160,11 +160,11 @@ pub(super) struct BareCaptureSink {
} }
impl BareCaptureSink { impl BareCaptureSink {
pub(super) async fn start(health: health::Reporter) -> Result<Self> { pub(super) async fn start(health: health::Reporter, aec: AecConfig) -> Result<Self> {
Self::start_with_controller(health, |capture_sink, status_tx| { Self::start_with_controller(health, move |capture_sink, status_tx| {
Box::new(FanoutController::with_status_sender( Box::new(FanoutController::with_status_sender(
capture_sink, capture_sink,
AecConfig::Off, aec,
status_tx, status_tx,
)) ))
}) })
@@ -1482,6 +1482,7 @@ mod tests {
max_viewers: None, max_viewers: None,
interactive: false, interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy, capture_mode: crate::cli::CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false, legacy_null_sink: false,
relay: None, relay: None,
} }
+3 -2
View File
@@ -568,7 +568,7 @@ fn copy_to_clipboard(text: &str) -> bool {
fn capture_summary(opts: &HostOpts) -> String { fn capture_summary(opts: &HostOpts) -> String {
let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()]; let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()];
if opts.capture_mode == crate::cli::CaptureMode::DesktopExcluding { 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 { } else if let Some(app) = &opts.app {
if opts.strict_audio { if opts.strict_audio {
bits.push(format!("app-audio={app} (strict)")); bits.push(format!("app-audio={app} (strict)"));
@@ -600,6 +600,7 @@ mod tests {
max_viewers: None, max_viewers: None,
interactive: false, interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy, capture_mode: crate::cli::CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false, legacy_null_sink: false,
relay: None, relay: None,
} }
@@ -629,7 +630,7 @@ mod tests {
desktop_excluding.capture_mode = crate::cli::CaptureMode::DesktopExcluding; desktop_excluding.capture_mode = crate::cli::CaptureMode::DesktopExcluding;
assert_eq!( assert_eq!(
capture_summary(&desktop_excluding), capture_summary(&desktop_excluding),
"fullscreen + desktop-excluding-audio (internal)" "fullscreen + desktop-excluding-audio"
); );
} }
+1
View File
@@ -418,6 +418,7 @@ mod tests {
max_viewers: None, max_viewers: None,
interactive: false, interactive: false,
capture_mode: CaptureMode::Legacy, capture_mode: CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false, legacy_null_sink: false,
relay: None, relay: None,
} }
+1
View File
@@ -215,6 +215,7 @@ mod tests {
max_viewers, max_viewers,
interactive: false, interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy, capture_mode: crate::cli::CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false, legacy_null_sink: false,
relay: None, relay: None,
} }
+5
View File
@@ -1,3 +1,4 @@
mod capabilities;
mod cli; mod cli;
mod common; mod common;
mod doctor; mod doctor;
@@ -19,6 +20,10 @@ async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
init_tracing(cli.verbose); init_tracing(cli.verbose);
if cli.capabilities {
return capabilities::run();
}
if matches!(cli.output, Some(cli::OutputFormat::Json)) { if matches!(cli.output, Some(cli::OutputFormat::Json)) {
common::output::set_json(true); common::output::set_json(true);
} }