feat(cli): publish desktop audio exclusion
This commit is contained in:
@@ -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 <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
|
||||
- `--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 <name>` 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 <name>` 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
|
||||
|
||||
@@ -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
@@ -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<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,
|
||||
|
||||
/// 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<u32>,
|
||||
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<HostOpts> {
|
||||
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:<idx>");
|
||||
}
|
||||
(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<AecConfig, String> {
|
||||
parse_aec_arg(value).map_err(|_| {
|
||||
format!(
|
||||
"invalid AEC identity {value:?}; expected `off` or `pulse-module:<unsigned decimal>`"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[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 <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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
+65
-10
@@ -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<BareCaptureSink>;
|
||||
}
|
||||
|
||||
@@ -49,8 +51,9 @@ impl CapturePlanBackend for SystemCapturePlanBackend {
|
||||
async fn start_bare_capture_sink(
|
||||
&mut self,
|
||||
health: health::Reporter,
|
||||
aec: AecConfig,
|
||||
) -> Result<BareCaptureSink> {
|
||||
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<anyhow::Error>,
|
||||
calls: Vec<BackendCall>,
|
||||
seen_aec: Option<AecConfig>,
|
||||
}
|
||||
|
||||
impl CapturePlanBackend for FailingDesktopBackend {
|
||||
@@ -311,8 +315,10 @@ mod tests {
|
||||
async fn start_bare_capture_sink(
|
||||
&mut self,
|
||||
_health: health::Reporter,
|
||||
aec: AecConfig,
|
||||
) -> Result<BareCaptureSink> {
|
||||
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> {
|
||||
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<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> {
|
||||
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
|
||||
|
||||
+4
-3
@@ -160,11 +160,11 @@ pub(super) struct BareCaptureSink {
|
||||
}
|
||||
|
||||
impl BareCaptureSink {
|
||||
pub(super) async fn start(health: health::Reporter) -> Result<Self> {
|
||||
Self::start_with_controller(health, |capture_sink, status_tx| {
|
||||
pub(super) async fn start(health: health::Reporter, aec: AecConfig) -> Result<Self> {
|
||||
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,
|
||||
}
|
||||
|
||||
+3
-2
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user