Zero behaviour change. This is what makes the screenshare exclusion engine able to see us at all (plan §5.1, impl plan §3): pixelpass must refuse to fan out our own playback, and until now it had no way to recognise it. Two carriers, matched by pixelpass as a union — `peerspeak.owned=1` and a `node.name` prefix `peerspeak_owned_<role>_<pid>`. Round 8 added the second after the phase-5 audit found a node property is invisible to the PipeWire registry `global` event and recoverable only by binding the node; the prefix is announced directly. A union is also the fail-closed direction: a missed tag leaks call audio into a share, a spurious one only over-excludes. Three tagging sites, all three verified live on this host: - native call playback → props on the stream dict - screenshare mpv/VLC → PULSE_PROP + PIPEWIRE_PROPS on the child - notification chimes → same, on pw-play/paplay The literals are a cross-repo wire contract, so they appear once here as named constants and are pinned in a fixture committed byte-identical in both repos (tests/fixtures/ownership-tag-contract.txt). The contract test is black-box: it builds a real child `Command` and reads back the environment it would carry, rather than testing our own formatter. Three live `#[ignore]`d exit-gate tests drive the real call sites and poll `pw-dump` for the resulting node — the plan requires the tag be shown landing on a live node, not just in the env. All three mutation-verified (drop either carrier, or the role, and the matching gate fails). Measured while verifying: mpv, VLC, pw-play and paplay all honour `node.name` from those env vars. The native stream set neither `application.name` nor a description, so a mixer fell back to `node.name` — which the tag turns into an internal identifier. Added an explicit `node.description = "PeerSpeak"` there, which keeps the plan's rule (the prefix must not reach `node.description`) while preserving its intent: mixers stay readable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
4.6 KiB
Rust
125 lines
4.6 KiB
Rust
use std::sync::Arc;
|
||
use std::sync::atomic::AtomicUsize;
|
||
use std::sync::mpsc::{Receiver, Sender};
|
||
use thiserror::Error;
|
||
|
||
/// Playback output channel count. Capture/encode/network remain mono; only the
|
||
/// listener-side playout bus is stereo.
|
||
pub const PLAYBACK_CHANNELS: usize = 2;
|
||
|
||
/// Target depth of the playback ring buffer, in interleaved samples (48kHz
|
||
/// stereo).
|
||
///
|
||
/// The playout chain is paced to keep the ring near this level: production is
|
||
/// driven by how fast PipeWire actually drains the ring (the hardware clock),
|
||
/// not by a fixed software timer — which is what eliminates the producer/
|
||
/// consumer beat that otherwise churns ~20% of audio into drops + silence.
|
||
/// 5760 = 60ms = 3×20ms stereo frames, comfortably above the 2048-frame max quantum
|
||
/// so a single hardware pull can never empty the ring before the mixer refills.
|
||
pub const PLAYBACK_TARGET_SAMPLES: usize = 2880 * PLAYBACK_CHANNELS;
|
||
|
||
#[derive(Error, Debug)]
|
||
pub enum AudioError {
|
||
#[error("Failed to initialize audio backend: {0}")]
|
||
Init(String),
|
||
#[error("Audio device error: {0}")]
|
||
Device(String),
|
||
#[error("Stream error: {0}")]
|
||
Stream(String),
|
||
#[error("Audio buffer overflow/underflow")]
|
||
BufferError,
|
||
#[error("Other audio error: {0}")]
|
||
Other(String),
|
||
}
|
||
|
||
pub trait AudioBackend: Send + Sync {
|
||
/// Starts capturing raw PCM audio from the input device (microphone),
|
||
/// sending chunks of samples (e.g. `Vec<i16>`) to the provided Sender.
|
||
fn start_capture(
|
||
&self,
|
||
tx: Sender<Vec<i16>>,
|
||
target_node: Option<String>,
|
||
) -> Result<(), AudioError>;
|
||
|
||
/// Starts playing back raw PCM audio to the output device (speaker),
|
||
/// reading mixed/incoming chunks of samples from the provided Receiver.
|
||
///
|
||
/// `ring_fill` is updated with the playback ring's current occupancy (in
|
||
/// samples) as the device drains and the worker fills it. The caller (the
|
||
/// mixer) reads it to pace production to the hardware clock — produce only
|
||
/// while the ring is below [`PLAYBACK_TARGET_SAMPLES`] — instead of on a
|
||
/// fixed timer that beats against the device quantum.
|
||
fn start_playback(
|
||
&self,
|
||
rx: Receiver<Vec<i16>>,
|
||
target_node: Option<String>,
|
||
ring_fill: Arc<AtomicUsize>,
|
||
) -> Result<(), AudioError>;
|
||
|
||
/// Stops both capture and playback streams.
|
||
fn stop(&self) -> Result<(), AudioError>;
|
||
}
|
||
|
||
pub mod clip_player;
|
||
pub mod eq;
|
||
pub mod gate;
|
||
pub mod limiter;
|
||
pub mod multitrack;
|
||
// The cross-repo ownership tag (plan §5.1). Platform-neutral on purpose: the
|
||
// carriers only matter on PipeWire, but the literals are a wire contract and
|
||
// their test must run on every platform so a rename can't pass CI elsewhere.
|
||
pub mod ownership;
|
||
pub mod pan;
|
||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||
// backend wires it in.
|
||
#[cfg(windows)]
|
||
pub mod cpal_impl;
|
||
#[cfg(target_os = "linux")]
|
||
pub mod echo_cancel;
|
||
#[cfg(target_os = "linux")]
|
||
pub mod pipewire_impl;
|
||
#[cfg(target_os = "linux")]
|
||
pub mod pw_cli;
|
||
pub mod recorder;
|
||
pub mod resample;
|
||
|
||
/// A selectable audio device for the input/output pickers. `name` is the stable
|
||
/// identifier the backend uses to request the device (`target_node`);
|
||
/// `description` is the human-facing label shown in the UI. The two may be equal
|
||
/// (cpal/WASAPI) or differ (PipeWire node name vs. description).
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct AudioDevice {
|
||
pub name: String,
|
||
pub description: String,
|
||
pub is_input: bool,
|
||
}
|
||
|
||
impl std::fmt::Display for AudioDevice {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
write!(f, "{}", self.description)
|
||
}
|
||
}
|
||
|
||
// Enumerate audio input/output devices for the pickers (sorted by description),
|
||
// returning the same `AudioDevice` shape regardless of platform: PipeWire
|
||
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
|
||
#[cfg(windows)]
|
||
pub use cpal_impl::enumerate_audio_devices;
|
||
#[cfg(target_os = "linux")]
|
||
pub use pw_cli::enumerate_audio_devices;
|
||
|
||
/// The audio backend implementation for the current platform.
|
||
///
|
||
/// The whole app constructs and threads this alias (via
|
||
/// `PlatformAudioBackend::new()`) rather than any concrete backend type, so
|
||
/// platform selection lives entirely here. Both implementations satisfy the
|
||
/// [`AudioBackend`] trait, which is the only interface the core talks to.
|
||
///
|
||
/// - Linux → PipeWire ([`pipewire_impl::PipeWireBackend`]).
|
||
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]).
|
||
#[cfg(target_os = "linux")]
|
||
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
|
||
#[cfg(windows)]
|
||
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
|