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
|
- 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
|
||||||
|
|||||||
@@ -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 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 7–8.
|
//! 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
@@ -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
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user