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
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
+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 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);
}
}
-2
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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 78.
//! 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
View File
@@ -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
View File
@@ -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"
);
}
+1
View File
@@ -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,
}
+1
View File
@@ -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,
}
+5
View File
@@ -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);
}