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
+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