Initialize project and implement decentralized voice chat client (PipeWire, Opus, Iroh, Iced)
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
use crate::network::{RoomState, NetError, PeerState, RoomEvent, PeerSpeakTicket};
|
||||
use iroh::{Endpoint, EndpointId};
|
||||
use iroh_gossip::net::Gossip;
|
||||
use iroh_gossip::proto::TopicId;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use async_trait::async_trait;
|
||||
use tokio_stream::StreamExt;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct GossipPayload {
|
||||
pub author: EndpointId,
|
||||
pub msg: GossipMessage,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum GossipMessage {
|
||||
Announce(PeerState),
|
||||
Leave,
|
||||
}
|
||||
|
||||
pub struct IrohGossipState {
|
||||
_endpoint: Endpoint,
|
||||
gossip: Gossip,
|
||||
address_lookup: iroh::address_lookup::memory::MemoryLookup,
|
||||
self_state: Arc<Mutex<Option<PeerState>>>,
|
||||
peers: Arc<Mutex<HashMap<EndpointId, PeerState>>>,
|
||||
event_tx: mpsc::Sender<RoomEvent>,
|
||||
event_rx: Mutex<Option<mpsc::Receiver<RoomEvent>>>,
|
||||
active_topic: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
active_topic_id: Mutex<Option<TopicId>>,
|
||||
active_sender: Mutex<Option<iroh_gossip::api::GossipSender>>,
|
||||
}
|
||||
|
||||
impl IrohGossipState {
|
||||
pub fn new(
|
||||
endpoint: Endpoint,
|
||||
gossip: Gossip,
|
||||
address_lookup: iroh::address_lookup::memory::MemoryLookup,
|
||||
) -> Self {
|
||||
let (event_tx, event_rx) = mpsc::channel(100);
|
||||
Self {
|
||||
_endpoint: endpoint,
|
||||
gossip,
|
||||
address_lookup,
|
||||
self_state: Arc::new(Mutex::new(None)),
|
||||
peers: Arc::new(Mutex::new(HashMap::new())),
|
||||
event_tx,
|
||||
event_rx: Mutex::new(Some(event_rx)),
|
||||
active_topic: Mutex::new(None),
|
||||
active_topic_id: Mutex::new(None),
|
||||
active_sender: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RoomState for IrohGossipState {
|
||||
async fn join(&self, ticket_str: &str, self_state: PeerState) -> Result<(), NetError> {
|
||||
let ticket = ticket_str.parse::<PeerSpeakTicket>()?;
|
||||
let topic_id = TopicId::from_bytes(ticket.topic_id);
|
||||
|
||||
// Stop any currently running topic
|
||||
let _ = self.leave().await;
|
||||
|
||||
// Add the host to the address book
|
||||
self.address_lookup.add_endpoint_info(ticket.host_addr.clone());
|
||||
|
||||
// Join the gossip topic. If we are the host, bootstrap list will be empty
|
||||
// or contain ourselves (which is fine), but let's bootstrap to the ticket host.
|
||||
let bootstrap_peers = if ticket.host_addr.id == self_state.addr.id {
|
||||
vec![]
|
||||
} else {
|
||||
vec![ticket.host_addr.id]
|
||||
};
|
||||
|
||||
let gossip_topic = self.gossip.subscribe(topic_id, bootstrap_peers).await
|
||||
.map_err(|e| NetError::Gossip(format!("Failed to join gossip topic: {}", e)))?;
|
||||
|
||||
let (gossip_sender, mut gossip_receiver) = gossip_topic.split();
|
||||
|
||||
*self.self_state.lock().await = Some(self_state.clone());
|
||||
*self.active_topic_id.lock().await = Some(topic_id);
|
||||
*self.active_sender.lock().await = Some(gossip_sender.clone());
|
||||
|
||||
let event_tx = self.event_tx.clone();
|
||||
let peers = self.peers.clone();
|
||||
let address_lookup = self.address_lookup.clone();
|
||||
let self_state_clone = self.self_state.clone();
|
||||
let gossip_sender_clone = gossip_sender.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
// Broadcast initial state
|
||||
let payload = GossipPayload {
|
||||
author: self_state_clone.lock().await.as_ref().unwrap().addr.id,
|
||||
msg: GossipMessage::Announce(self_state_clone.lock().await.clone().unwrap()),
|
||||
};
|
||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||
let _ = gossip_sender_clone.broadcast(bytes.into()).await;
|
||||
}
|
||||
|
||||
// Stream topic messages
|
||||
while let Some(res) = gossip_receiver.next().await {
|
||||
match res {
|
||||
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
||||
if let Ok(payload) = serde_json::from_slice::<GossipPayload>(&msg.content) {
|
||||
match payload.msg {
|
||||
GossipMessage::Announce(state) => {
|
||||
if payload.author == self_state_clone.lock().await.as_ref().unwrap().addr.id {
|
||||
continue; // Ignore our own announcements
|
||||
}
|
||||
let mut peer_map = peers.lock().await;
|
||||
let is_new = !peer_map.contains_key(&payload.author);
|
||||
let state_changed = peer_map.get(&payload.author) != Some(&state);
|
||||
|
||||
if is_new {
|
||||
address_lookup.add_endpoint_info(state.addr.clone());
|
||||
peer_map.insert(payload.author, state.clone());
|
||||
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
|
||||
} else if state_changed {
|
||||
peer_map.insert(payload.author, state.clone());
|
||||
let _ = event_tx.send(RoomEvent::PeerUpdated(payload.author, state)).await;
|
||||
}
|
||||
}
|
||||
GossipMessage::Leave => {
|
||||
let mut peer_map = peers.lock().await;
|
||||
if peer_map.remove(&payload.author).is_some() {
|
||||
let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(iroh_gossip::api::Event::NeighborUp(_peer_id)) => {
|
||||
// Resend state on new neighbor connection to guarantee synchronization
|
||||
if let Some(state) = self_state_clone.lock().await.as_ref() {
|
||||
let payload = GossipPayload {
|
||||
author: state.addr.id,
|
||||
msg: GossipMessage::Announce(state.clone()),
|
||||
};
|
||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||
let _ = gossip_sender_clone.broadcast(bytes.into()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
*self.active_topic.lock().await = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError> {
|
||||
let mut self_guard = self.self_state.lock().await;
|
||||
*self_guard = Some(self_state.clone());
|
||||
|
||||
if let Some(sender) = self.active_sender.lock().await.as_ref() {
|
||||
let payload = GossipPayload {
|
||||
author: self_state.addr.id,
|
||||
msg: GossipMessage::Announce(self_state),
|
||||
};
|
||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||
sender.broadcast(bytes.into()).await
|
||||
.map_err(|e| NetError::Gossip(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn leave(&self) -> Result<(), NetError> {
|
||||
let mut handle_guard = self.active_topic.lock().await;
|
||||
if let Some(handle) = handle_guard.take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
let mut topic_id_guard = self.active_topic_id.lock().await;
|
||||
let _ = topic_id_guard.take();
|
||||
|
||||
let mut sender_guard = self.active_sender.lock().await;
|
||||
if let Some(sender) = sender_guard.take() {
|
||||
if let Some(self_state) = self.self_state.lock().await.as_ref() {
|
||||
let payload = GossipPayload {
|
||||
author: self_state.addr.id,
|
||||
msg: GossipMessage::Leave,
|
||||
};
|
||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||
let _ = sender.broadcast(bytes.into()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.peers.lock().await.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_peers(&self) -> Vec<(EndpointId, PeerState)> {
|
||||
let guard = self.peers.blocking_lock();
|
||||
guard.iter().map(|(k, v)| (*k, v.clone())).collect()
|
||||
}
|
||||
|
||||
async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError> {
|
||||
let mut rx_guard = self.event_rx.lock().await;
|
||||
if let Some(rx) = rx_guard.take() {
|
||||
Ok(rx)
|
||||
} else {
|
||||
Err(NetError::Other("Events already subscribed".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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::Receiver;
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioProtocol {
|
||||
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
|
||||
connections: Arc<Mutex<HashMap<EndpointId, Connection>>>,
|
||||
}
|
||||
|
||||
impl iroh::protocol::ProtocolHandler for AudioProtocol {
|
||||
fn accept(
|
||||
&self,
|
||||
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();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)>>>,
|
||||
}
|
||||
|
||||
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 audio_proto = AudioProtocol {
|
||||
incoming_tx: incoming_tx.clone(),
|
||||
connections: connections.clone(),
|
||||
};
|
||||
|
||||
let transport = Self {
|
||||
endpoint,
|
||||
connections,
|
||||
incoming_tx,
|
||||
incoming_rx: Mutex::new(Some(incoming_rx)),
|
||||
};
|
||||
|
||||
(transport, audio_proto)
|
||||
}
|
||||
}
|
||||
|
||||
#[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()))?;
|
||||
|
||||
conns.insert(peer_id, conn.clone());
|
||||
|
||||
let incoming_tx_inner = self.incoming_tx.clone();
|
||||
let connections_inner = self.connections.clone();
|
||||
let conn_clone = conn.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match conn_clone.read_datagram().await {
|
||||
Ok(bytes) => {
|
||||
if let Err(_) = incoming_tx_inner.send((peer_id, bytes)).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
connections_inner.lock().await.remove(&peer_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
conn
|
||||
};
|
||||
|
||||
conn.send_datagram(data)
|
||||
.map_err(|e| NetError::Connection(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use iroh::EndpointId;
|
||||
use bytes::Bytes;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum NetError {
|
||||
#[error("Failed to initialize network: {0}")]
|
||||
Init(String),
|
||||
#[error("Failed to connect/dial peer: {0}")]
|
||||
Connection(String),
|
||||
#[error("Gossip swarm error: {0}")]
|
||||
Gossip(String),
|
||||
#[error("Serialization / Deserialization error: {0}")]
|
||||
Serde(String),
|
||||
#[error("Invalid ticket: {0}")]
|
||||
InvalidTicket(String),
|
||||
#[error("Other network error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PeerState {
|
||||
pub name: String,
|
||||
pub is_muted: bool,
|
||||
pub addr: iroh::EndpointAddr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RoomEvent {
|
||||
PeerJoined(EndpointId, PeerState),
|
||||
PeerLeft(EndpointId),
|
||||
PeerUpdated(EndpointId, PeerState),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct PeerSpeakTicket {
|
||||
pub host_addr: iroh::EndpointAddr,
|
||||
pub topic_id: [u8; 32],
|
||||
}
|
||||
|
||||
impl ToString for PeerSpeakTicket {
|
||||
fn to_string(&self) -> String {
|
||||
let serialized = serde_json::to_vec(self).unwrap();
|
||||
// Convert to base64 URL-safe string
|
||||
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &serialized)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for PeerSpeakTicket {
|
||||
type Err = NetError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s)
|
||||
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
||||
let ticket: PeerSpeakTicket = serde_json::from_slice(&decoded)
|
||||
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
||||
Ok(ticket)
|
||||
}
|
||||
}
|
||||
|
||||
#[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>;
|
||||
|
||||
/// Subscribes to incoming datagrams from any peer.
|
||||
async fn receive_datagrams(&self) -> Result<Receiver<(EndpointId, Bytes)>, NetError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RoomState: Send + Sync {
|
||||
/// Joins a room using a gossip ticket string and announces our state.
|
||||
async fn join(&self, ticket: &str, self_state: PeerState) -> Result<(), NetError>;
|
||||
|
||||
/// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it.
|
||||
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>;
|
||||
|
||||
/// Leaves the room and announces departure.
|
||||
async fn leave(&self) -> Result<(), NetError>;
|
||||
|
||||
/// Returns a list of currently active peers in the room.
|
||||
fn active_peers(&self) -> Vec<(EndpointId, PeerState)>;
|
||||
|
||||
/// Subscribes to room events (peer joined, peer left, peer updated).
|
||||
async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>;
|
||||
}
|
||||
|
||||
pub mod iroh_impl;
|
||||
pub mod gossip;
|
||||
Reference in New Issue
Block a user