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
+249 -40
View File
@@ -80,7 +80,10 @@ fn session() -> &'static chanora_core::ChanoraSession {
fn log_sink() -> &'static chanora_core::InMemoryLogSink {
static SINK: OnceLock<chanora_core::InMemoryLogSink> = OnceLock::new();
SINK.get_or_init(|| {
chanora_core::InMemoryLogSink::new(500, chanora_core::Redactor::with_default_policy())
chanora_core::InMemoryLogSink::new(
chanora_core::DEFAULT_LOG_CAPACITY,
chanora_core::Redactor::with_default_policy(),
)
})
}
@@ -342,6 +345,9 @@ pub struct BridgeChannel {
pub order: i64,
/// True when the server marks the channel as password-protected.
pub has_password: bool,
/// Talk power threshold required to speak in this channel.
/// None means no talk-power restriction.
pub needed_talk_power: Option<i32>,
}
/// Client as seen by Dart.
@@ -361,6 +367,10 @@ pub struct BridgeClient {
pub is_speaking: bool,
/// True for TeamSpeak ServerQuery clients.
pub is_server_query: bool,
/// Current talk power value assigned by the server.
pub talk_power: i32,
/// True when the server has granted talk power regardless of numeric value.
pub talk_power_granted: bool,
}
/// Server snapshot as seen by Dart.
@@ -384,8 +394,8 @@ pub struct BridgeSnapshot {
pub own_client_id: u64,
}
impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
fn from(s: chanora_protocol::ServerSnapshot) -> Self {
impl From<chanora_core::ServerSnapshot> for BridgeSnapshot {
fn from(s: chanora_core::ServerSnapshot) -> Self {
Self {
server_name: s.server_name,
welcome_message: s.welcome_message,
@@ -400,6 +410,7 @@ impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
name: c.name,
order: c.order,
has_password: c.has_password,
needed_talk_power: c.needed_talk_power,
})
.collect(),
clients: s
@@ -413,6 +424,8 @@ impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
output_muted: c.output_muted,
is_speaking: c.is_speaking,
is_server_query: c.is_server_query,
talk_power: c.talk_power,
talk_power_granted: c.talk_power_granted,
})
.collect(),
own_client_id: s.own_client_id,
@@ -501,24 +514,6 @@ pub fn handle_route_change(route: BridgeAudioRoute) {
}
}
/// Handle iOS AVAudioSession media-services reset (legacy, no route arg).
///
/// Called by the existing FRB-generated Dart binding. Uses
/// `AudioRoute::Unknown` which triggers a route-change recompute.
/// The AppDelegate now also calls `handle_media_services_reset_with_route`
/// directly after rebuilding the session.
#[frb(sync)]
pub fn handle_media_services_reset() {
let result = runtime().block_on(async {
session()
.ios_handle_media_services_reset(chanora_audio::AudioRoute::Unknown)
.await
});
if let Err(e) = result {
warn!(target: "chanora_bridge", error = %e, "iOS media-services reset handling failed");
}
}
/// Handle iOS AVAudioSession media-services reset with the current
/// route class. Called by AppDelegate after rebuilding the session.
///
@@ -552,11 +547,11 @@ pub fn handle_interruption_ended(should_resume: bool) {
}
}
/// Set the push-to-talk state.
/// Set the focused/on-screen push-to-talk hold state.
///
/// Superseded in v1 by [`set_transmit_mode`] + the binding capture
/// dialog. Retained so legacy callers and integration tests keep
/// working; the new VoiceBar UI no longer invokes this.
/// Binding capture chooses which physical key drives PTT, while this
/// command carries the actual press/release edge for fallback focused
/// keyboard handling and touch controls.
pub async fn set_ptt(active: bool) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_ptt(active).await })
@@ -691,6 +686,46 @@ pub enum BridgePttInputClass {
MouseSideButton,
}
/// Privacy-safe PTT capability descriptor for the UI.
#[derive(Debug, Clone)]
pub struct BridgePttDescriptor {
/// 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<chanora_core::PttDescriptorSnapshot> for BridgePttDescriptor {
fn from(desc: chanora_core::PttDescriptorSnapshot) -> Self {
Self {
level: desc.level,
backend_id: desc.backend_id,
bound_input_class: desc.bound_input_class,
}
}
}
/// Persisted PTT binding display state for the UI.
#[derive(Debug, Clone)]
pub struct BridgePttBinding {
/// 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 From<chanora_core::PersistedPttBinding> for BridgePttBinding {
fn from(binding: chanora_core::PersistedPttBinding) -> Self {
Self {
input_class: binding.input_class,
key_label: binding.key_label,
}
}
}
impl From<BridgePttInputClass> for chanora_core::PttInputClass {
fn from(c: BridgePttInputClass) -> Self {
match c {
@@ -721,27 +756,34 @@ pub async fn set_ptt_binding(
Ok(())
}
/// Read the current PTT capability descriptor. Returns a
/// `(level, backend_id, bound_input_class)` triple matching the
/// privacy-safe `BridgeEvent::PttCapability` event shape; useful
/// for the initial UI render before the first event arrives.
pub async fn ptt_descriptor() -> (String, String, String) {
/// Read the current PTT capability descriptor. Matches the privacy-safe
/// `BridgeEvent::PttCapability` event shape; useful for the initial UI
/// render before the first event arrives.
pub async fn ptt_descriptor() -> BridgePttDescriptor {
runtime()
.spawn(async { session().ptt_descriptor().await })
.await
.unwrap_or_else(|_| (String::new(), String::new(), String::new()))
.map(Into::into)
.unwrap_or_else(|_| BridgePttDescriptor {
level: String::new(),
backend_id: String::new(),
bound_input_class: String::new(),
})
}
/// Return the persisted PTT binding as a
/// `(input_class, platform_key)` pair so the UI can hydrate its
/// display state at launch (e.g. show "PTT: Space" next to the
/// badge before the user re-opens the binding dialog). Empty
/// strings mean no binding has been persisted yet.
pub async fn get_ptt_binding() -> (String, String) {
/// Return the persisted PTT binding so the UI can hydrate its display
/// state at launch (e.g. show "PTT: Space" next to the badge before the
/// user re-opens the binding dialog). Empty strings mean no binding has
/// been persisted yet.
pub async fn get_ptt_binding() -> BridgePttBinding {
runtime()
.spawn(async { session().get_ptt_binding().await })
.await
.unwrap_or_else(|_| (String::new(), String::new()))
.map(Into::into)
.unwrap_or_else(|_| BridgePttBinding {
input_class: String::new(),
key_label: String::new(),
})
}
/// Move our own client to `channel_id`. Optional channel password
@@ -794,6 +836,36 @@ pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> {
Ok(())
}
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. No-op when client has no active voice queue. Volume is
/// applied directly to the tsclientlib AudioQueue and takes effect
/// immediately on the next render callback.
pub async fn set_client_volume(client_id: u64, volume: f32) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_client_volume(client_id, volume).await })
.await
.map_err(|e| task_join_error("set_client_volume", e))??;
Ok(())
}
/// Send a text message to the specified target.
pub async fn send_chat_message(
message: String,
target: BridgeMessageTarget,
) -> Result<(), BridgeError> {
let target_core: chanora_core::MessageTarget = match target {
BridgeMessageTarget::Server => chanora_core::MessageTarget::Server,
BridgeMessageTarget::Channel => chanora_core::MessageTarget::Channel,
BridgeMessageTarget::Client(id) => chanora_core::MessageTarget::Client(id),
BridgeMessageTarget::Poke(id) => chanora_core::MessageTarget::Poke(id),
};
runtime()
.spawn(async move { session().send_text_message(message, target_core).await })
.await
.map_err(|e| task_join_error("send_chat_message", e))??;
Ok(())
}
/// Statistics from the audio engine.
#[derive(Debug, Clone)]
pub struct BridgeAudioStats {
@@ -1164,8 +1236,14 @@ pub fn export_diagnostics() -> String {
let android_audio_yaml =
chanora_audio::mobile_voice_backend::current_android_audio_diagnostics()
.map(|d| d.to_yaml_fragment());
let network_info = runtime().block_on(async { session().network_diagnostics_summary().await });
let protocol_events = runtime().block_on(async { session().drain_protocol_events().await });
match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) {
Ok(exp) => exp.with_android_audio(android_audio_yaml).to_text(),
Ok(exp) => exp
.with_android_audio(android_audio_yaml)
.with_network_info(Some(network_info))
.with_protocol_events(protocol_events)
.to_text(),
Err(e) => format!("(diagnostic export failed: {e})"),
}
}
@@ -1417,6 +1495,46 @@ pub enum BridgeEvent {
/// Resolved permission state.
state: PermissionStateKind,
},
/// A text message 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: BridgeMessageTarget,
},
/// Audio route changed (speaker/earpiece/BT/wired).
AudioRouteChanged {
/// The new audio route.
route: BridgeAudioRoute,
},
}
/// Bridge message target scope.
#[derive(Debug, Clone, Copy)]
pub enum BridgeMessageTarget {
/// Broadcast to entire server.
Server,
/// Broadcast to current channel.
Channel,
/// Private message to a specific client.
Client(u64),
/// Poke a specific client.
Poke(u64),
}
impl From<chanora_core::MessageTarget> for BridgeMessageTarget {
fn from(t: chanora_core::MessageTarget) -> Self {
match t {
chanora_core::MessageTarget::Server => Self::Server,
chanora_core::MessageTarget::Channel => Self::Channel,
chanora_core::MessageTarget::Client(id) => Self::Client(id),
chanora_core::MessageTarget::Poke(id) => Self::Poke(id),
}
}
}
/// Bridge mirror of core join projection sync state.
@@ -1563,6 +1681,22 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
began,
should_resume,
},
chanora_core::SessionEvent::ChatMessage {
sender_id,
sender_name,
message,
target,
} => BridgeEvent::ChatMessage {
sender_id,
sender_name,
message,
target: target.into(),
},
chanora_core::SessionEvent::AudioRouteChanged { route } => {
BridgeEvent::AudioRouteChanged {
route: route.into(),
}
}
}
}
}
@@ -1711,6 +1845,66 @@ pub async fn audio_processing_stats() -> Result<BridgeAudioProcessingStats, Brid
Ok(stats.into())
}
/// Audio device info from the platform.
#[derive(Debug, Clone)]
pub struct BridgeAudioDevice {
/// Human-readable device name.
pub name: String,
/// True if the OS reports this as the default device.
pub is_default: bool,
}
/// List of available audio devices.
#[derive(Debug, Clone)]
pub struct BridgeAudioDeviceList {
/// Available input devices.
pub input_devices: Vec<BridgeAudioDevice>,
/// Available output devices.
pub output_devices: Vec<BridgeAudioDevice>,
}
/// List available audio input and output devices from the platform.
pub fn list_audio_devices() -> BridgeAudioDeviceList {
let list = chanora_audio::list_audio_devices();
BridgeAudioDeviceList {
input_devices: list
.input_devices
.into_iter()
.map(|d| BridgeAudioDevice {
name: d.name,
is_default: d.is_default,
})
.collect(),
output_devices: list
.output_devices
.into_iter()
.map(|d| BridgeAudioDevice {
name: d.name,
is_default: d.is_default,
})
.collect(),
}
}
/// Set the preferred input device by name. Takes effect on next
/// `start_audio`.
pub async fn set_input_device(name: Option<String>) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_input_device(name).await })
.await
.map_err(|e| task_join_error("set_input_device", e))??;
Ok(())
}
/// Set the preferred output device by name.
pub async fn set_output_device(name: Option<String>) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_output_device(name).await })
.await
.map_err(|e| task_join_error("set_output_device", e))??;
Ok(())
}
/// Configure the VAD model path.
pub async fn set_vad_model_path(path: String) -> Result<(), BridgeError> {
if path.trim().is_empty() {
@@ -1733,7 +1927,7 @@ pub async fn set_ten_vad_model_path(path: String) -> Result<(), BridgeError> {
));
}
runtime()
.spawn(async move { chanora_audio::vad::set_ten_model_path(&path).map_err(|e| e) })
.spawn(async move { chanora_audio::vad::set_ten_model_path(&path) })
.await
.map_err(|e| task_join_error("set_ten_vad_model_path", e))?
.map_err(|e| BridgeError::Unmapped(format!("set_ten_vad_model_path: {e}")))?;
@@ -1785,3 +1979,18 @@ pub async fn set_ios_voice_processing_mode(
};
set_audio_processing_config(config).await
}
/// Set the preferred audio output route (Android/iOS).
#[frb(sync)]
pub fn set_audio_output_route(route: BridgeAudioRoute) {
runtime().block_on(async {
let _ = session().ios_handle_route_change(route.into()).await;
});
}
/// Called from Flutter when the app enters background/foreground.
#[frb(sync)]
pub fn record_lifecycle_event(state: String) {
runtime().block_on(async {
session().record_lifecycle_event(&state).await;
});
}