383 lines
14 KiB
Rust
383 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"]
|
|
#[cfg(not(windows))]
|
|
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"
|
|
);
|
|
}
|
|
}
|