feat(cli): publish desktop audio exclusion
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user