Files
peerspeak/src/audio/clip_player.rs
T
molluskandClaude Opus 5 b5569fe2c6 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>
2026-07-25 20:52:22 -04:00

382 lines
14 KiB
Rust

//! Independent playback engine for inline chat audio attachments.
//!
//! The rodio device sink stays on a dedicated OS thread and never enters iced
//! state or the call-audio pipeline. The GUI sends small commands and reads a
//! shared status snapshot at its redraw cadence.
use crate::files::AttachmentId;
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, decoder::DecoderError};
use std::io::Cursor;
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
/// State published by the playback thread for the GUI.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClipStatus {
pub playing_id: Option<AttachmentId>,
pub position: Duration,
pub total: Option<Duration>,
pub paused: bool,
/// Set when output initialization or decoding rejects the requested clip.
/// The app consumes this as a signal to fall back to the normal file chip.
pub failure: Option<ClipFailure>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipFailure {
pub id: AttachmentId,
pub error: String,
/// Decoder rejection means the filename hint should fall back to a file
/// chip. Output-device failures remain retryable as audio.
pub invalid_audio: bool,
}
pub type SharedClipStatus = Arc<Mutex<ClipStatus>>;
#[derive(Debug)]
enum ClipCommand {
Play(AttachmentId, Vec<u8>),
Pause,
Resume,
Seek(Duration),
Stop,
SetVolume(f32),
}
/// Cheap, `Send` command handle for the dedicated playback thread.
pub struct ClipPlayer {
command_tx: mpsc::Sender<ClipCommand>,
status: SharedClipStatus,
}
impl ClipPlayer {
/// Start the playback worker. The system output device is opened lazily on
/// first Play, so merely launching PeerSpeak never claims another stream.
///
/// `initial_volume` is the universal gain (`1.0` = unity) applied to every
/// clip, restored from config so the level persists across sessions.
pub fn new(initial_volume: f32) -> (Self, SharedClipStatus) {
let (command_tx, command_rx) = mpsc::channel();
let status = Arc::new(Mutex::new(ClipStatus::default()));
let worker_status = Arc::clone(&status);
std::thread::Builder::new()
.name("peerspeak-clip-player".to_string())
.spawn(move || playback_worker(command_rx, worker_status, initial_volume))
.expect("failed to spawn clip playback thread");
(
Self {
command_tx,
status: Arc::clone(&status),
},
status,
)
}
pub fn play(&self, id: AttachmentId, bytes: Vec<u8>) {
update_status(&self.status, |status| {
status.playing_id = Some(id);
status.position = Duration::ZERO;
status.total = None;
status.paused = false;
status.failure = None;
});
let _ = self.command_tx.send(ClipCommand::Play(id, bytes));
}
pub fn pause(&self) {
let _ = self.command_tx.send(ClipCommand::Pause);
}
pub fn resume(&self) {
let _ = self.command_tx.send(ClipCommand::Resume);
}
pub fn seek(&self, position: Duration) {
let _ = self.command_tx.send(ClipCommand::Seek(position));
}
pub fn stop(&self) {
let _ = self.command_tx.send(ClipCommand::Stop);
}
/// Set the universal playback gain (`1.0` = unity). Applies to the current
/// clip immediately and to every clip played afterwards.
pub fn set_volume(&self, volume: f32) {
let _ = self.command_tx.send(ClipCommand::SetVolume(volume));
}
}
fn playback_worker(
command_rx: mpsc::Receiver<ClipCommand>,
status: SharedClipStatus,
initial_volume: f32,
) {
let mut output: Option<MixerDeviceSink> = None;
let mut player: Option<Player> = None;
// Universal gain remembered across clips so a level set on one upload
// carries to the next; reapplied to each freshly connected player.
let mut volume = initial_volume.max(0.0);
loop {
match command_rx.recv_timeout(Duration::from_millis(100)) {
Ok(ClipCommand::Play(id, bytes)) => {
// In-memory readers do not expose file metadata to rodio. Pass
// the known attachment length explicitly so formats without a
// duration in their headers (notably MP3 and Vorbis) can derive
// a total duration and support reliable seeking.
let source = match decode_clip(bytes) {
Ok(source) => source,
Err(error) => {
fail(
&status,
id,
format!("unsupported or invalid audio: {error}"),
true,
);
continue;
}
};
let total = source.total_duration();
if output.is_none() {
match DeviceSinkBuilder::open_default_sink() {
Ok(sink) => {
let new_player = Player::connect_new(sink.mixer());
new_player.set_volume(volume);
player = Some(new_player);
output = Some(sink);
}
Err(error) => {
fail(
&status,
id,
format!("audio output unavailable: {error}"),
false,
);
continue;
}
}
}
if let Some(player) = player.as_ref() {
player.clear();
player.append(source);
player.play();
update_status(&status, |s| {
s.playing_id = Some(id);
s.position = Duration::ZERO;
s.total = total;
s.paused = false;
s.failure = None;
});
}
}
Ok(ClipCommand::Pause) => {
if let Some(player) = player.as_ref() {
player.pause();
update_status(&status, |s| s.paused = true);
}
}
Ok(ClipCommand::Resume) => {
if let Some(player) = player.as_ref() {
player.play();
update_status(&status, |s| s.paused = false);
}
}
Ok(ClipCommand::Seek(position)) => {
if let Some(player) = player.as_ref()
&& player.try_seek(position).is_ok()
{
update_status(&status, |s| s.position = position);
}
}
Ok(ClipCommand::Stop) => {
if let Some(player) = player.as_ref() {
player.clear();
}
reset(&status);
}
Ok(ClipCommand::SetVolume(level)) => {
volume = level.max(0.0);
if let Some(player) = player.as_ref() {
player.set_volume(volume);
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if let Some(player) = player.as_ref() {
let (active, failed) = status
.lock()
.map(|s| (s.playing_id.is_some(), s.failure.is_some()))
.unwrap_or_default();
if active && !failed && player.empty() {
reset(&status);
} else if active && !failed {
update_status(&status, |s| {
s.position = player.get_pos();
s.paused = player.is_paused();
});
}
}
}
}
fn decode_clip(bytes: Vec<u8>) -> Result<Decoder<Cursor<Vec<u8>>>, DecoderError> {
let byte_len = bytes.len() as u64;
Decoder::builder()
.with_data(Cursor::new(bytes))
.with_byte_len(byte_len)
.build()
}
fn fail(status: &SharedClipStatus, id: AttachmentId, error: String, invalid_audio: bool) {
crate::log_msg(&format!("Inline audio playback failed: {error}"));
update_status(status, |s| {
// Keep the id active until the GUI observes the failure on its next
// tick. This guarantees the active-only timer cannot disappear in the
// small window between sending Play and decoder/output failure.
s.playing_id = Some(id);
s.position = Duration::ZERO;
s.total = None;
s.paused = false;
s.failure = Some(ClipFailure {
id,
error,
invalid_audio,
});
});
}
fn reset(status: &SharedClipStatus) {
update_status(status, |s| *s = ClipStatus::default());
}
fn update_status(status: &SharedClipStatus, update: impl FnOnce(&mut ClipStatus)) {
if let Ok(mut status) = status.lock() {
update(&mut status);
}
}
pub fn status_snapshot(status: &SharedClipStatus) -> ClipStatus {
status.lock().map(|s| s.clone()).unwrap_or_default()
}
/// Format clip time as `mm:ss` (hours are folded into minutes).
pub fn format_time(duration: Duration) -> String {
let seconds = duration.as_secs();
format!("{}:{:02}", seconds / 60, seconds % 60)
}
/// Playback progress in `0.0..=1.0`; unknown and zero durations report zero.
pub fn progress(position: Duration, total: Option<Duration>) -> f32 {
let Some(total) = total.filter(|duration| !duration.is_zero()) else {
return 0.0;
};
(position.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0) as f32
}
/// Convert a slider fraction into a clamped position within a clip.
pub fn seek_target(fraction: f32, total: Duration) -> Duration {
total.mul_f64(f64::from(fraction.clamp(0.0, 1.0)))
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
#[test]
fn in_memory_mp3_reports_duration() {
// One headerless constant-bitrate MP3 frame repeated to model files
// that do not carry an Xing/VBR duration header.
let frame = base64::engine::general_purpose::STANDARD
.decode("//sQxAAABIQVWVRggDCqCKiDNlAAAAGgS4BgAmTT2AQAABCxOD5d7gQOfqBAEHS4Ph/EAIRI7//0A0KBNpABgMRIDCSI04PcIFdF0PJKFgzlUf5eAoF8BRIPfh4FTvUDQl+dUi5pc0w=")
.expect("valid test fixture");
let bytes = frame.repeat(20);
let decoder = decode_clip(bytes).expect("CBR MP3 should decode");
assert!(decoder.total_duration().is_some());
}
#[test]
fn formats_clip_time() {
assert_eq!(format_time(Duration::ZERO), "0:00");
assert_eq!(format_time(Duration::from_secs(65)), "1:05");
assert_eq!(format_time(Duration::from_secs(3_661)), "61:01");
}
#[test]
fn progress_handles_unknown_zero_and_clamps() {
assert_eq!(progress(Duration::from_secs(1), None), 0.0);
assert_eq!(progress(Duration::from_secs(1), Some(Duration::ZERO)), 0.0);
assert_eq!(
progress(Duration::from_secs(5), Some(Duration::from_secs(10))),
0.5
);
assert_eq!(
progress(Duration::from_secs(20), Some(Duration::from_secs(10))),
1.0
);
}
#[test]
fn seek_target_clamps_fraction() {
let total = Duration::from_secs(100);
assert_eq!(seek_target(0.25, total), Duration::from_secs(25));
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"
);
}
}