feat: integrate chat voice and diagnostics client

This commit is contained in:
Edison Jwa
2026-05-23 06:51:55 +09:00
parent 7e28791ec2
commit 7d5d8c2c90
93 changed files with 10273 additions and 3347 deletions
+340 -93
View File
@@ -60,13 +60,55 @@ pub use chanora_audio::{
};
pub use chanora_audio::{PttBinding, PttInputClass};
pub use chanora_diagnostics::{
DiagnosticExport, InMemoryLogSink, KnownSecretRegistry, RedactingLogLayer, Redactor,
DiagnosticExport, InMemoryLogSink, KnownSecretRegistry, ProtocolEventRecorder,
RedactingLogLayer, Redactor, DEFAULT_LOG_CAPACITY,
};
pub use chanora_protocol::{
ChannelInfo, ClientInfo, ConnectConfig, DisconnectReason, ProtocolError, ServerSnapshot,
ChannelInfo, ChatMessage, ClientInfo, ConnectConfig, DisconnectReason, MessageTarget,
ProtocolError, ServerSnapshot,
};
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
/// Privacy-safe snapshot of the active PTT capability.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PttDescriptorSnapshot {
/// Stable capability level name.
pub level: String,
/// Stable backend identifier.
pub backend_id: String,
/// Coarse bound input class; empty when no binding is active.
pub bound_input_class: String,
}
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
fn from(desc: PttBackendDescriptor) -> Self {
Self {
level: desc.level.as_str().to_string(),
backend_id: desc.backend_id.to_string(),
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
}
}
}
/// Persisted PTT binding state exposed to callers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedPttBinding {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
pub input_class: String,
/// Display-only key label; empty when no binding is active.
pub key_label: String,
}
impl PersistedPttBinding {
fn empty() -> Self {
Self {
input_class: String::new(),
key_label: String::new(),
}
}
}
/// Errors that can arise during top-level orchestration.
#[derive(Debug, Error)]
pub enum CoreError {
@@ -202,6 +244,22 @@ pub enum SessionEvent {
/// Platform-provided resume hint. For begin events this is false.
should_resume: bool,
},
/// A text message was received from the server.
ChatMessage {
/// Client id of the sender.
sender_id: u64,
/// Nickname of the sender.
sender_name: String,
/// Message content.
message: String,
/// Target scope (server/channel/private/poke).
target: MessageTarget,
},
/// Audio route changed (speaker/earpiece/BT/wired headset).
AudioRouteChanged {
/// New audio route.
route: AudioRoute,
},
}
/// Bridge-safe mirror of channel-join projection sync state.
@@ -261,6 +319,43 @@ pub enum NetworkState {
/// fall behind we'd rather skip than block the supervisor.
const EVENT_CHANNEL_CAPACITY: usize = 64;
/// Network diagnostics snapshot collected across connection lifetimes.
#[derive(Debug, Clone, Default)]
struct NetworkDiagnostics {
/// Total count of connects (including the initial one).
connect_count: u64,
/// Count of disconnects (graceful + loss).
disconnect_count: u64,
/// Recent loss reasons (last 8, ring buffer).
loss_reasons: Vec<String>,
}
impl NetworkDiagnostics {
fn record_connect(&mut self) {
self.connect_count = self.connect_count.saturating_add(1);
}
fn record_loss(&mut self, reason: &str) {
self.disconnect_count = self.disconnect_count.saturating_add(1);
if self.loss_reasons.len() >= 8 {
self.loss_reasons.remove(0);
}
self.loss_reasons.push(reason.to_string());
}
fn summary(&self) -> String {
let mut s = format!(
"connects: {}\ndisconnects: {}\n",
self.connect_count, self.disconnect_count
);
if !self.loss_reasons.is_empty() {
s.push_str(&format!(
"loss_reasons: [{}]\n",
self.loss_reasons.join(", ")
));
}
s
}
}
struct SupervisorInner {
/// Optional cached AudioEngineConfig — set when start_audio is
/// first called, used to re-create the engine after a reconnect.
@@ -287,10 +382,6 @@ struct ConnectedState {
/// Supervisor task handle. Awaited on disconnect for clean
/// teardown.
supervisor: Option<JoinHandle<()>>,
/// Connection config used to dial this connection; retained for
/// future diagnostics. The supervisor task holds its own clone.
#[allow(dead_code)]
cfg: ConnectConfig,
/// Audio supervision state. Wrapped in Arc<Mutex<_>> so the
/// supervisor and the public API both see updates.
sup_inner: Arc<Mutex<SupervisorInner>>,
@@ -345,13 +436,6 @@ pub struct ChanoraSession {
/// Release-tail timer (SDD-096). Drives the selector's
/// `ptt_held` input from PTT key edges.
release_tail: Arc<ReleaseTailTimer>,
/// Missed-key-up watchdog (SAD-079 / DEC-028). Subscribes to
/// `voice_selector.subscribe_ptt_held()` so it only fires when
/// an actual PTT key has been "stuck" for the configured
/// timeout (default 30 s). Lives on the session because it
/// must outlive engine restarts. Spawned lazily on the first
/// `start_audio` because it needs a tokio runtime context.
ptt_watchdog: Arc<Mutex<Option<chanora_audio::MissedKeyUpWatchdog>>>,
/// Last PTT binding the user requested via `set_ptt_binding`.
/// Kept here so it survives the gap between user-saving a
/// binding (which may happen before any audio is running) and
@@ -361,6 +445,16 @@ pub struct ChanoraSession {
/// the binding survives app restarts.
pending_binding: Arc<Mutex<Option<PttBinding>>>,
next_connection_epoch: Arc<Mutex<u64>>,
/// SRS-100: Network diagnostics snapshot across connection
/// lifetimes (reconnect counts, loss reasons).
network_diag: Arc<Mutex<NetworkDiagnostics>>,
/// SRS-097/098: Protocol event recorder for diagnostic export
/// and state-sync replay verification.
event_recorder: Arc<Mutex<chanora_diagnostics::ProtocolEventRecorder>>,
/// SRS-026: Preferred input device name.
preferred_input_device: Arc<Mutex<Option<String>>>,
/// SRS-026: Preferred output device name.
preferred_output_device: Arc<Mutex<Option<String>>>,
}
impl ChanoraSession {
@@ -387,8 +481,13 @@ impl ChanoraSession {
voice_selector: selector,
release_tail,
pending_binding: Arc::new(Mutex::new(None)),
ptt_watchdog: Arc::new(Mutex::new(None)),
next_connection_epoch: Arc::new(Mutex::new(1)),
network_diag: Arc::new(Mutex::new(NetworkDiagnostics::default())),
event_recorder: Arc::new(Mutex::new(chanora_diagnostics::ProtocolEventRecorder::new(
256,
))),
preferred_input_device: Arc::new(Mutex::new(None)),
preferred_output_device: Arc::new(Mutex::new(None)),
}
}
@@ -440,16 +539,16 @@ impl ChanoraSession {
// exist yet (no audio engine running), so we stash the
// binding in `pending_binding`; it gets applied when the
// controller arms inside `start_audio`.
let (input_class_s, platform_key, _key_label) = store.get_ptt_binding();
if !input_class_s.is_empty() || !platform_key.is_empty() {
let input_class = match input_class_s.as_str() {
let stored_binding = store.get_ptt_binding();
if !stored_binding.input_class.is_empty() || !stored_binding.platform_key.is_empty() {
let input_class = match stored_binding.input_class.as_str() {
"keyboard" => PttInputClass::Keyboard,
"mouse-side-button" => PttInputClass::MouseSideButton,
_ => PttInputClass::None,
};
let binding = PttBinding {
input_class,
platform_key,
platform_key: stored_binding.platform_key,
};
*self.pending_binding.lock().await = Some(binding);
}
@@ -589,32 +688,94 @@ impl ChanoraSession {
audio_running: false,
}));
let supervisor = tokio::spawn(supervisor_loop(
self.inner.clone(),
self.events_tx.clone(),
cfg.clone(),
lost_rx,
probe,
let supervisor = tokio::spawn(supervisor_loop(SupervisorContext {
state_arc: self.inner.clone(),
events_tx: self.events_tx.clone(),
initial_cfg: cfg.clone(),
initial_lost_rx: lost_rx,
initial_probe: probe,
cancel_rx,
sup_inner.clone(),
self.network_tx.subscribe(),
self.voice_selector.clone(),
self.pending_binding.clone(),
self.release_tail.clone(),
self.next_connection_epoch.clone(),
));
sup_inner: sup_inner.clone(),
network_rx: self.network_tx.subscribe(),
network_diag: self.network_diag.clone(),
event_recorder: self.event_recorder.clone(),
voice_selector: self.voice_selector.clone(),
pending_binding: self.pending_binding.clone(),
release_tail: self.release_tail.clone(),
next_connection_epoch: self.next_connection_epoch.clone(),
}));
let _ = self.events_tx.send(SessionEvent::Connected {
server_name: snap.server_name.clone(),
});
// Record connect (SRS-100).
{
let mut diag = self.network_diag.lock().await;
diag.record_connect();
}
// Record protocol event (SRS-097).
{
let mut rec = self.event_recorder.lock().await;
rec.record_connected(&snap.server_name);
}
// Auto-save as a recent server (SRS-085). If a bookmark with
// the same host+port already exists, update it; otherwise
// create a new one with a default display name.
{
let bm_guard = self.bookmark_store.lock().await;
if let Some(repo) = bm_guard.as_ref() {
let host = cfg.address.clone();
let display = snap.server_name.clone();
let nickname = cfg.nickname.clone();
let password = cfg.password.clone();
let bm = Bookmark {
id: 0,
display_name: display,
host,
nickname,
password,
};
if let Err(e) = repo.upsert_or_add(&bm) {
warn!(target: "chanora_core", error = %e, "could not auto-save recent server");
}
}
}
// Forward inbound chat messages from the protocol adapter
// to the event broadcast stream. Chat is infrequent (~human
// typing rate), so a simple loop with try_recv + yield is fine.
if let Some(chat_rx) = client.take_chat_rx() {
let ev_tx = self.events_tx.clone();
tokio::spawn(async move {
use tokio::time::{sleep, Duration};
let mut rx = chat_rx;
loop {
match rx.try_recv() {
Ok(msg) => {
let _ = ev_tx.send(SessionEvent::ChatMessage {
sender_id: msg.sender_id.0,
sender_name: msg.sender_name,
message: msg.message,
target: msg.target,
});
}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
sleep(Duration::from_millis(200)).await;
}
}
}
});
}
*guard = Some(ConnectedState {
protocol: client,
audio: None,
ptt_controller: None,
cancel_tx: Some(cancel_tx),
supervisor: Some(supervisor),
cfg,
sup_inner,
join_state,
channel_passwords: HashMap::new(),
@@ -701,11 +862,56 @@ impl ChanoraSession {
self.inner.lock().await.is_some()
}
/// Send a text message to the specified target.
pub async fn send_text_message(
&self,
message: String,
target: MessageTarget,
) -> Result<(), CoreError> {
if message.trim().is_empty() {
return Ok(());
}
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
state.protocol.send_text_message(message, target).await?;
Ok(())
}
/// Get a network diagnostics summary (SRS-100).
pub async fn network_diagnostics_summary(&self) -> String {
let diag = self.network_diag.lock().await;
diag.summary()
}
/// Drain and return recorded protocol events (SRS-097).
pub async fn drain_protocol_events(&self) -> Vec<String> {
let mut rec = self.event_recorder.lock().await;
rec.drain()
}
/// Record a platform lifecycle event (SRS-138).
pub async fn record_lifecycle_event(&self, state: &str) {
let mut rec = self.event_recorder.lock().await;
rec.record_lifecycle(state);
}
/// Start the audio engine attached to the current connection.
/// Fails if not connected. Idempotent — calling twice replaces
/// the engine. Stores the config so the supervisor can restart
/// audio after a reconnect.
pub async fn start_audio(&self, mut cfg: AudioEngineConfig) -> Result<(), CoreError> {
// Apply stored device preferences (SRS-026).
{
let dev_in = self.preferred_input_device.lock().await;
let dev_out = self.preferred_output_device.lock().await;
if cfg.input_device_name.is_none() {
cfg.input_device_name = dev_in.clone();
}
if cfg.output_device_name.is_none() {
cfg.output_device_name = dev_out.clone();
}
}
let mut guard = self.inner.lock().await;
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
@@ -782,32 +988,6 @@ impl ChanoraSession {
let controller = ptt::PttController::new(self.release_tail.clone());
state.ptt_controller = Some(controller.clone());
// SAD-079 / DEC-028 missed-key-up watchdog: DISABLED for
// P0 per owner decision (2026-05-16). The original 30 s
// ceiling caused real users to be cut off mid-sentence in
// PTT mode whenever they spoke for longer than the
// timeout. The watchdog's purpose (catching OS-level
// key-up loss when the app loses focus / is minimised /
// hits App Nap) is real, but the fixed-timeout
// implementation is the wrong shape.
//
// P1 redesign options under consideration:
// * Raise ceiling to ~5 min (owner-tunable, per DEC-028)
// * Add Windows GetAsyncKeyState / macOS
// CGEventSourceKeyState / X11 XQueryKeymap polling so
// we detect the actual OS desync directly instead of
// timing out on legitimate long speech
// * Combine with an RMS-silence check once the audio
// level meter (P1) lands, so the watchdog only fires
// when the user has been "transmitting" silence for
// the entire window
//
// Until P1 picks one of those, we ship without the
// watchdog. The existing MissedKeyUpWatchdog code path
// and tests remain in place so the P1 work can re-enable
// it with the chosen detection strategy.
let _ = &self.ptt_watchdog;
// Apply any binding the user saved before audio was running
// (SDD-094 follow-up). Persistence + caching happen in
// `set_ptt_binding`; here we forward the cached value to
@@ -879,12 +1059,6 @@ impl ChanoraSession {
Ok(())
}
/// Update the active PTT binding (gen2 v0.9.3 / DEC-026). The
/// binding flows into the platform backend's `rebind` hook via
/// the [`ptt::PttController`] (SDD-088) and the freshly-published
/// `PttBackendDescriptor` is broadcast as
/// `SessionEvent::PttCapability` so the UI badge updates
/// immediately. The audio engine must be running.
/// Update the active PTT binding (gen2 v0.9.3 / DEC-026).
///
/// This call must succeed even when the audio engine is not
@@ -930,30 +1104,24 @@ impl ChanoraSession {
Ok(())
}
/// Returns the active PTT capability descriptor as a triple
/// `(level, backend_id, bound_input_class)`. Useful for the
/// initial UI render before the first `PttCapability` event
/// arrives. Returns the universal Focused descriptor when no
/// audio engine is running.
pub async fn ptt_descriptor(&self) -> (String, String, String) {
/// Returns the active PTT capability descriptor. Useful for the
/// initial UI render before the first `PttCapability` event arrives.
/// Returns the universal Focused descriptor when no audio engine is
/// running.
pub async fn ptt_descriptor(&self) -> PttDescriptorSnapshot {
let guard = self.inner.lock().await;
let desc = match guard.as_ref().and_then(|s| s.ptt_controller.as_ref()) {
Some(controller) => controller.descriptor().await,
None => PttBackendDescriptor::focused(),
};
(
desc.level.as_str().to_string(),
desc.backend_id.to_string(),
desc.bound_input_class.unwrap_or("").to_string(),
)
desc.into()
}
/// Read the persisted PTT binding as `(input_class, key_label)`.
/// Used by the Flutter side at launch so the badge and the
/// mode line can show the user's saved hotkey before any audio
/// engine has spun up. Empty strings indicate no binding has
/// been saved yet.
pub async fn get_ptt_binding(&self) -> (String, String) {
/// Read the persisted PTT binding. Used by the Flutter side at launch
/// so the badge and the mode line can show the user's saved hotkey
/// before any audio engine has spun up. Empty strings indicate no
/// binding has been saved yet.
pub async fn get_ptt_binding(&self) -> PersistedPttBinding {
// 1) Prefer the in-memory pending binding (most-recent
// save, possibly not yet flushed to disk on a slow FS).
if let Some(b) = self.pending_binding.lock().await.clone() {
@@ -962,15 +1130,21 @@ impl ChanoraSession {
PttInputClass::Keyboard => "keyboard",
PttInputClass::MouseSideButton => "mouse-side-button",
};
return (class_s.to_string(), b.platform_key);
return PersistedPttBinding {
input_class: class_s.to_string(),
key_label: b.platform_key,
};
}
// 2) Fall back to the persisted file (case: app just
// started, init_storage already ran).
if let Some(store) = self.identity_store.lock().await.as_ref() {
let (class_s, _platform_key, key_label) = store.get_ptt_binding();
return (class_s, key_label);
let stored_binding = store.get_ptt_binding();
return PersistedPttBinding {
input_class: stored_binding.input_class,
key_label: stored_binding.key_label,
};
}
(String::new(), String::new())
PersistedPttBinding::empty()
}
/// Move our own client to `channel_id`. Optional channel
@@ -1040,12 +1214,27 @@ impl ChanoraSession {
Ok(())
}
/// Read audio engine statistics: (frames_sent, frames_received, ptt_active).
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. No-op if audio is not started or client has no active
/// voice queue.
pub async fn set_client_volume(&self, client_id: u64, volume: f32) -> Result<(), CoreError> {
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
audio.set_client_volume(client_id, volume);
Ok(())
}
/// Read audio engine statistics: (frames_sent, frames_received, transmit_active).
pub async fn audio_stats(&self) -> Result<(u32, u32, bool), CoreError> {
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
Ok((audio.frames_sent(), audio.frames_received(), audio.ptt()))
Ok((
audio.frames_sent(),
audio.frames_received(),
audio.transmit_active(),
))
}
/// Apply the Rust-owned P1 audio-processing configuration.
@@ -1103,7 +1292,8 @@ impl ChanoraSession {
Ok(())
}
/// iOS route-change hook (SDD-100). No-op when audio is not running.
/// Route-change hook (SDD-100/SRS-112). Updates audio processing
/// config and notifies Flutter of the new route.
pub async fn ios_handle_route_change(&self, route: AudioRoute) -> Result<(), CoreError> {
let guard = self.inner.lock().await;
if let Some(state) = guard.as_ref() {
@@ -1113,6 +1303,9 @@ impl ChanoraSession {
audio.set_audio_processing_config(config)?;
}
}
let _ = self
.events_tx
.send(SessionEvent::AudioRouteChanged { route });
Ok(())
}
@@ -1468,6 +1661,19 @@ impl ChanoraSession {
self.voice_selector.hard_mute()
}
/// Set the preferred input device name (SRS-026). Takes effect
/// on the next audio engine start.
pub async fn set_input_device(&self, name: Option<String>) -> Result<(), CoreError> {
*self.preferred_input_device.lock().await = name;
Ok(())
}
/// Set the preferred output device name (SRS-026).
pub async fn set_output_device(&self, name: Option<String>) -> Result<(), CoreError> {
*self.preferred_output_device.lock().await = name;
Ok(())
}
/// Update the release-tail (SDD-096). Clamped to `0..=500` ms
/// inclusive. Persists best-effort and re-emits voice state.
pub async fn set_release_tail_ms(&self, ms: u32) -> Result<(), CoreError> {
@@ -1588,21 +1794,40 @@ const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// declares the connection lost.
const WATCHDOG_MAX_MISSES: u32 = 3;
#[allow(clippy::too_many_arguments)]
async fn supervisor_loop(
struct SupervisorContext {
state_arc: Arc<Mutex<Option<ConnectedState>>>,
events_tx: broadcast::Sender<SessionEvent>,
initial_cfg: ConnectConfig,
initial_lost_rx: oneshot::Receiver<chanora_protocol::DisconnectReason>,
initial_probe: chanora_protocol::SnapshotProbe,
mut cancel_rx: oneshot::Receiver<()>,
cancel_rx: oneshot::Receiver<()>,
sup_inner: Arc<Mutex<SupervisorInner>>,
mut network_rx: watch::Receiver<NetworkState>,
network_rx: watch::Receiver<NetworkState>,
network_diag: Arc<Mutex<NetworkDiagnostics>>,
event_recorder: Arc<Mutex<chanora_diagnostics::ProtocolEventRecorder>>,
voice_selector: Arc<TransmitModeSelector>,
pending_binding: Arc<Mutex<Option<PttBinding>>>,
release_tail: Arc<ReleaseTailTimer>,
next_connection_epoch: Arc<Mutex<u64>>,
) {
}
async fn supervisor_loop(ctx: SupervisorContext) {
let SupervisorContext {
state_arc,
events_tx,
initial_cfg,
initial_lost_rx,
initial_probe,
mut cancel_rx,
sup_inner,
mut network_rx,
network_diag,
event_recorder,
voice_selector,
pending_binding,
release_tail,
next_connection_epoch,
} = ctx;
let mut lost_rx = initial_lost_rx;
let mut probe = initial_probe;
let mut cfg = initial_cfg;
@@ -1700,6 +1925,11 @@ async fn supervisor_loop(
channels: snap.channels.len() as u32,
clients: snap.clients.len() as u32,
});
// Record protocol event (SRS-097).
event_recorder.lock().await.record_snapshot_changed(
snap.channels.len(),
snap.clients.len(),
);
}
}
Ok(Err(e)) => {
@@ -1747,6 +1977,12 @@ async fn supervisor_loop(
reason: reason_str.clone(),
});
// Record network diagnostic (SRS-100).
{
let mut diag = network_diag.lock().await;
diag.record_loss(&reason_str);
}
// Stop the audio engine before reconnect — its
// voice_out_tx points at the dead protocol client.
// Also drop the dead protocol client itself so
@@ -1778,6 +2014,11 @@ async fn supervisor_loop(
attempt,
delay_secs,
});
// Record protocol event (SRS-097).
event_recorder
.lock()
.await
.record_reconnecting(attempt as u64, delay_secs as u64);
info!(
target: "chanora_core",
attempt,
@@ -2114,6 +2355,7 @@ mod tests {
name: "a".into(),
order: 0,
has_password: false,
needed_talk_power: None,
},
ChannelInfo {
id: chanora_protocol::ChannelId(2),
@@ -2121,6 +2363,7 @@ mod tests {
name: "b".into(),
order: 1,
has_password: false,
needed_talk_power: None,
},
],
clients: vec![ClientInfo {
@@ -2131,6 +2374,8 @@ mod tests {
output_muted: false,
is_speaking: false,
is_server_query: false,
talk_power: 0,
talk_power_granted: false,
}],
own_client_id: 10,
};
@@ -2155,6 +2400,7 @@ mod tests {
name: "b".into(),
order: 1,
has_password: false,
needed_talk_power: None,
},
ChannelInfo {
id: chanora_protocol::ChannelId(1),
@@ -2162,6 +2408,7 @@ mod tests {
name: "a".into(),
order: 0,
has_password: false,
needed_talk_power: None,
},
],
clients: vec![],