feat: integrate chat voice and diagnostics client
This commit is contained in:
+340
-93
@@ -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![],
|
||||
|
||||
Reference in New Issue
Block a user