feat: integrate chat voice and diagnostics client
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user