feat(tap): TapAudioPipe + TapMetrics (spec §4.1)
- TapMetrics: atomic counters (drop + observe per slice-1 §3.8). - TapAudioPipe: AudioSource + AudioSink impl over mpsc + VecDeque ring. Drop-oldest on overflow (lowest-latency-correct); silence on underflow. - TAP_PLAYOUT_FRAMES = 5 (100ms @ 20ms/frame; tunable constant per spec §4.1). - 6 unit tests cover underflow, overflow drops-oldest, sink-full drops, disconnected engine, frame round-trip, empty ring. Spec ref: 2026-06-28-slice-2-agent-tap-design.md §4.1.
This commit is contained in:
@@ -27,18 +27,22 @@
|
||||
//! — that would invert the canonical-home of `PcmFrame` and pull the
|
||||
//! loopback peer into the tap story.
|
||||
|
||||
pub mod metrics;
|
||||
pub mod protocol;
|
||||
pub mod tap_audio_pipe;
|
||||
|
||||
// Re-export PcmFrame so `rutster-tap` consumers (the binary, the echo brain,
|
||||
// future brains) get it from one canonical home (spec §3.1).
|
||||
pub use rutster_media::PcmFrame;
|
||||
|
||||
pub use metrics::{MetricsSnapshot, TapMetrics, TAP_PLAYOUT_FRAMES};
|
||||
pub use protocol::{
|
||||
decode_envelope, decode_pcm, encode_audio_in, encode_audio_out, encode_bye, encode_error,
|
||||
encode_hello, encode_pcm, encode_session_end, AudioPayload, DecodedFrame, DecodedPayload,
|
||||
Envelope, ErrorPayload, FrameKind, HelloPayload, Payload, ReasonPayload, SessionEndPayload,
|
||||
TapProtoError, PROTOCOL_VERSION, SAMPLES_PER_FRAME as WIRE_SAMPLES_PER_FRAME,
|
||||
};
|
||||
pub use tap_audio_pipe::TapAudioPipe;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
62
crates/rutster-tap/src/metrics.rs
Normal file
62
crates/rutster-tap/src/metrics.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
//! # TapMetrics — the "observe" half of "drop + observe" (slice-1 §3.8)
|
||||
//!
|
||||
//! Atomic counters shared between `TapAudioPipe` (hot path — inbound/outbound
|
||||
//! drops, playout overflow/underflow) and `TapClient` (seq gaps, unknown
|
||||
//! frames, malformed frames, reconnect attempts). All ops are
|
||||
//! `fetch_add(_, Ordering::Relaxed)` — we're using the counts for
|
||||
//! observability, not synchronization, so relaxed ordering is correct +
|
||||
//! cheapest. A snapshot read is taken at log time (e.g. once per second or
|
||||
//! on tap-disconnect).
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Bounded playout ring capacity (spec §4.1: 5 frames = 100 ms at 20 ms/frame).
|
||||
/// Tunable *constant* (no runtime config in slice-2; a future-rung concern).
|
||||
pub const TAP_PLAYOUT_FRAMES: usize = 5;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TapMetrics {
|
||||
pub inbound_dropped: AtomicU64,
|
||||
pub outbound_dropped: AtomicU64,
|
||||
pub playout_overflow: AtomicU64,
|
||||
pub playout_underflow: AtomicU64,
|
||||
pub seq_gaps: AtomicU64,
|
||||
pub unknown_frames: AtomicU64,
|
||||
pub malformed_frames: AtomicU64,
|
||||
pub reconnect_attempts: AtomicU64,
|
||||
}
|
||||
|
||||
impl TapMetrics {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
/// Read a consistent-ish snapshot. Not atomic across fields (each is
|
||||
/// independently read), but for log/observability purposes that's fine —
|
||||
/// we're reporting "approximately this many X happened," not synchronizing.
|
||||
pub fn snapshot(&self) -> MetricsSnapshot {
|
||||
MetricsSnapshot {
|
||||
inbound_dropped: self.inbound_dropped.load(Ordering::Relaxed),
|
||||
outbound_dropped: self.outbound_dropped.load(Ordering::Relaxed),
|
||||
playout_overflow: self.playout_overflow.load(Ordering::Relaxed),
|
||||
playout_underflow: self.playout_underflow.load(Ordering::Relaxed),
|
||||
seq_gaps: self.seq_gaps.load(Ordering::Relaxed),
|
||||
unknown_frames: self.unknown_frames.load(Ordering::Relaxed),
|
||||
malformed_frames: self.malformed_frames.load(Ordering::Relaxed),
|
||||
reconnect_attempts: self.reconnect_attempts.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MetricsSnapshot {
|
||||
pub inbound_dropped: u64,
|
||||
pub outbound_dropped: u64,
|
||||
pub playout_overflow: u64,
|
||||
pub playout_underflow: u64,
|
||||
pub seq_gaps: u64,
|
||||
pub unknown_frames: u64,
|
||||
pub malformed_frames: u64,
|
||||
pub reconnect_attempts: u64,
|
||||
}
|
||||
215
crates/rutster-tap/src/tap_audio_pipe.rs
Normal file
215
crates/rutster-tap/src/tap_audio_pipe.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
//! # TapAudioPipe — the seam object (spec §4.1)
|
||||
//!
|
||||
//! The sync object `RtcSession` holds and `loop_driver` calls via the
|
||||
//! `AudioSource`/`AudioSink` trait seam. It's a thin wrapper over two
|
||||
//! `tokio::sync::mpsc` channels + a bounded `VecDeque` playout ring:
|
||||
//!
|
||||
//! ```text
|
||||
//! peer mic → Opus decode → on_pcm_frame() → tx_pcm_in → TapClient → audio_in (WS)
|
||||
//! brain audio_out (WS) → TapClient → rx_audio_out → [playout ring] → next_pcm_frame() → Opus encode → peer
|
||||
//! ```
|
||||
//!
|
||||
//! # Why `VecDeque` for the playout ring (not `mpsc`?)
|
||||
//! The playout ring has a specific policy on overflow (drop *oldest*, not
|
||||
//! newest — spec §4.1). `mpsc` drops the *newest* on `try_send` when
|
||||
//! full (the receiver would see a gap). For a real-time media path, we
|
||||
//! want stale (old) frames dropped, not late (new) frames — drop-oldest
|
||||
//! keeps the buffer at-or-behind real-time. A `VecDeque` under our
|
||||
//! manual `push_back_bounded` policy is the smallest structure that fits.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rutster_media::{AudioSink, AudioSource, PcmFrame};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use crate::metrics::{TapMetrics, TAP_PLAYOUT_FRAMES};
|
||||
|
||||
pub struct TapAudioPipe {
|
||||
// Core → brain (inbound decoded PCM from peer):
|
||||
tx_pcm_in: mpsc::Sender<PcmFrame>,
|
||||
// Brain → core (playout buffer):
|
||||
rx_audio_out: mpsc::Receiver<PcmFrame>,
|
||||
// Bounded ring between rx_audio_out and next_pcm_frame (spec §4.1).
|
||||
playout_ring: VecDeque<PcmFrame>,
|
||||
metrics: Arc<TapMetrics>,
|
||||
}
|
||||
|
||||
impl TapAudioPipe {
|
||||
pub fn new(
|
||||
tx_pcm_in: mpsc::Sender<PcmFrame>,
|
||||
rx_audio_out: mpsc::Receiver<PcmFrame>,
|
||||
metrics: Arc<TapMetrics>,
|
||||
) -> Self {
|
||||
Self {
|
||||
tx_pcm_in,
|
||||
rx_audio_out,
|
||||
playout_ring: VecDeque::with_capacity(TAP_PLAYOUT_FRAMES),
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the inbound mpsc into the playout ring. Called by
|
||||
/// `next_pcm_frame` (one drain per source tick). Returns the count
|
||||
/// drained. **Hot-path policy:** every drop is counted (drop + observe).
|
||||
fn drain_inbound(&mut self) -> usize {
|
||||
let mut drained = 0;
|
||||
loop {
|
||||
match self.rx_audio_out.try_recv() {
|
||||
Ok(frame) => {
|
||||
// push_back_bounded: drop oldest if full (drop-oldest policy).
|
||||
if self.playout_ring.len() >= TAP_PLAYOUT_FRAMES {
|
||||
self.playout_ring.pop_front();
|
||||
self.metrics
|
||||
.playout_overflow
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
debug!(overflow = true, "playout ring overflow; dropped oldest");
|
||||
}
|
||||
self.playout_ring.push_back(frame);
|
||||
drained += 1;
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => break,
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
// Engine task gone. Silence until TapClient reconnects
|
||||
// via a fresh mpsc (handled by TapEngine's reconnect loop).
|
||||
trace!("rx_audio_out disconnected; silence until reconnect");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
drained
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioSource for TapAudioPipe {
|
||||
/// Take the next brain-proposed PCM frame to send to the peer.
|
||||
/// `None` = silence (loop_driver emits Opus silence on None — slice-1).
|
||||
fn next_pcm_frame(&mut self) -> Option<PcmFrame> {
|
||||
// First drain any frames the engine task has queued; then pop one.
|
||||
self.drain_inbound();
|
||||
match self.playout_ring.pop_front() {
|
||||
Some(frame) => Some(frame),
|
||||
None => {
|
||||
// Underflow: brain slower than the 20ms tick. Silence.
|
||||
self.metrics
|
||||
.playout_underflow
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioSink for TapAudioPipe {
|
||||
/// Receive a decoded PCM frame from the peer. Must not block
|
||||
/// (slice-1 §3.3 contract — `on_pcm_frame` runs in the 20ms loop).
|
||||
/// `try_send` (not `send`) — if the channel is full (engine task slow
|
||||
/// or gone), drop + count (hot-path "drop + observe, don't crash").
|
||||
fn on_pcm_frame(&mut self, frame: PcmFrame) {
|
||||
if self.tx_pcm_in.try_send(frame).is_err() {
|
||||
self.metrics.inbound_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
trace!("inbound PCM dropped (channel full)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[allow(clippy::type_complexity)] // test helper: 5-tuple of channel ends; not worth a struct.
|
||||
fn channels() -> (
|
||||
mpsc::Sender<PcmFrame>,
|
||||
mpsc::Receiver<PcmFrame>,
|
||||
mpsc::Sender<PcmFrame>,
|
||||
mpsc::Receiver<PcmFrame>,
|
||||
Arc<TapMetrics>,
|
||||
) {
|
||||
// tx_pcm_in (inbound PCM → engine), rx_audio_out (engine → playout ring)
|
||||
let (tx_pcm_in, rx_pcm_in) = mpsc::channel(64);
|
||||
let (tx_audio_out, rx_audio_out) = mpsc::channel(64);
|
||||
let metrics = TapMetrics::new();
|
||||
(tx_pcm_in, rx_pcm_in, tx_audio_out, rx_audio_out, metrics)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_returns_none_on_empty_ring() {
|
||||
let (_, _, tx_audio_out, rx_audio_out, metrics) = channels();
|
||||
let mut pipe = TapAudioPipe::new(tx_audio_out, rx_audio_out, metrics);
|
||||
assert!(pipe.next_pcm_frame().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_returns_frame_pushed_by_engine() {
|
||||
let (_, _, tx_audio_out, rx_audio_out, metrics) = channels();
|
||||
let mut pipe = TapAudioPipe::new(tx_audio_out.clone(), rx_audio_out, metrics.clone());
|
||||
// Engine task (simulated): push an audio_out frame.
|
||||
let mut frame = PcmFrame::zeroed();
|
||||
frame.samples[0] = 42;
|
||||
tx_audio_out.blocking_send(frame).unwrap();
|
||||
// Source should now drain + return it.
|
||||
let got = pipe.next_pcm_frame().expect("frame present");
|
||||
assert_eq!(got.samples[0], 42);
|
||||
assert_eq!(metrics.playout_underflow.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_drops_oldest_not_newest() {
|
||||
let (_, _, tx_audio_out, rx_audio_out, metrics) = channels();
|
||||
let mut pipe = TapAudioPipe::new(tx_audio_out.clone(), rx_audio_out, metrics.clone());
|
||||
// Push TAP_PLAYOUT_FRAMES + 3 frames. The first 3 should be dropped
|
||||
// (oldest), keeping the last TAP_PLAYOUT_FRAMES.
|
||||
for i in 0..(TAP_PLAYOUT_FRAMES + 3) {
|
||||
let mut f = PcmFrame::zeroed();
|
||||
f.samples[0] = i as i16;
|
||||
tx_audio_out.blocking_send(f).unwrap();
|
||||
}
|
||||
// Drain into the ring via next_pcm_frame's drain step (returns the
|
||||
// first popped, so we call N+1 times to fully drain + assert).
|
||||
let first = pipe.next_pcm_frame().expect("first frame after drain");
|
||||
assert_eq!(first.samples[0], 3); // first 3 (0,1,2) were dropped
|
||||
assert_eq!(metrics.playout_overflow.load(Ordering::Relaxed), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underflow_increments_counter() {
|
||||
let (_, _, tx_audio_out, rx_audio_out, metrics) = channels();
|
||||
let mut pipe = TapAudioPipe::new(tx_audio_out, rx_audio_out, metrics.clone());
|
||||
pipe.next_pcm_frame(); // None -> underflow
|
||||
pipe.next_pcm_frame(); // None -> underflow
|
||||
assert_eq!(metrics.playout_underflow.load(Ordering::Relaxed), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sink_full_drops_and_counts() {
|
||||
// Tiny inbound channel to force overflow.
|
||||
let (tx_pcm_in, _rx_pcm_in, _tx_audio_out, rx_audio_out, metrics) = channels();
|
||||
let mut pipe = TapAudioPipe::new(tx_pcm_in, rx_audio_out, metrics.clone());
|
||||
// Channel capacity 64 — push 100 frames.
|
||||
for _ in 0..100 {
|
||||
pipe.on_pcm_frame(PcmFrame::zeroed());
|
||||
}
|
||||
assert!(metrics.inbound_dropped.load(Ordering::Relaxed) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_engine_returns_none() {
|
||||
let (_, _, tx_audio_out, rx_audio_out, metrics) = channels();
|
||||
let mut pipe = TapAudioPipe::new(tx_audio_out, rx_audio_out, metrics);
|
||||
// Drop the sender → rx_audio_out sees Disconnected → None (silence).
|
||||
// (Implicit drop at scope exit here; explicit next_pcm_frame below
|
||||
// reads Disconnected.)
|
||||
assert!(pipe.next_pcm_frame().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_engine_returns_none_after_close() {
|
||||
let (tx_pcm_in, _rx_pcm_in, tx_audio_out, rx_audio_out, metrics) = channels();
|
||||
let mut pipe = TapAudioPipe::new(tx_pcm_in, rx_audio_out, metrics);
|
||||
drop(tx_audio_out); // close the engine→playout-ring direction
|
||||
// Now next_pcm_frame should return None (silence) — Disconnected path.
|
||||
assert!(pipe.next_pcm_frame().is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user