feat: redesign audio networking for real-network resilience
Replaces the fire-and-forget datagram path with sequenced packets, a per-peer jitter buffer, and persistent per-peer send tasks. Together these fix three intertwined weaknesses that only showed up off localhost. Packet format: every audio frame now carries a 4-byte little-endian sequence number header ([seq][opus payload]), the basis for reordering and loss detection. Jitter buffer (core/jitter.rs): incoming packets are reordered by sequence behind a fixed ~60ms playout delay. Missing sequences with later packets already buffered are concealed via Opus PLC (decode(None)) -- a path the decoder supported but nothing ever invoked. Underruns go idle and re-buffer rather than concealing indefinitely. Covered by unit tests using real encoded frames (reorder, gap-conceal, prime, late-drop). Transport (network/iroh_impl.rs): each peer gets one long-lived send task fed by a shallow bounded channel (drop-oldest on backpressure), instead of spawning a throwaway task per peer per 20ms frame. Connections are now established reactively on peer-join and torn down on peer-leave; the lexicographically-lower EndpointId dials so a full-mesh pair forms exactly one shared bidirectional connection instead of two racing ones. This also removes the previous lock-held-across-connect().await serialization. Opus decoder: PLC output is now sized to one 20ms frame, so concealment synthesizes 20ms instead of a 120ms burst from the oversized max buffer. Known follow-up (Tier 2): no reconnect on transient connection loss; a send error currently retires the peer until they rejoin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+133
-75
@@ -2,16 +2,98 @@ use crate::network::{NetworkTransport, NetError};
|
||||
use iroh::{Endpoint, EndpointId};
|
||||
use iroh::endpoint::Connection;
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::collections::HashMap;
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioProtocol {
|
||||
const AUDIO_ALPN: &[u8] = b"peerspeak-audio";
|
||||
|
||||
/// Per-peer datagram send queue depth. Audio is real-time, so a backlog is
|
||||
/// useless latency — keep it shallow and drop the oldest frame when full.
|
||||
const SEND_QUEUE_DEPTH: usize = 8;
|
||||
|
||||
/// State shared between the transport and its protocol handler so both inbound
|
||||
/// (accepted) and outbound (dialed) connections register the same way.
|
||||
struct Shared {
|
||||
/// Sync-lockable send handles, so `broadcast` can fan out from the (non-async)
|
||||
/// capture/encode thread without touching the Tokio runtime.
|
||||
senders: StdMutex<HashMap<EndpointId, mpsc::Sender<Bytes>>>,
|
||||
/// Per-peer task handles + a retained connection clone. Keeping the clone
|
||||
/// alive is what stops iroh from closing an accepted connection once the
|
||||
/// `accept()` future returns.
|
||||
peers: tokio::sync::Mutex<HashMap<EndpointId, PeerTasks>>,
|
||||
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
|
||||
connections: Arc<Mutex<HashMap<EndpointId, Connection>>>,
|
||||
}
|
||||
|
||||
struct PeerTasks {
|
||||
send_task: tokio::task::JoinHandle<()>,
|
||||
read_task: tokio::task::JoinHandle<()>,
|
||||
_conn: Connection,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
/// Register a live connection: spin up its send loop (datagrams out) and
|
||||
/// read loop (datagrams in). Idempotent — a second registration for an
|
||||
/// already-known peer is ignored so we never run duplicate loops.
|
||||
async fn register(self: &Arc<Self>, peer_id: EndpointId, conn: Connection) {
|
||||
{
|
||||
let peers = self.peers.lock().await;
|
||||
if peers.contains_key(&peer_id) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let (send_tx, mut send_rx) = mpsc::channel::<Bytes>(SEND_QUEUE_DEPTH);
|
||||
|
||||
let conn_send = conn.clone();
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(data) = send_rx.recv().await {
|
||||
if conn_send.send_datagram(data).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let conn_read = conn.clone();
|
||||
let incoming_tx = self.incoming_tx.clone();
|
||||
let read_task = tokio::spawn(async move {
|
||||
while let Ok(bytes) = conn_read.read_datagram().await {
|
||||
if incoming_tx.send((peer_id, bytes)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
self.senders.lock().unwrap().insert(peer_id, send_tx);
|
||||
self.peers.lock().await.insert(
|
||||
peer_id,
|
||||
PeerTasks { send_task, read_task, _conn: conn },
|
||||
);
|
||||
crate::log_msg(&format!("Transport: registered peer {:?}", peer_id));
|
||||
}
|
||||
|
||||
async fn remove(&self, peer_id: EndpointId) {
|
||||
self.senders.lock().unwrap().remove(&peer_id);
|
||||
if let Some(tasks) = self.peers.lock().await.remove(&peer_id) {
|
||||
tasks.send_task.abort();
|
||||
tasks.read_task.abort();
|
||||
// Dropping `_conn` (the last retained clone) closes the connection.
|
||||
crate::log_msg(&format!("Transport: removed peer {:?}", peer_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AudioProtocol {
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AudioProtocol {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AudioProtocol").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl iroh::protocol::ProtocolHandler for AudioProtocol {
|
||||
@@ -20,24 +102,11 @@ impl iroh::protocol::ProtocolHandler for AudioProtocol {
|
||||
connection: Connection,
|
||||
) -> impl std::future::Future<Output = Result<(), iroh::protocol::AcceptError>> + Send {
|
||||
let peer_id = connection.remote_id();
|
||||
let incoming_tx = self.incoming_tx.clone();
|
||||
let connections = self.connections.clone();
|
||||
|
||||
let shared = self.shared.clone();
|
||||
async move {
|
||||
connections.lock().await.insert(peer_id, connection.clone());
|
||||
loop {
|
||||
match connection.read_datagram().await {
|
||||
Ok(bytes) => {
|
||||
if incoming_tx.send((peer_id, bytes)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
connections.lock().await.remove(&peer_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Register and return: the retained connection clone in `PeerTasks`
|
||||
// keeps the connection open after this future resolves.
|
||||
shared.register(peer_id, connection).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -45,81 +114,70 @@ impl iroh::protocol::ProtocolHandler for AudioProtocol {
|
||||
|
||||
pub struct IrohTransport {
|
||||
endpoint: Endpoint,
|
||||
connections: Arc<Mutex<HashMap<EndpointId, Connection>>>,
|
||||
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
|
||||
incoming_rx: Mutex<Option<mpsc::Receiver<(EndpointId, Bytes)>>>,
|
||||
self_id: EndpointId,
|
||||
shared: Arc<Shared>,
|
||||
incoming_rx: tokio::sync::Mutex<Option<mpsc::Receiver<(EndpointId, Bytes)>>>,
|
||||
}
|
||||
|
||||
impl IrohTransport {
|
||||
pub fn new(endpoint: Endpoint) -> (Self, AudioProtocol) {
|
||||
let (incoming_tx, incoming_rx) = mpsc::channel(1000);
|
||||
let connections = Arc::new(Mutex::new(HashMap::new()));
|
||||
let self_id = endpoint.id();
|
||||
|
||||
let audio_proto = AudioProtocol {
|
||||
incoming_tx: incoming_tx.clone(),
|
||||
connections: connections.clone(),
|
||||
};
|
||||
let shared = Arc::new(Shared {
|
||||
senders: StdMutex::new(HashMap::new()),
|
||||
peers: tokio::sync::Mutex::new(HashMap::new()),
|
||||
incoming_tx,
|
||||
});
|
||||
|
||||
let protocol = AudioProtocol { shared: shared.clone() };
|
||||
let transport = Self {
|
||||
endpoint,
|
||||
connections,
|
||||
incoming_tx,
|
||||
incoming_rx: Mutex::new(Some(incoming_rx)),
|
||||
self_id,
|
||||
shared,
|
||||
incoming_rx: tokio::sync::Mutex::new(Some(incoming_rx)),
|
||||
};
|
||||
|
||||
(transport, audio_proto)
|
||||
(transport, protocol)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NetworkTransport for IrohTransport {
|
||||
async fn send_datagram(&self, peer_id: EndpointId, data: Bytes) -> Result<(), NetError> {
|
||||
let mut conns = self.connections.lock().await;
|
||||
let conn = if let Some(conn) = conns.get(&peer_id) {
|
||||
conn.clone()
|
||||
} else {
|
||||
// Establish a new connection.
|
||||
// We use the same audio ALPN: b"peerspeak-audio"
|
||||
let alpn = b"peerspeak-audio";
|
||||
let conn = self.endpoint.connect(peer_id, alpn).await
|
||||
.map_err(|e| NetError::Connection(e.to_string()))?;
|
||||
async fn connect_peer(&self, peer_id: EndpointId) {
|
||||
// Deterministic initiator: only the lexicographically-lower id dials, so
|
||||
// a full-mesh pair forms exactly one shared connection instead of two
|
||||
// racing ones. The higher id waits for the inbound `accept()`.
|
||||
if self.self_id.to_string() >= peer_id.to_string() {
|
||||
return;
|
||||
}
|
||||
if self.shared.peers.lock().await.contains_key(&peer_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
conns.insert(peer_id, conn.clone());
|
||||
match self.endpoint.connect(peer_id, AUDIO_ALPN).await {
|
||||
Ok(conn) => self.shared.register(peer_id, conn).await,
|
||||
Err(e) => crate::log_msg(&format!("Transport: dial to {:?} failed: {:?}", peer_id, e)),
|
||||
}
|
||||
}
|
||||
|
||||
let incoming_tx_inner = self.incoming_tx.clone();
|
||||
let connections_inner = self.connections.clone();
|
||||
let conn_clone = conn.clone();
|
||||
async fn disconnect_peer(&self, peer_id: EndpointId) {
|
||||
self.shared.remove(peer_id).await;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match conn_clone.read_datagram().await {
|
||||
Ok(bytes) => {
|
||||
if incoming_tx_inner.send((peer_id, bytes)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
connections_inner.lock().await.remove(&peer_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
conn
|
||||
};
|
||||
|
||||
conn.send_datagram(data)
|
||||
.map_err(|e| NetError::Connection(e.to_string()))?;
|
||||
Ok(())
|
||||
fn broadcast(&self, data: Bytes) {
|
||||
let senders = self.shared.senders.lock().unwrap();
|
||||
for tx in senders.values() {
|
||||
// Drop on a full queue: stale audio is worthless, and we must never
|
||||
// block the encode thread on a slow peer.
|
||||
let _ = tx.try_send(data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive_datagrams(&self) -> Result<Receiver<(EndpointId, Bytes)>, NetError> {
|
||||
let mut rx_guard = self.incoming_rx.lock().await;
|
||||
if let Some(rx) = rx_guard.take() {
|
||||
Ok(rx)
|
||||
} else {
|
||||
Err(NetError::Other("Datagram receiver already subscribed".to_string()))
|
||||
}
|
||||
rx_guard
|
||||
.take()
|
||||
.ok_or_else(|| NetError::Other("Datagram receiver already subscribed".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -69,8 +69,17 @@ impl FromStr for PeerSpeakTicket {
|
||||
|
||||
#[async_trait]
|
||||
pub trait NetworkTransport: Send + Sync {
|
||||
/// Send a low-latency unreliable datagram to a specific peer (for audio).
|
||||
async fn send_datagram(&self, peer_id: EndpointId, data: Bytes) -> Result<(), NetError>;
|
||||
/// Establish (or ensure) a connection to a peer and set up its send path.
|
||||
/// Idempotent; safe to call again for an already-connected peer.
|
||||
async fn connect_peer(&self, peer_id: EndpointId);
|
||||
|
||||
/// Tear down the connection and send path for a peer that has left.
|
||||
async fn disconnect_peer(&self, peer_id: EndpointId);
|
||||
|
||||
/// Fan a single audio datagram out to every connected peer. Non-blocking:
|
||||
/// per-peer queues drop the oldest-pending frame when full, so a slow link
|
||||
/// can never stall the capture/encode thread. Callable from any thread.
|
||||
fn broadcast(&self, data: Bytes);
|
||||
|
||||
/// Subscribes to incoming datagrams from any peer.
|
||||
async fn receive_datagrams(&self) -> Result<Receiver<(EndpointId, Bytes)>, NetError>;
|
||||
|
||||
Reference in New Issue
Block a user