audio: tag the fourth playback path, rodio's ClipPlayer
ClipPlayer opens a rodio default sink, which on Linux reaches the graph
through PipeWire's ALSA plugin. It was untagged through all of phase 1,
and it is a real echo path: B broadcasts music, A tunes in, A shares
their desktop, B hears their own track played back at them. Confirmed
live as `alsa_playback.peerspeak-...` with no ownership properties.
rodio exposes no way to set PipeWire node properties, so the carrier is
PIPEWIRE_ALSA, set once at the top of main while still single-threaded.
Measured, with PIPEWIRE_PROPS and PULSE_PROP unset, to establish that
setting it process-wide is safe:
- aplay (ALSA plugin) -> both carriers land. Confirms the mechanism.
- pw-play (native) -> untouched. Our own call-playback and capture
streams are native, so they keep their own
explicit tagging and are unaffected.
- arecord (ALSA capture)-> IS tagged, on a Stream/Input/Audio. Not
surgical in the role dimension; harmless only
because R10-1 honours the carriers on
producers alone. This is why R10-1 lands first.
Local playlist tracks are tagged too, not just inbound peer audio. A
local track is already broadcast to peers over the call on the same
keypress, so sharing it again through the screen share would send the far
end two copies at differing latency. That is a defect, not a feature.
Codex phase-1 review F1. Round 10, R10-2.
New live exit-gate row drives the real ClipPlayer; mutation-verified
(drop the tag -> no node within 5s). The wiring guard is mutation-
verified too, and its first version was WRONG: it searched raw source and
passed against a main with the call deleted, because the comment above it
named the function. It strips comments now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -328,4 +328,54 @@ mod tests {
|
||||
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
|
||||
assert_eq!(seek_target(2.0, total), total);
|
||||
}
|
||||
|
||||
/// **The fourth playback path's exit gate (round 10, R10-2).** Drives a
|
||||
/// real [`ClipPlayer`] — the same object the app uses for chat clips, peer
|
||||
/// music and the local playlist — and asserts the node it puts on the
|
||||
/// graph carries both ownership carriers.
|
||||
///
|
||||
/// This path was untagged through all of phase 1, which is a real echo:
|
||||
/// B broadcasts music, A tunes in, A shares their desktop, B hears their
|
||||
/// own track. It was missed because phase 1 worked from the impl plan's
|
||||
/// list of three playback sites and that list was incomplete — so this
|
||||
/// gate drives the *player*, not the tagging helper.
|
||||
///
|
||||
/// ⚠️ **Run alone**: it sets a process-wide environment variable, which is
|
||||
/// only sound single-threaded. In production `main` does this before
|
||||
/// anything is spawned; a test binary has no such guarantee, hence
|
||||
/// `--test-threads=1`.
|
||||
///
|
||||
/// `cargo test --lib -- --ignored --test-threads=1 clip_player_node`
|
||||
#[test]
|
||||
#[ignore = "live: requires a running PipeWire daemon and pw-dump; run with --test-threads=1"]
|
||||
fn clip_player_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::{self, live_test};
|
||||
|
||||
// SAFETY: `--test-threads=1` is documented above and in the ignore
|
||||
// reason; this is the same call `main` makes, exercised for real
|
||||
// rather than reimplemented, so the gate cannot pass against a
|
||||
// formatter that production never uses.
|
||||
unsafe { ownership::tag_this_process_alsa_audio() };
|
||||
|
||||
let (player, _status) = ClipPlayer::new(1.0);
|
||||
// Six seconds of silence: long enough for the poll, inaudible.
|
||||
player.play([0u8; 32], live_test::silent_wav(6));
|
||||
|
||||
let prefix = live_test::expected_prefix(ownership::CLIP_ROLE);
|
||||
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
|
||||
player.stop();
|
||||
|
||||
let (name, owned) = found.unwrap_or_else(|| {
|
||||
panic!("no live clip-player node named {prefix:?} appeared within 5s")
|
||||
});
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
owned.as_deref(),
|
||||
Some(ownership::OWNED_PROP_VALUE),
|
||||
"carrier 1 must be on the live node, not just carrier 2"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,46 @@ pub const PULSE_PROP_ENV: &str = "PULSE_PROP";
|
||||
/// (`pw-play`, mpv on `--ao=pipewire`) receives extra stream properties.
|
||||
pub const PIPEWIRE_PROPS_ENV: &str = "PIPEWIRE_PROPS";
|
||||
|
||||
/// The environment variable read **only by PipeWire's ALSA plugin**, and so
|
||||
/// the one that reaches audio played through ALSA — which on Linux is how
|
||||
/// `rodio`/`cpal` reach the graph (round 10, R10-2).
|
||||
///
|
||||
/// This is the *in-process* carrier: unlike [`PULSE_PROP_ENV`] and
|
||||
/// [`PIPEWIRE_PROPS_ENV`], which are set per-child on a [`Command`], this one
|
||||
/// is set process-wide in `main` because the audio it tags is played by a
|
||||
/// library inside this process, with no fork to hang properties on.
|
||||
///
|
||||
/// **Measured 2026-07-25**, with `PIPEWIRE_PROPS` and `PULSE_PROP` explicitly
|
||||
/// unset, to establish that it is surgical enough to set process-wide:
|
||||
///
|
||||
/// - `aplay` (ALSA plugin) ⇒ node carries **both** carriers. ✅
|
||||
/// - `pw-play` (PipeWire-native) ⇒ `node.name=pw-play`, `peerspeak.owned`
|
||||
/// absent. ✅ So our own native call-playback and capture streams, which go
|
||||
/// through `pipewire_impl`, are **not** affected by this variable; they keep
|
||||
/// being tagged explicitly at their own site.
|
||||
/// - `arecord` (ALSA plugin, capture) ⇒ **is** tagged, on a
|
||||
/// `Stream/Input/Audio`. ⚠️ Not surgical in the *role* dimension. Harmless
|
||||
/// only because pixelpass honours the carriers on `Stream/Output/Audio`
|
||||
/// alone (R10-1) — which is why R10-1 lands first. Nothing in peerspeak
|
||||
/// captures via ALSA on Linux today (the Linux backend is native PipeWire;
|
||||
/// `cpal` is the Windows backend), so this is a guard against a future
|
||||
/// caller, not a live condition.
|
||||
pub const PIPEWIRE_ALSA_ENV: &str = "PIPEWIRE_ALSA";
|
||||
|
||||
/// Role of in-process audio played through `rodio`: received chat clips, peer
|
||||
/// music, and locally chosen playlist tracks (round 10, R10-2).
|
||||
///
|
||||
/// **All three are tagged, deliberately**, including the local playlist. The
|
||||
/// case for exempting locally chosen music is that the user picked it and may
|
||||
/// want it shared; the case against is stronger. A local track played from the
|
||||
/// playlist is *already being broadcast to peers over the call* on the same
|
||||
/// keypress (`app::play_local_track` → `broadcast_track`), so sharing it a
|
||||
/// second time through the screen-share sends the far end two copies of the
|
||||
/// same audio, offset by the two paths' differing latency. That is not a lost
|
||||
/// feature, it is a defect. A user who wants music in the share can play it in
|
||||
/// any other application, which peerspeak never tags.
|
||||
pub const CLIP_ROLE: &str = "clip";
|
||||
|
||||
/// Role of the native call-playback stream — the node carrying the far end's
|
||||
/// voice.
|
||||
pub const NATIVE_PLAYBACK_ROLE: &str = "call";
|
||||
@@ -135,6 +175,46 @@ pub fn tag_child(command: &mut Command, role: &str) {
|
||||
command.env(PIPEWIRE_PROPS_ENV, pipewire_props_value(&node_name));
|
||||
}
|
||||
|
||||
/// Tag the audio this process plays **through ALSA** — in practice everything
|
||||
/// `rodio` plays, which is [`crate::audio::clip_player::ClipPlayer`]: received
|
||||
/// chat clips, peer music, and local playlist tracks.
|
||||
///
|
||||
/// `ClipPlayer` opens a `rodio` default sink, which on Linux reaches the graph
|
||||
/// through PipeWire's ALSA plugin. It is a genuine fourth playback path and it
|
||||
/// was untagged until round 10, which is a real echo: B broadcasts music, A
|
||||
/// tunes in, A shares their desktop, and B hears their own track played back
|
||||
/// at them. Found by Codex's phase-1 review; confirmed live as
|
||||
/// `alsa_playback.peerspeak-…` with no ownership properties at all.
|
||||
///
|
||||
/// # Why an environment variable and not a property on the stream
|
||||
///
|
||||
/// `rodio` exposes no way to set PipeWire node properties — it is an ALSA
|
||||
/// consumer several layers down. The alternative fix is to rebuild `ClipPlayer`
|
||||
/// on peerspeak's own PipeWire backend, which is a large change for an
|
||||
/// identical outcome. [`PIPEWIRE_ALSA_ENV`] reaches exactly the ALSA path and
|
||||
/// nothing else (measured — see the constant).
|
||||
///
|
||||
/// # Must be called before any thread starts
|
||||
///
|
||||
/// `std::env::set_var` is `unsafe` in edition 2024 because a concurrent
|
||||
/// `getenv` in another thread is a data race. The only safe moment is the top
|
||||
/// of `main`, before anything is spawned; that is also correct on the merits,
|
||||
/// since the plugin reads the variable when a stream is opened.
|
||||
///
|
||||
/// Inherited values are **replaced**. Unlike the `Command` carriers (R10-5),
|
||||
/// there is nothing to preserve: no user sets `PIPEWIRE_ALSA` to route
|
||||
/// peerspeak's own clip playback, and honouring one would defeat the tag.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// The caller must guarantee no other thread exists in this process.
|
||||
pub unsafe fn tag_this_process_alsa_audio() {
|
||||
let node_name = owned_node_name(CLIP_ROLE);
|
||||
// SAFETY: the caller's obligation, discharged by calling this at the top
|
||||
// of `main` before any thread is spawned.
|
||||
unsafe { std::env::set_var(PIPEWIRE_ALSA_ENV, pipewire_props_value(&node_name)) };
|
||||
}
|
||||
|
||||
/// Live-test support for the phase-1 exit gate, shared by the two modules
|
||||
/// that spawn tagged players (`notify`, `screenshare`).
|
||||
///
|
||||
@@ -353,6 +433,72 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// R10-2's in-process carrier, checked as a *value*, not by setting the
|
||||
/// variable: mutating the process environment from a test races every
|
||||
/// other test in the binary. That the value reaches `PIPEWIRE_ALSA` at all
|
||||
/// is what the live gate below proves, and only a live gate can.
|
||||
#[test]
|
||||
fn the_alsa_carrier_value_carries_both_carriers() {
|
||||
let name = owned_node_name(CLIP_ROLE);
|
||||
let value = pipewire_props_value(&name);
|
||||
|
||||
assert_eq!(PIPEWIRE_ALSA_ENV, "PIPEWIRE_ALSA");
|
||||
assert!(
|
||||
value.contains(&format!(
|
||||
"\"{}\" = \"{}\"",
|
||||
fixture_get("prop_key"),
|
||||
fixture_get("prop_value")
|
||||
)),
|
||||
"carrier 1: {value}"
|
||||
);
|
||||
assert!(
|
||||
value.contains(&format!("\"node.name\" = \"{name}\"")),
|
||||
"carrier 2: {value}"
|
||||
);
|
||||
assert!(name.starts_with(&fixture_get("node_name_prefix")));
|
||||
// The role is a distinct token, so an audit can tell clip audio from
|
||||
// the call stream and from a spawned player.
|
||||
assert!(name.contains("_clip_"), "role token present: {name}");
|
||||
}
|
||||
|
||||
/// The in-process carrier only works if `main` actually sets it, and the
|
||||
/// live gate cannot prove that half: a test binary has no `main`, so it
|
||||
/// must call the tagger itself. That leaves exactly one way to regress —
|
||||
/// delete the line from `main` — and this is the cheapest thing that
|
||||
/// catches it.
|
||||
///
|
||||
/// A source-text assertion is crude. It is also the *only* check available
|
||||
/// short of driving the real GUI binary, and phase 1's miss was precisely
|
||||
/// a call site nobody verified existed.
|
||||
#[test]
|
||||
fn main_tags_this_process_before_anything_starts() {
|
||||
const MAIN: &str = include_str!("../main.rs");
|
||||
|
||||
// ⚠️ Comments are stripped first, and that is not tidiness. The first
|
||||
// version of this test searched the raw source and **passed against a
|
||||
// `main` with the call deleted**, because the explanatory comment
|
||||
// above it still named the function. A gate satisfiable by two sources
|
||||
// gates neither (the phase-3r lesson); here the prose was the second
|
||||
// source. Caught by mutation, which is the only reason it is not still
|
||||
// green and worthless.
|
||||
let code: String = MAIN
|
||||
.lines()
|
||||
.map(|line| line.split("//").next().unwrap_or(""))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let call = code
|
||||
.find("tag_this_process_alsa_audio()")
|
||||
.expect("main must call tag_this_process_alsa_audio (round 10, R10-2)");
|
||||
let gui = code
|
||||
.find("run_gui")
|
||||
.expect("main runs the GUI; this test's ordering check assumes it");
|
||||
assert!(
|
||||
call < gui,
|
||||
"the tag must be set before the GUI starts any thread"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_generated_name_matches_the_prefix_pixelpass_looks_for() {
|
||||
for role in ["call", "mpv", "vlc", "notify"] {
|
||||
|
||||
+16
@@ -3,6 +3,22 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
// Tag the audio we play through ALSA (rodio's `ClipPlayer`: chat clips,
|
||||
// peer music, local playlist tracks) so the screen-share exclusion engine
|
||||
// can recognise it as ours and refuse to fan it back to the far end.
|
||||
//
|
||||
// First statement in the program, and that is load-bearing: this sets an
|
||||
// environment variable, which is only sound while the process is still
|
||||
// single-threaded, and PipeWire's ALSA plugin reads it when a stream is
|
||||
// opened. See `audio::ownership::tag_this_process_alsa_audio`.
|
||||
//
|
||||
// SAFETY: nothing has been spawned yet, so no thread can be reading the
|
||||
// environment concurrently.
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
peerspeak::audio::ownership::tag_this_process_alsa_audio()
|
||||
};
|
||||
|
||||
if let Err(e) = peerspeak::app::run_gui() {
|
||||
eprintln!("Error running GUI: {:?}", e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user