2403 lines
87 KiB
Rust
2403 lines
87 KiB
Rust
//! Public API exposed to Dart via `flutter_rust_bridge`.
|
|
//!
|
|
//! Naming follows the FRB v2 convention: free functions at the crate
|
|
//! API root, with `#[frb(sync)]` for synchronous calls and async fn
|
|
//! signatures for async ones. Every input and output is an owned
|
|
//! type whose layout is schema-controlled (no `tsclientlib`, no
|
|
//! `cpal`, no `Connection` handles).
|
|
|
|
use std::sync::OnceLock;
|
|
use std::time::Duration;
|
|
|
|
use flutter_rust_bridge::frb;
|
|
use tokio::runtime::Runtime;
|
|
use tokio::sync::{broadcast, mpsc};
|
|
use tracing::{info, warn};
|
|
|
|
use crate::frb_generated::StreamSink;
|
|
use crate::BridgeError;
|
|
|
|
/// Process-wide tokio runtime used to drive the async core. Created
|
|
/// lazily on first use and never torn down — the application's
|
|
/// process lifetime is the runtime's lifetime.
|
|
fn runtime() -> &'static Runtime {
|
|
static RT: OnceLock<Runtime> = OnceLock::new();
|
|
RT.get_or_init(|| {
|
|
tokio::runtime::Builder::new_multi_thread()
|
|
.worker_threads(2)
|
|
.enable_all()
|
|
.thread_name("chanora-rt")
|
|
.build()
|
|
.expect("tokio runtime")
|
|
})
|
|
}
|
|
|
|
fn task_join_error(task: &'static str, error: tokio::task::JoinError) -> BridgeError {
|
|
warn!(
|
|
target: "chanora_bridge",
|
|
task,
|
|
error = %error,
|
|
"runtime task failed; surfacing in diagnostics"
|
|
);
|
|
BridgeError::Unmapped(format!("join: {error}"))
|
|
}
|
|
|
|
enum PlatformAudioEvent {
|
|
RouteChanged(chanora_audio::AudioRoute),
|
|
MediaServicesResetWithRoute(chanora_audio::AudioRoute),
|
|
InterruptionBegan,
|
|
InterruptionEnded { should_resume: bool },
|
|
AudioOutputRoute(chanora_audio::AudioRoute),
|
|
Lifecycle { state: String },
|
|
}
|
|
|
|
impl PlatformAudioEvent {
|
|
async fn process(self) {
|
|
match self {
|
|
Self::RouteChanged(route) => {
|
|
if let Err(e) = session().ios_handle_route_change(route).await {
|
|
warn!(target: "chanora_bridge", error = %e, "iOS route-change handling failed");
|
|
}
|
|
}
|
|
Self::MediaServicesResetWithRoute(route) => {
|
|
if let Err(e) = session().ios_handle_media_services_reset(route).await {
|
|
warn!(target: "chanora_bridge", error = %e, "iOS media-services reset (with route) handling failed");
|
|
}
|
|
}
|
|
Self::InterruptionBegan => {
|
|
if let Err(e) = session().ios_handle_interruption_began().await {
|
|
warn!(target: "chanora_bridge", error = %e, "iOS interruption-began handling failed");
|
|
}
|
|
}
|
|
Self::InterruptionEnded { should_resume } => {
|
|
if let Err(e) = session().ios_handle_interruption_ended(should_resume).await {
|
|
warn!(target: "chanora_bridge", error = %e, "iOS interruption-ended handling failed");
|
|
}
|
|
}
|
|
Self::AudioOutputRoute(route) => {
|
|
if let Err(e) = session().ios_handle_route_change(route).await {
|
|
warn!(target: "chanora_bridge", error = %e, "audio output route handling failed");
|
|
}
|
|
}
|
|
Self::Lifecycle { state } => {
|
|
session().record_lifecycle_event(&state).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn platform_audio_events() -> &'static mpsc::UnboundedSender<PlatformAudioEvent> {
|
|
static TX: OnceLock<mpsc::UnboundedSender<PlatformAudioEvent>> = OnceLock::new();
|
|
TX.get_or_init(|| {
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<PlatformAudioEvent>();
|
|
runtime().spawn(async move {
|
|
while let Some(event) = rx.recv().await {
|
|
event.process().await;
|
|
}
|
|
});
|
|
tx
|
|
})
|
|
}
|
|
|
|
fn dispatch_platform_audio_event(event: PlatformAudioEvent) {
|
|
if let Err(e) = platform_audio_events().send(event) {
|
|
warn!(
|
|
target: "chanora_bridge",
|
|
error = %e,
|
|
"ordered platform audio event dispatch failed"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn install_panic_diagnostic_hook() {
|
|
static INSTALLED: OnceLock<()> = OnceLock::new();
|
|
let _ = INSTALLED.get_or_init(|| {
|
|
std::panic::set_hook(Box::new(|panic_info| {
|
|
let message = panic_info
|
|
.payload()
|
|
.downcast_ref::<&'static str>()
|
|
.map(|s| (*s).to_string())
|
|
.or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
|
|
.unwrap_or_else(|| "non-string panic payload".to_string());
|
|
let location = panic_info
|
|
.location()
|
|
.map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column()))
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
warn!(
|
|
target: "chanora_bridge",
|
|
panic_message = %message,
|
|
panic_location = %location,
|
|
"panic captured for diagnostic export"
|
|
);
|
|
}));
|
|
});
|
|
}
|
|
|
|
/// Process-wide session handle. One instance per process is enough
|
|
/// for Alpha (DEC-006 single-connection invariant); the Mutex inside
|
|
/// `ChanoraSession` enforces the connection-count invariant.
|
|
fn session() -> &'static chanora_core::ChanoraSession {
|
|
static S: OnceLock<chanora_core::ChanoraSession> = OnceLock::new();
|
|
S.get_or_init(chanora_core::ChanoraSession::new)
|
|
}
|
|
|
|
/// Process-wide redacted-log sink. Captures the last N tracing
|
|
/// records (already redacted) so the user-initiated diagnostic
|
|
/// export has something to ship. Lazily created on first access.
|
|
fn log_sink() -> &'static chanora_core::InMemoryLogSink {
|
|
static SINK: OnceLock<chanora_core::InMemoryLogSink> = OnceLock::new();
|
|
SINK.get_or_init(|| {
|
|
chanora_core::InMemoryLogSink::new(
|
|
chanora_core::DEFAULT_LOG_CAPACITY,
|
|
chanora_core::Redactor::with_default_policy(),
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Process-wide broadcast channel for [`BridgeEvent::PermissionState`]
|
|
/// emissions published by the platform permission JNI hook (SDD-106
|
|
/// §5). Kept separate from the core `SessionEvent` stream because
|
|
/// permission state is owned by the platform-bound bridge layer, not
|
|
/// by `chanora_core` (which is platform-agnostic). The
|
|
/// [`events_stream`] task fans this channel and the core session
|
|
/// stream into the single Dart-facing sink.
|
|
///
|
|
/// Capacity (64) is chosen empirically and should match the order of
|
|
/// magnitude of other `BridgeEvent` broadcast channels — generous so
|
|
/// a slow Dart subscriber misses at most the oldest queued event
|
|
/// (`tokio::sync::broadcast` drops oldest, never blocks the
|
|
/// producer) rather than impacting the JNI thread that produced it.
|
|
fn permission_events() -> &'static broadcast::Sender<BridgeEvent> {
|
|
static TX: OnceLock<broadcast::Sender<BridgeEvent>> = OnceLock::new();
|
|
TX.get_or_init(|| broadcast::channel(64).0)
|
|
}
|
|
|
|
/// Publish a permission-state event onto the bridge's permission
|
|
/// channel and clamp the audio engine's transmit selector
|
|
/// accordingly (SDD-106 §5/§6, SRS-209).
|
|
///
|
|
/// Called by the platform-side JNI hook (see
|
|
/// [`crate::permission_jni`]). The call is non-blocking under
|
|
/// normal operation: the transmit-selector clamp uses `AtomicU8`
|
|
/// (see `chanora_audio::TransmitModeSelector::set_permission_state`)
|
|
/// and the broadcast send drops oldest on a full channel rather
|
|
/// than parking the JNI thread. The function never panics under
|
|
/// normal operation; the only theoretical panic source is internal
|
|
/// `tokio::sync::broadcast` invariants, and any such panic would be
|
|
/// caught by the outer `catch_unwind` in the JNI entry point.
|
|
/// A missing subscriber or a closed selector is logged at `warn`
|
|
/// level and otherwise ignored.
|
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
|
pub(crate) fn publish_permission_state(permission: String, state: PermissionStateKind) {
|
|
// 1. Authoritative audio-engine clamp (SDD-106 §6). Only the
|
|
// microphone permission drives the transmit gate; other
|
|
// permissions (e.g. POST_NOTIFICATIONS per SDD-107 §6) ride
|
|
// the same event surface but do not affect transmit.
|
|
if permission == "android.permission.RECORD_AUDIO" {
|
|
let selector = session().transmit_selector();
|
|
// SDD-106 §5: selector.set_permission_state uses
|
|
// AtomicU8::store which is non-blocking; the JNI thread is
|
|
// not parked. Safe to call from publishPermissionState.
|
|
selector.set_permission_state(state.to_permission_gate());
|
|
}
|
|
|
|
// 2. Fan out to Dart subscribers. Best-effort: a send error
|
|
// means no current subscriber (Dart side not yet attached
|
|
// or already torn down) which is fine.
|
|
let _ = permission_events().send(BridgeEvent::PermissionState { permission, state });
|
|
}
|
|
|
|
/// Default tracing filter. Suppresses the chatty
|
|
/// `tsproto::resend` and `tsproto::packet_codec` paths that
|
|
/// flood the diagnostic export during transient packet loss;
|
|
/// users can still raise verbosity via `RUST_LOG=info`.
|
|
const DEFAULT_LOG_FILTER: &str =
|
|
"info,tsproto::resend=error,tsproto::packet_codec=error,tsclientlib=error,ts_bookkeeping::messages::s2c=error";
|
|
|
|
/// Initialise the bridge. Must be called once on Dart side before
|
|
/// any other API call. Sets up panic logging.
|
|
#[frb(init)]
|
|
pub fn bridge_init() {
|
|
flutter_rust_bridge::setup_default_user_utils();
|
|
|
|
// Always install the redacted in-memory log sink — diagnostic
|
|
// export depends on it (DEC-016: user-initiated only, never
|
|
// auto-upload). It runs alongside whatever platform sink
|
|
// exists below; both consume the same `tracing` events.
|
|
let redact_layer = chanora_core::RedactingLogLayer::new(log_sink().clone()).with_sanitizer();
|
|
|
|
// On Android, also fan tracing output out to logcat so a user can
|
|
// see protocol/audio diagnostics via `adb logcat -s chanora`.
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
use tracing_subscriber::layer::SubscriberExt;
|
|
use tracing_subscriber::util::SubscriberInitExt;
|
|
let android_layer = tracing_android::layer("chanora").ok();
|
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER));
|
|
let _ = tracing_subscriber::registry()
|
|
.with(filter)
|
|
.with(android_layer)
|
|
.with(redact_layer)
|
|
.try_init();
|
|
}
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
{
|
|
use tracing_subscriber::layer::SubscriberExt;
|
|
use tracing_subscriber::util::SubscriberInitExt;
|
|
let fmt_layer = tracing_subscriber::fmt::layer().with_target(true);
|
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER));
|
|
|
|
// Also tee tracing output to a rotating file in the OS's
|
|
// standard log directory so developers and beta testers can
|
|
// hand-inspect output without launching from a terminal.
|
|
// The path is platform-conventional:
|
|
// * Linux: ~/.local/state/app.chanora/chanora_flutter/chanora.log
|
|
// * macOS: ~/Library/Logs/app.chanora.chanora_flutter/chanora.log
|
|
// * Windows: %LOCALAPPDATA%\app.chanora\chanora_flutter\logs\chanora.log
|
|
// Best-effort: if the directory cannot be created or the
|
|
// file cannot be opened, the layer is silently dropped and
|
|
// stderr remains the only sink.
|
|
let file_writer = open_log_file();
|
|
if let Some(file) = file_writer {
|
|
let file_layer = tracing_subscriber::fmt::layer()
|
|
.with_target(true)
|
|
.with_ansi(false)
|
|
.with_writer(std::sync::Mutex::new(file));
|
|
let _ = tracing_subscriber::registry()
|
|
.with(filter)
|
|
.with(fmt_layer)
|
|
.with(file_layer)
|
|
.with(redact_layer)
|
|
.try_init();
|
|
} else {
|
|
let _ = tracing_subscriber::registry()
|
|
.with(filter)
|
|
.with(fmt_layer)
|
|
.with(redact_layer)
|
|
.try_init();
|
|
}
|
|
}
|
|
|
|
install_panic_diagnostic_hook();
|
|
info!(target: "chanora_bridge", "bridge initialised");
|
|
if let Some(p) = log_file_path() {
|
|
info!(target: "chanora_bridge", path = %p.display(), "log file path");
|
|
}
|
|
}
|
|
|
|
/// Return the platform-conventional log-file path as a string, or
|
|
/// an empty string if the platform does not have one (mobile).
|
|
#[frb(sync)]
|
|
pub fn log_file_path_str() -> String {
|
|
log_file_path()
|
|
.map(|p| p.display().to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Resolve the platform-conventional log-file path. Returns `None`
|
|
/// if the platform conventions can't be honoured (e.g. neither
|
|
/// `LOCALAPPDATA` nor `HOME` is set).
|
|
fn log_file_path() -> Option<std::path::PathBuf> {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let base = std::env::var_os("LOCALAPPDATA").or_else(|| std::env::var_os("APPDATA"))?;
|
|
Some(
|
|
std::path::PathBuf::from(base)
|
|
.join("app.chanora")
|
|
.join("chanora_flutter")
|
|
.join("logs")
|
|
.join("chanora.log"),
|
|
)
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let home = std::env::var_os("HOME")?;
|
|
Some(
|
|
std::path::PathBuf::from(home)
|
|
.join("Library")
|
|
.join("Logs")
|
|
.join("app.chanora.chanora_flutter")
|
|
.join("chanora.log"),
|
|
)
|
|
}
|
|
#[cfg(all(
|
|
unix,
|
|
not(target_os = "macos"),
|
|
not(target_os = "android"),
|
|
not(target_os = "ios")
|
|
))]
|
|
{
|
|
let base = std::env::var_os("XDG_STATE_HOME")
|
|
.map(std::path::PathBuf::from)
|
|
.or_else(|| {
|
|
std::env::var_os("HOME")
|
|
.map(|h| std::path::PathBuf::from(h).join(".local").join("state"))
|
|
})?;
|
|
Some(
|
|
base.join("app.chanora")
|
|
.join("chanora_flutter")
|
|
.join("chanora.log"),
|
|
)
|
|
}
|
|
#[cfg(target_os = "ios")]
|
|
{
|
|
None
|
|
}
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
// Android log file location is set up via the bridge's
|
|
// Java-side init that writes the chosen path into an env
|
|
// var (not currently wired; P1 follow-up). For now we
|
|
// return None and rely on logcat.
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Open the platform log file in append mode, rotating once on
|
|
/// startup so each launch begins with a fresh file. Best-effort:
|
|
/// returns `None` on any I/O error.
|
|
#[cfg(not(target_os = "android"))]
|
|
fn open_log_file() -> Option<std::fs::File> {
|
|
let path = log_file_path()?;
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
// Rotate at every launch (not on size). A single session can
|
|
// emit huge logs when a chatty subsystem floods (the original
|
|
// bug that surfaced this: tsclientlib emitting one warning per
|
|
// outbound voice frame while server-side muted). Truncating on
|
|
// every launch keeps per-session disk use bounded by what one
|
|
// session can produce in its lifetime; the on-launch rotate
|
|
// also gives the previous session's log a stable home at
|
|
// `chanora.log.1` for post-mortem inspection.
|
|
//
|
|
// Two generations kept: `chanora.log.1` (previous launch) and
|
|
// `chanora.log.2` (the one before that). Older generations
|
|
// are deleted to keep disk use bounded across many launches.
|
|
if path.exists() {
|
|
let g1 = path.with_extension("log.1");
|
|
let g2 = path.with_extension("log.2");
|
|
// .2 is dropped; .1 becomes .2; current becomes .1.
|
|
let _ = std::fs::remove_file(&g2);
|
|
let _ = std::fs::rename(&g1, &g2);
|
|
let _ = std::fs::rename(&path, &g1);
|
|
}
|
|
// Open fresh (truncate if rename somehow failed so we never
|
|
// append onto a stale file).
|
|
std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.write(true)
|
|
.truncate(true)
|
|
.open(&path)
|
|
.ok()
|
|
}
|
|
|
|
// ---------- DTOs ----------
|
|
|
|
/// Channel as seen by Dart. Matches `chanora_protocol::ChannelInfo`
|
|
/// but with primitive `u64` ids so the Dart side gets `BigInt`s
|
|
/// without any wrapper-type ceremony.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BridgeChannel {
|
|
/// Stable channel id.
|
|
pub id: u64,
|
|
/// Parent channel id; 0 means top-level.
|
|
pub parent: u64,
|
|
/// Display name.
|
|
pub name: String,
|
|
/// Server-side ordering hint.
|
|
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.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BridgeClient {
|
|
/// Stable client id.
|
|
pub id: u64,
|
|
/// Channel id the client is currently in.
|
|
pub channel: u64,
|
|
/// Nickname.
|
|
pub name: String,
|
|
/// True when this client has muted microphone/input capture.
|
|
pub input_muted: bool,
|
|
/// True when this client has muted speaker/output audio.
|
|
pub output_muted: bool,
|
|
/// True when recent inbound voice activity was observed for this client.
|
|
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,
|
|
}
|
|
|
|
/// Rich profile and live connection details for one online client.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BridgeClientProfile {
|
|
/// Stable online client id.
|
|
pub id: u64,
|
|
/// Channel the client is currently in.
|
|
pub channel: u64,
|
|
/// Nickname.
|
|
pub name: String,
|
|
/// TeamSpeak unique id.
|
|
pub unique_id: String,
|
|
/// Stable TeamSpeak database id, when visible.
|
|
pub database_id: Option<u64>,
|
|
/// ISO country code, when visible.
|
|
pub country_code: String,
|
|
/// User description, when visible.
|
|
pub description: String,
|
|
/// Client version string.
|
|
pub version: String,
|
|
/// Client platform string.
|
|
pub platform: String,
|
|
/// Account creation time as Unix seconds.
|
|
pub created_unix_seconds: Option<i64>,
|
|
/// Last connection time as Unix seconds.
|
|
pub last_connected_unix_seconds: Option<i64>,
|
|
/// Total historical connections.
|
|
pub connections_total: Option<u64>,
|
|
/// Current online duration in seconds.
|
|
pub online_seconds: Option<i64>,
|
|
/// Current idle time in milliseconds.
|
|
pub idle_milliseconds: Option<i64>,
|
|
/// Current ping in milliseconds.
|
|
pub ping_milliseconds: Option<i64>,
|
|
/// Current ping deviation in milliseconds.
|
|
pub ping_deviation_milliseconds: Option<i64>,
|
|
/// Client address. Empty when permission-gated.
|
|
pub client_address: String,
|
|
/// Resolved server group names.
|
|
pub server_groups: Vec<String>,
|
|
/// Resolved channel group name.
|
|
pub channel_group: String,
|
|
/// TeamSpeak avatar file path suffix.
|
|
pub avatar_path: String,
|
|
/// Downloaded bytes this month.
|
|
pub bytes_downloaded_month: Option<u64>,
|
|
/// Uploaded bytes this month.
|
|
pub bytes_uploaded_month: Option<u64>,
|
|
/// Downloaded bytes across all time.
|
|
pub bytes_downloaded_total: Option<u64>,
|
|
/// Uploaded bytes across all time.
|
|
pub bytes_uploaded_total: Option<u64>,
|
|
/// Client-to-server total packet loss ratio.
|
|
pub packet_loss_client_to_server_total: Option<f32>,
|
|
/// Server-to-client total packet loss ratio.
|
|
pub packet_loss_server_to_client_total: Option<f32>,
|
|
}
|
|
|
|
/// Server snapshot as seen by Dart.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BridgeSnapshot {
|
|
/// Server name.
|
|
pub server_name: String,
|
|
/// Welcome banner text.
|
|
pub welcome_message: String,
|
|
/// Server platform (e.g. "Linux").
|
|
pub platform: String,
|
|
/// Server version string.
|
|
pub version: String,
|
|
/// Channels currently known.
|
|
pub channels: Vec<BridgeChannel>,
|
|
/// Clients currently known.
|
|
pub clients: Vec<BridgeClient>,
|
|
/// Our own client id. Useful for the UI to highlight our row
|
|
/// in the client list and to know which channel we are in
|
|
/// without trusting the optimistic local state.
|
|
pub own_client_id: u64,
|
|
}
|
|
|
|
impl From<chanora_core::ClientProfile> for BridgeClientProfile {
|
|
fn from(profile: chanora_core::ClientProfile) -> Self {
|
|
Self {
|
|
id: profile.id.0,
|
|
channel: profile.channel.0,
|
|
name: profile.name,
|
|
unique_id: profile.unique_id,
|
|
database_id: profile.database_id,
|
|
country_code: profile.country_code,
|
|
description: profile.description,
|
|
version: profile.version,
|
|
platform: profile.platform,
|
|
created_unix_seconds: profile.created_unix_seconds,
|
|
last_connected_unix_seconds: profile.last_connected_unix_seconds,
|
|
connections_total: profile.connections_total,
|
|
online_seconds: profile.online_seconds,
|
|
idle_milliseconds: profile.idle_milliseconds,
|
|
ping_milliseconds: profile.ping_milliseconds,
|
|
ping_deviation_milliseconds: profile.ping_deviation_milliseconds,
|
|
client_address: profile.client_address,
|
|
server_groups: profile.server_groups,
|
|
channel_group: profile.channel_group,
|
|
avatar_path: profile.avatar_path,
|
|
bytes_downloaded_month: profile.bytes_downloaded_month,
|
|
bytes_uploaded_month: profile.bytes_uploaded_month,
|
|
bytes_downloaded_total: profile.bytes_downloaded_total,
|
|
bytes_uploaded_total: profile.bytes_uploaded_total,
|
|
packet_loss_client_to_server_total: profile.packet_loss_client_to_server_total,
|
|
packet_loss_server_to_client_total: profile.packet_loss_server_to_client_total,
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
platform: s.platform,
|
|
version: s.version,
|
|
channels: s
|
|
.channels
|
|
.into_iter()
|
|
.map(|c| BridgeChannel {
|
|
id: c.id.0,
|
|
parent: c.parent.0,
|
|
name: c.name,
|
|
order: c.order,
|
|
has_password: c.has_password,
|
|
needed_talk_power: c.needed_talk_power,
|
|
})
|
|
.collect(),
|
|
clients: s
|
|
.clients
|
|
.into_iter()
|
|
.map(|c| BridgeClient {
|
|
id: c.id.0,
|
|
channel: c.channel.0,
|
|
name: c.name,
|
|
input_muted: c.input_muted,
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- Commands ----------
|
|
|
|
/// Connect to a TeamSpeak-compatible server and return the initial
|
|
/// state snapshot. Honours the DEC-006 single-connection invariant
|
|
/// via [`BridgeError::AlreadyConnected`].
|
|
///
|
|
/// `password` is optional — pass an empty string for servers that
|
|
/// don't require one.
|
|
pub async fn connect(
|
|
host: String,
|
|
nickname: String,
|
|
password: String,
|
|
) -> Result<BridgeSnapshot, BridgeError> {
|
|
let cfg = chanora_core::ConnectConfig {
|
|
address: host,
|
|
nickname,
|
|
password: if password.is_empty() {
|
|
None
|
|
} else {
|
|
Some(password)
|
|
},
|
|
identity: None,
|
|
ready_timeout: Duration::from_secs(15),
|
|
resolved_address: None,
|
|
};
|
|
let snap = match runtime()
|
|
.spawn(async move { session().connect(cfg).await })
|
|
.await
|
|
.map_err(|e| task_join_error("connect", e))?
|
|
{
|
|
Ok(snap) => snap,
|
|
Err(chanora_core::CoreError::AlreadyConnected) => runtime()
|
|
.spawn(async { session().snapshot().await })
|
|
.await
|
|
.map_err(|e| task_join_error("connect.snapshot", e))??,
|
|
Err(e) => return Err(e.into()),
|
|
};
|
|
Ok(snap.into())
|
|
}
|
|
|
|
/// Warm server address resolution for the active host field. This is
|
|
/// intentionally fire-and-forget from the UI perspective: it schedules
|
|
/// Rust-side prefetch work and never opens a TS3 session.
|
|
pub async fn prefetch_server(host: String) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().prefetch_server(host).await })
|
|
.await
|
|
.map_err(|e| task_join_error("prefetch_server", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Re-fetch a fresh snapshot from the active connection.
|
|
pub async fn snapshot() -> Result<BridgeSnapshot, BridgeError> {
|
|
let snap = runtime()
|
|
.spawn(async { session().snapshot().await })
|
|
.await
|
|
.map_err(|e| task_join_error("snapshot", e))??;
|
|
Ok(snap.into())
|
|
}
|
|
|
|
/// Fetch richer profile and live connection details for one online client.
|
|
pub async fn client_profile(client_id: u64) -> Result<BridgeClientProfile, BridgeError> {
|
|
let profile = runtime()
|
|
.spawn(async move { session().client_profile(client_id).await })
|
|
.await
|
|
.map_err(|e| task_join_error("client_profile", e))??;
|
|
Ok(profile.into())
|
|
}
|
|
|
|
/// Disconnect from the server. No-op if not connected.
|
|
pub async fn disconnect() -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async { session().disconnect().await })
|
|
.await
|
|
.map_err(|e| task_join_error("disconnect", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// True if a connection is currently active.
|
|
pub async fn is_connected() -> bool {
|
|
runtime()
|
|
.spawn(async { session().is_connected().await })
|
|
.await
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
// ---------- Audio commands (Beta) ----------
|
|
|
|
// `start_audio` was removed per SDD-094 — voice activation now
|
|
// flows through `voice_join` / `voice_leave`, which transparently
|
|
// drive `AudioEngine::ensure_running` / `shutdown_if_idle`.
|
|
|
|
/// Handle iOS AVAudioSession route changes (SDD-100).
|
|
#[frb(sync)]
|
|
pub fn handle_route_change(route: BridgeAudioRoute) {
|
|
dispatch_platform_audio_event(PlatformAudioEvent::RouteChanged(route.into()));
|
|
}
|
|
|
|
/// Handle iOS AVAudioSession media-services reset with the current
|
|
/// route class. Called by AppDelegate after rebuilding the session.
|
|
///
|
|
/// `route_class` is the Swift-side route class string (e.g. "Speaker").
|
|
#[frb(sync)]
|
|
pub fn handle_media_services_reset_with_route(route_class: String) {
|
|
let route = chanora_audio::AudioRoute::from_route_class(&route_class);
|
|
dispatch_platform_audio_event(PlatformAudioEvent::MediaServicesResetWithRoute(route));
|
|
}
|
|
|
|
/// Handle iOS AVAudioSession interruption begin (SDD-101).
|
|
#[frb(sync)]
|
|
pub fn handle_interruption_began() {
|
|
dispatch_platform_audio_event(PlatformAudioEvent::InterruptionBegan);
|
|
}
|
|
|
|
/// Handle iOS AVAudioSession interruption end (SDD-101).
|
|
#[frb(sync)]
|
|
pub fn handle_interruption_ended(should_resume: bool) {
|
|
dispatch_platform_audio_event(PlatformAudioEvent::InterruptionEnded { should_resume });
|
|
}
|
|
|
|
/// Set the focused/on-screen push-to-talk hold state.
|
|
///
|
|
/// 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 })
|
|
.await
|
|
.map_err(|e| task_join_error("set_ptt", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
// ---------- v1 voice lifecycle (SDD-094/095/096) ----------
|
|
|
|
/// Voice transmit mode mirror (SDD-095). Schema-controlled enum;
|
|
/// the wire encoding matches [`chanora_core::TransmitMode::as_u8`].
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum BridgeTransmitMode {
|
|
/// Push-to-talk (default).
|
|
Ptt,
|
|
/// Continuous transmission while in channel and not muted.
|
|
Continuous,
|
|
/// Voice-activity detection — reserved per DEC-030; v1 behaves
|
|
/// as `Continuous`.
|
|
VoiceActivity,
|
|
}
|
|
|
|
impl From<BridgeTransmitMode> for chanora_core::TransmitMode {
|
|
fn from(m: BridgeTransmitMode) -> Self {
|
|
match m {
|
|
BridgeTransmitMode::Ptt => Self::Ptt,
|
|
BridgeTransmitMode::Continuous => Self::Continuous,
|
|
BridgeTransmitMode::VoiceActivity => Self::VoiceActivity,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<chanora_core::TransmitMode> for BridgeTransmitMode {
|
|
fn from(m: chanora_core::TransmitMode) -> Self {
|
|
match m {
|
|
chanora_core::TransmitMode::Ptt => Self::Ptt,
|
|
chanora_core::TransmitMode::Continuous => Self::Continuous,
|
|
chanora_core::TransmitMode::VoiceActivity => Self::VoiceActivity,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn transmit_mode_from_u8(v: u8) -> BridgeTransmitMode {
|
|
chanora_core::TransmitMode::from_u8(v)
|
|
.unwrap_or_default()
|
|
.into()
|
|
}
|
|
|
|
/// Join a voice channel (SDD-094). Moves the user to `channel_id`,
|
|
/// brings up the audio engine if needed, and emits
|
|
/// `BridgeEvent::VoiceState`. `password` may be empty.
|
|
pub async fn voice_join(channel_id: u64, password: String) -> Result<(), BridgeError> {
|
|
let pw = if password.is_empty() {
|
|
None
|
|
} else {
|
|
Some(password)
|
|
};
|
|
runtime()
|
|
.spawn(async move { session().voice_join(channel_id, pw).await })
|
|
.await
|
|
.map_err(|e| task_join_error("voice_join", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Leave the current voice channel (SDD-094). Tears down the audio
|
|
/// engine and emits `BridgeEvent::VoiceState`.
|
|
pub async fn voice_leave() -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async { session().voice_leave().await })
|
|
.await
|
|
.map_err(|e| task_join_error("voice_leave", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the active transmit mode (SDD-095).
|
|
pub async fn set_transmit_mode(mode: BridgeTransmitMode) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_transmit_mode(mode.into()).await })
|
|
.await
|
|
.map_err(|e| task_join_error("set_transmit_mode", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the active transmit mode.
|
|
pub async fn get_transmit_mode() -> BridgeTransmitMode {
|
|
runtime()
|
|
.spawn(async { session().transmit_mode() })
|
|
.await
|
|
.unwrap_or(chanora_core::TransmitMode::Ptt)
|
|
.into()
|
|
}
|
|
|
|
/// Update the release-tail in milliseconds (SDD-096). Values are
|
|
/// clamped to `0..=500` on the Rust side; passing anything larger
|
|
/// silently saturates.
|
|
pub async fn set_release_tail_ms(ms: u32) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_release_tail_ms(ms).await })
|
|
.await
|
|
.map_err(|e| task_join_error("set_release_tail_ms", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the current release-tail in milliseconds.
|
|
pub async fn get_release_tail_ms() -> u32 {
|
|
runtime()
|
|
.spawn(async { session().release_tail_ms() })
|
|
.await
|
|
.unwrap_or(200)
|
|
}
|
|
|
|
/// Engage or release the hard-mute clamp (SDD-094). When `true`
|
|
/// the audio engine transmits nothing regardless of mode.
|
|
pub async fn set_hard_mute(muted: bool) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_hard_mute(muted).await })
|
|
.await
|
|
.map_err(|e| task_join_error("set_hard_mute", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings;
|
|
/// the bridge never carries raw key codes.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum BridgePttInputClass {
|
|
/// No binding is active.
|
|
None,
|
|
/// A keyboard key.
|
|
Keyboard,
|
|
/// A mouse side button (Mouse4 / Mouse5).
|
|
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 {
|
|
BridgePttInputClass::None => chanora_core::PttInputClass::None,
|
|
BridgePttInputClass::Keyboard => chanora_core::PttInputClass::Keyboard,
|
|
BridgePttInputClass::MouseSideButton => chanora_core::PttInputClass::MouseSideButton,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update the active PTT binding (gen2 v0.9.3 / DEC-026). The
|
|
/// platform_key string is opaque to the bridge — it identifies the
|
|
/// bound key inside the platform backend and never appears in any
|
|
/// log record or diagnostic export (DEC-027 enforced at the
|
|
/// diagnostics-sanitizer layer).
|
|
pub async fn set_ptt_binding(
|
|
input_class: BridgePttInputClass,
|
|
platform_key: String,
|
|
) -> Result<(), BridgeError> {
|
|
let binding = chanora_core::PttBinding {
|
|
input_class: input_class.into(),
|
|
platform_key,
|
|
};
|
|
runtime()
|
|
.spawn(async move { session().set_ptt_binding(binding).await })
|
|
.await
|
|
.map_err(|e| task_join_error("set_ptt_binding", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// 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
|
|
.map(Into::into)
|
|
.unwrap_or_else(|_| BridgePttDescriptor {
|
|
level: String::new(),
|
|
backend_id: String::new(),
|
|
bound_input_class: String::new(),
|
|
})
|
|
}
|
|
|
|
/// 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
|
|
.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
|
|
/// for password-protected channels — pass an empty string when not
|
|
/// required.
|
|
pub async fn move_to_channel(channel_id: u64, password: String) -> Result<(), BridgeError> {
|
|
let pw = if password.is_empty() {
|
|
None
|
|
} else {
|
|
Some(password)
|
|
};
|
|
runtime()
|
|
.spawn(async move { session().move_to_channel(channel_id, pw).await })
|
|
.await
|
|
.map_err(|e| task_join_error("move_to_channel", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Toggle self input-mute (microphone) on the server. Independent
|
|
/// of push-to-talk: a muted client never transmits regardless of
|
|
/// PTT state.
|
|
pub async fn set_input_muted(muted: bool) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_self_muted(Some(muted), None).await })
|
|
.await
|
|
.map_err(|e| task_join_error("set_input_muted", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Toggle self output-mute (speaker). Mutes locally *and* informs
|
|
/// the server. The server uses this for the channel icon next to
|
|
/// the client name; the local mute kicks in immediately even
|
|
/// before the server acknowledges.
|
|
pub async fn set_output_muted(muted: bool) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_self_muted(None, Some(muted)).await })
|
|
.await
|
|
.map_err(|e| task_join_error("set_output_muted", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set master output gain. `1.0` is unity, `0.0` is silent. Values
|
|
/// above `1.0` amplify and can clip downstream. Errors when audio
|
|
/// is not started.
|
|
pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_output_gain(gain).await })
|
|
.await
|
|
.map_err(|e| task_join_error("set_output_gain", e))??;
|
|
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 {
|
|
/// Number of Opus frames sent since audio started.
|
|
pub frames_sent: u32,
|
|
/// Number of inbound voice packets decoded.
|
|
pub frames_received: u32,
|
|
/// Current push-to-talk state.
|
|
pub ptt_active: bool,
|
|
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
|
|
pub input_level: f32,
|
|
}
|
|
|
|
/// Bridge route class for P1 audio-processing policy.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum BridgeAudioRoute {
|
|
/// Built-in speakerphone.
|
|
Speaker,
|
|
/// Built-in receiver/earpiece.
|
|
Earpiece,
|
|
/// Wired or USB headset.
|
|
WiredHeadset,
|
|
/// Bluetooth HFP duplex route.
|
|
BluetoothHfp,
|
|
/// Bluetooth A2DP output-only route.
|
|
BluetoothA2dp,
|
|
/// Unknown route.
|
|
Unknown,
|
|
}
|
|
|
|
/// Bridge iOS voice-processing mode.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum BridgeIosVoiceProcessingMode {
|
|
/// Shipping VPIO path.
|
|
PlatformVoiceProcessing,
|
|
/// Experimental Sonora path.
|
|
SonoraExperimental,
|
|
}
|
|
|
|
/// Bridge processing backend.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum BridgeAudioBackend {
|
|
/// Platform voice processing.
|
|
PlatformVoiceProcessing,
|
|
/// Sonora backend.
|
|
Sonora,
|
|
/// WebRTC APM backend.
|
|
WebrtcApm,
|
|
/// No-op backend.
|
|
Noop,
|
|
}
|
|
|
|
/// Bridge VAD backend.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum BridgeVadBackend {
|
|
/// Silero ONNX VAD.
|
|
SileroOnnx,
|
|
/// WebRTC fallback VAD.
|
|
WebrtcVad,
|
|
/// Debug energy VAD.
|
|
EnergyDebug,
|
|
/// VAD disabled.
|
|
Disabled,
|
|
}
|
|
|
|
/// Bridge effect owner for AEC/NS/AGC.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum BridgeEffectOwner {
|
|
/// Platform-owned effect.
|
|
Platform,
|
|
/// Sonora-owned effect.
|
|
Sonora,
|
|
/// WebRTC APM-owned effect.
|
|
WebrtcApm,
|
|
/// Conservative route-managed setting.
|
|
Conservative,
|
|
/// Disabled.
|
|
Off,
|
|
}
|
|
|
|
/// P1 audio-processing configuration DTO.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BridgeAudioProcessingConfig {
|
|
/// Route class.
|
|
pub route: BridgeAudioRoute,
|
|
/// iOS voice-processing mode.
|
|
pub ios_mode: BridgeIosVoiceProcessingMode,
|
|
/// Processing backend.
|
|
pub processing_backend: BridgeAudioBackend,
|
|
/// VAD backend.
|
|
pub vad_backend: BridgeVadBackend,
|
|
/// AEC owner.
|
|
pub aec: BridgeEffectOwner,
|
|
/// Noise suppression owner.
|
|
pub ns: BridgeEffectOwner,
|
|
/// AGC owner.
|
|
pub agc: BridgeEffectOwner,
|
|
/// High-pass filter enabled.
|
|
pub hpf_enabled: bool,
|
|
/// Limiter enabled.
|
|
pub limiter_enabled: bool,
|
|
/// VAD hangover in ms.
|
|
pub vad_hangover_ms: u32,
|
|
/// VAD pre-roll in ms.
|
|
pub vad_pre_roll_ms: u32,
|
|
/// Minimum transmit duration in ms.
|
|
pub vad_min_tx_ms: u32,
|
|
/// Debug WAV dump enabled.
|
|
pub debug_wav_dump_enabled: bool,
|
|
}
|
|
|
|
/// P1 audio-processing stats DTO.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BridgeAudioProcessingStats {
|
|
/// Input dBFS.
|
|
pub input_dbfs: f32,
|
|
/// Render dBFS.
|
|
pub render_dbfs: f32,
|
|
/// Processed capture dBFS.
|
|
pub processed_dbfs: f32,
|
|
/// Latest VAD probability.
|
|
pub vad_probability: f32,
|
|
/// VAD active.
|
|
pub vad_active: bool,
|
|
/// Currently transmitting.
|
|
pub transmitting: bool,
|
|
/// VAD backend.
|
|
pub vad_backend: BridgeVadBackend,
|
|
/// Fallback VAD active.
|
|
pub vad_fallback_active: bool,
|
|
/// Processing backend.
|
|
pub processing_backend: BridgeAudioBackend,
|
|
/// iOS voice-processing mode.
|
|
pub ios_voice_processing_mode: BridgeIosVoiceProcessingMode,
|
|
/// Audio route.
|
|
pub audio_route: BridgeAudioRoute,
|
|
/// Actual sample rate.
|
|
pub actual_sample_rate_hz: u32,
|
|
/// Actual IO buffer frames.
|
|
pub actual_io_buffer_frames: u32,
|
|
/// Input overruns.
|
|
pub input_overruns: u64,
|
|
/// Output underruns.
|
|
pub output_underruns: u64,
|
|
/// Callback xruns.
|
|
pub callback_xruns: u64,
|
|
/// Clipped samples.
|
|
pub clipped_samples: u64,
|
|
/// Number of effectively silent processed capture frames.
|
|
pub zero_frames: u64,
|
|
/// Number of processed capture frames.
|
|
pub capture_frames: u64,
|
|
/// Input callbacks carrying 10 ms of audio.
|
|
pub callbacks_10ms: u64,
|
|
/// Input callbacks carrying 20 ms of audio.
|
|
pub callbacks_20ms: u64,
|
|
/// Input callbacks carrying other sizes.
|
|
pub callbacks_other: u64,
|
|
/// Sonora enabled.
|
|
pub sonora_enabled: bool,
|
|
/// Platform voice processing enabled.
|
|
pub platform_voice_processing_enabled: bool,
|
|
}
|
|
|
|
impl From<BridgeAudioRoute> for chanora_core::AudioRoute {
|
|
fn from(route: BridgeAudioRoute) -> Self {
|
|
match route {
|
|
BridgeAudioRoute::Speaker => Self::Speaker,
|
|
BridgeAudioRoute::Earpiece => Self::Earpiece,
|
|
BridgeAudioRoute::WiredHeadset => Self::WiredHeadset,
|
|
BridgeAudioRoute::BluetoothHfp => Self::BluetoothHfp,
|
|
BridgeAudioRoute::BluetoothA2dp => Self::BluetoothA2dp,
|
|
BridgeAudioRoute::Unknown => Self::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<chanora_core::AudioRoute> for BridgeAudioRoute {
|
|
fn from(route: chanora_core::AudioRoute) -> Self {
|
|
match route {
|
|
chanora_core::AudioRoute::Speaker => Self::Speaker,
|
|
chanora_core::AudioRoute::Earpiece => Self::Earpiece,
|
|
chanora_core::AudioRoute::WiredHeadset => Self::WiredHeadset,
|
|
chanora_core::AudioRoute::BluetoothHfp => Self::BluetoothHfp,
|
|
chanora_core::AudioRoute::BluetoothA2dp => Self::BluetoothA2dp,
|
|
chanora_core::AudioRoute::Unknown => Self::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<BridgeIosVoiceProcessingMode> for chanora_core::IosVoiceProcessingMode {
|
|
fn from(mode: BridgeIosVoiceProcessingMode) -> Self {
|
|
match mode {
|
|
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
|
BridgeIosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<chanora_core::IosVoiceProcessingMode> for BridgeIosVoiceProcessingMode {
|
|
fn from(mode: chanora_core::IosVoiceProcessingMode) -> Self {
|
|
match mode {
|
|
chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => {
|
|
Self::PlatformVoiceProcessing
|
|
}
|
|
chanora_core::IosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<BridgeAudioBackend> for chanora_core::AudioBackend {
|
|
fn from(backend: BridgeAudioBackend) -> Self {
|
|
match backend {
|
|
BridgeAudioBackend::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
|
BridgeAudioBackend::Sonora => Self::Sonora,
|
|
BridgeAudioBackend::WebrtcApm => Self::WebrtcApm,
|
|
BridgeAudioBackend::Noop => Self::Noop,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<chanora_core::AudioBackend> for BridgeAudioBackend {
|
|
fn from(backend: chanora_core::AudioBackend) -> Self {
|
|
match backend {
|
|
chanora_core::AudioBackend::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
|
chanora_core::AudioBackend::Sonora => Self::Sonora,
|
|
chanora_core::AudioBackend::WebrtcApm => Self::WebrtcApm,
|
|
chanora_core::AudioBackend::Noop => Self::Noop,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<BridgeVadBackend> for chanora_core::VadBackend {
|
|
fn from(backend: BridgeVadBackend) -> Self {
|
|
match backend {
|
|
BridgeVadBackend::SileroOnnx => Self::SileroOnnx,
|
|
BridgeVadBackend::WebrtcVad => Self::WebrtcVad,
|
|
BridgeVadBackend::EnergyDebug => Self::EnergyDebug,
|
|
BridgeVadBackend::Disabled => Self::Disabled,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<chanora_core::VadBackend> for BridgeVadBackend {
|
|
fn from(backend: chanora_core::VadBackend) -> Self {
|
|
match backend {
|
|
chanora_core::VadBackend::SileroOnnx => Self::SileroOnnx,
|
|
chanora_core::VadBackend::WebrtcVad => Self::WebrtcVad,
|
|
chanora_core::VadBackend::EnergyDebug => Self::EnergyDebug,
|
|
chanora_core::VadBackend::Disabled => Self::Disabled,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<BridgeEffectOwner> for chanora_core::EffectOwner {
|
|
fn from(owner: BridgeEffectOwner) -> Self {
|
|
match owner {
|
|
BridgeEffectOwner::Platform => Self::Platform,
|
|
BridgeEffectOwner::Sonora => Self::Sonora,
|
|
BridgeEffectOwner::WebrtcApm => Self::WebrtcApm,
|
|
BridgeEffectOwner::Conservative => Self::Conservative,
|
|
BridgeEffectOwner::Off => Self::Off,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<chanora_core::EffectOwner> for BridgeEffectOwner {
|
|
fn from(owner: chanora_core::EffectOwner) -> Self {
|
|
match owner {
|
|
chanora_core::EffectOwner::Platform => Self::Platform,
|
|
chanora_core::EffectOwner::Sonora => Self::Sonora,
|
|
chanora_core::EffectOwner::WebrtcApm => Self::WebrtcApm,
|
|
chanora_core::EffectOwner::Conservative => Self::Conservative,
|
|
chanora_core::EffectOwner::Off => Self::Off,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<BridgeAudioProcessingConfig> for chanora_core::AudioProcessingConfig {
|
|
fn from(config: BridgeAudioProcessingConfig) -> Self {
|
|
Self {
|
|
route: config.route.into(),
|
|
ios_mode: config.ios_mode.into(),
|
|
processing_backend: config.processing_backend.into(),
|
|
vad_backend: config.vad_backend.into(),
|
|
aec: config.aec.into(),
|
|
ns: config.ns.into(),
|
|
agc: config.agc.into(),
|
|
hpf_enabled: config.hpf_enabled,
|
|
limiter_enabled: config.limiter_enabled,
|
|
vad_hangover_ms: config.vad_hangover_ms,
|
|
vad_pre_roll_ms: config.vad_pre_roll_ms,
|
|
vad_min_tx_ms: config.vad_min_tx_ms,
|
|
debug_wav_dump_enabled: config.debug_wav_dump_enabled,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<chanora_core::AudioProcessingConfig> for BridgeAudioProcessingConfig {
|
|
fn from(c: chanora_core::AudioProcessingConfig) -> Self {
|
|
Self {
|
|
route: c.route.into(),
|
|
ios_mode: c.ios_mode.into(),
|
|
processing_backend: c.processing_backend.into(),
|
|
vad_backend: c.vad_backend.into(),
|
|
aec: c.aec.into(),
|
|
ns: c.ns.into(),
|
|
agc: c.agc.into(),
|
|
hpf_enabled: c.hpf_enabled,
|
|
limiter_enabled: c.limiter_enabled,
|
|
vad_hangover_ms: c.vad_hangover_ms,
|
|
vad_pre_roll_ms: c.vad_pre_roll_ms,
|
|
vad_min_tx_ms: c.vad_min_tx_ms,
|
|
debug_wav_dump_enabled: c.debug_wav_dump_enabled,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<chanora_core::AudioProcessingStats> for BridgeAudioProcessingStats {
|
|
fn from(stats: chanora_core::AudioProcessingStats) -> Self {
|
|
Self {
|
|
input_dbfs: stats.input_dbfs,
|
|
render_dbfs: stats.render_dbfs,
|
|
processed_dbfs: stats.processed_dbfs,
|
|
vad_probability: stats.vad_probability,
|
|
vad_active: stats.vad_active,
|
|
transmitting: stats.transmitting,
|
|
vad_backend: stats.vad_backend.into(),
|
|
vad_fallback_active: stats.vad_fallback_active,
|
|
processing_backend: stats.processing_backend.into(),
|
|
ios_voice_processing_mode: stats.ios_voice_processing_mode.into(),
|
|
audio_route: stats.audio_route.into(),
|
|
actual_sample_rate_hz: stats.actual_sample_rate_hz,
|
|
actual_io_buffer_frames: stats.actual_io_buffer_frames,
|
|
input_overruns: stats.input_overruns,
|
|
output_underruns: stats.output_underruns,
|
|
callback_xruns: stats.callback_xruns,
|
|
clipped_samples: stats.clipped_samples,
|
|
zero_frames: stats.zero_frames,
|
|
capture_frames: stats.capture_frames,
|
|
callbacks_10ms: stats.callbacks_10ms,
|
|
callbacks_20ms: stats.callbacks_20ms,
|
|
callbacks_other: stats.callbacks_other,
|
|
sonora_enabled: stats.sonora_enabled,
|
|
platform_voice_processing_enabled: stats.platform_voice_processing_enabled,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- Diagnostics (A.3) ----------
|
|
|
|
/// User-initiated diagnostic export. Returns a multi-line text
|
|
/// blob, redacted per the production policy, that the user can
|
|
/// share or copy. DEC-016 forbids automatic uploads — this is the
|
|
/// only path that surfaces logs.
|
|
///
|
|
/// SDD-112 item 10 / SDD-113 item 7 / SDD-116 item 3: when an
|
|
/// Android voice session is open this export embeds the
|
|
/// `[audio.android]` verification-matrix fragment (requested /
|
|
/// achieved stream config, per-effect engagement, latency tier).
|
|
/// On non-Android targets or before a voice session opens the
|
|
/// section is omitted. Per SDD-090 every field in that section is
|
|
/// a device-side technical scalar — no PII admitted.
|
|
#[frb(sync)]
|
|
pub fn export_diagnostics() -> String {
|
|
let metadata = vec![
|
|
(
|
|
"crate_version".to_string(),
|
|
env!("CARGO_PKG_VERSION").to_string(),
|
|
),
|
|
("target_os".to_string(), std::env::consts::OS.to_string()),
|
|
(
|
|
"target_arch".to_string(),
|
|
std::env::consts::ARCH.to_string(),
|
|
),
|
|
];
|
|
// SDD-116 item 3: pull the latest Android voice-audio
|
|
// diagnostics snapshot from the process-global slot published
|
|
// by AndroidVoiceUnit::open(). Returns None on non-Android and
|
|
// before any voice session has opened.
|
|
let android_audio_yaml =
|
|
chanora_audio::mobile_voice_backend::current_android_audio_diagnostics()
|
|
.map(|d| d.to_yaml_fragment());
|
|
let audio_health = session().audio_processing_stats_if_ready();
|
|
let network_info = runtime().block_on(async { session().network_diagnostics_summary().await });
|
|
let protocol_events = runtime().block_on(async { session().protocol_events_snapshot().await });
|
|
let android_audio_yaml = android_audio_yaml.map(|mut yaml| {
|
|
if let Some(stats) = audio_health {
|
|
yaml.push_str(&format!(
|
|
" health:\n\
|
|
\x20\x20\x20\x20capture_frames: {}\n\
|
|
\x20\x20\x20\x20zero_frames: {}\n\
|
|
\x20\x20\x20\x20callbacks_10ms: {}\n\
|
|
\x20\x20\x20\x20callbacks_20ms: {}\n\
|
|
\x20\x20\x20\x20callbacks_other: {}\n\
|
|
\x20\x20\x20\x20callback_xruns: {}\n\
|
|
\x20\x20\x20\x20clipped_samples: {}\n",
|
|
stats.capture_frames,
|
|
stats.zero_frames,
|
|
stats.callbacks_10ms,
|
|
stats.callbacks_20ms,
|
|
stats.callbacks_other,
|
|
stats.callback_xruns,
|
|
stats.clipped_samples,
|
|
));
|
|
}
|
|
yaml
|
|
});
|
|
match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) {
|
|
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})"),
|
|
}
|
|
}
|
|
|
|
// ---------- Storage (A.2) ----------
|
|
|
|
/// Wire the identity persistence store to a platform-private
|
|
/// directory. Should be called once on app start after Flutter has
|
|
/// resolved `getApplicationSupportDirectory()` (or equivalent).
|
|
///
|
|
/// Subsequent [`connect`] calls will reuse the persisted identity,
|
|
/// or generate-and-persist a fresh one on first use. This keeps the
|
|
/// server-visible UID stable across app restarts.
|
|
///
|
|
/// Beta caveat: the identity is stored as a plain file (mode 0600
|
|
/// on Unix). It is *not* encrypted at rest. RISK-PoC-002 documents
|
|
/// this gap; the v0.4 storage rework lands the proper Secret
|
|
/// Service + Android Keystore + iOS Keychain backends.
|
|
pub async fn init_storage(dir: String) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().init_storage(&dir).await })
|
|
.await
|
|
.map_err(|e| task_join_error("init_storage", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Bookmark DTO mirroring [`chanora_core::Bookmark`].
|
|
#[derive(Debug, Clone)]
|
|
pub struct BridgeBookmark {
|
|
/// Row id assigned by SQLite. Use `0` when adding new rows;
|
|
/// the returned id is then meaningful.
|
|
pub id: i64,
|
|
/// User-facing label.
|
|
pub display_name: String,
|
|
/// `hostname[:port]` or TSDNS name.
|
|
pub host: String,
|
|
/// Nickname to use for this bookmark.
|
|
pub nickname: String,
|
|
/// Optional remembered password. Empty string = none.
|
|
pub password: String,
|
|
}
|
|
|
|
impl From<chanora_core::Bookmark> for BridgeBookmark {
|
|
fn from(b: chanora_core::Bookmark) -> Self {
|
|
Self {
|
|
id: b.id,
|
|
display_name: b.display_name,
|
|
host: b.host,
|
|
nickname: b.nickname,
|
|
password: b.password.unwrap_or_default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<BridgeBookmark> for chanora_core::Bookmark {
|
|
fn from(b: BridgeBookmark) -> Self {
|
|
chanora_core::Bookmark {
|
|
id: b.id,
|
|
display_name: b.display_name,
|
|
host: b.host,
|
|
nickname: b.nickname,
|
|
password: if b.password.is_empty() {
|
|
None
|
|
} else {
|
|
Some(b.password)
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// List persisted bookmarks.
|
|
pub async fn list_bookmarks() -> Result<Vec<BridgeBookmark>, BridgeError> {
|
|
let v = runtime()
|
|
.spawn(async { session().list_bookmarks().await })
|
|
.await
|
|
.map_err(|e| task_join_error("list_bookmarks", e))??;
|
|
Ok(v.into_iter().map(Into::into).collect())
|
|
}
|
|
|
|
/// Insert a bookmark and return its assigned id. The `id` field on
|
|
/// the input is ignored.
|
|
pub async fn add_bookmark(b: BridgeBookmark) -> Result<i64, BridgeError> {
|
|
let core_b: chanora_core::Bookmark = b.into();
|
|
let id = runtime()
|
|
.spawn(async move { session().add_bookmark(core_b).await })
|
|
.await
|
|
.map_err(|e| task_join_error("add_bookmark", e))??;
|
|
Ok(id)
|
|
}
|
|
|
|
/// Update an existing bookmark.
|
|
pub async fn update_bookmark(b: BridgeBookmark) -> Result<(), BridgeError> {
|
|
let core_b: chanora_core::Bookmark = b.into();
|
|
runtime()
|
|
.spawn(async move { session().update_bookmark(core_b).await })
|
|
.await
|
|
.map_err(|e| task_join_error("update_bookmark", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a bookmark by id.
|
|
pub async fn delete_bookmark(id: i64) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().delete_bookmark(id).await })
|
|
.await
|
|
.map_err(|e| task_join_error("delete_bookmark", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
// ---------- Connectivity (A.6.1) ----------
|
|
|
|
/// Coarse OS-reported network state. Mirrors
|
|
/// [`chanora_core::NetworkState`] across the bridge.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum BridgeNetworkState {
|
|
/// No signal seen yet.
|
|
Unknown,
|
|
/// OS reports a usable network.
|
|
Online,
|
|
/// OS reports no network.
|
|
Offline,
|
|
}
|
|
|
|
impl From<BridgeNetworkState> for chanora_core::NetworkState {
|
|
fn from(s: BridgeNetworkState) -> Self {
|
|
match s {
|
|
BridgeNetworkState::Unknown => chanora_core::NetworkState::Unknown,
|
|
BridgeNetworkState::Online => chanora_core::NetworkState::Online,
|
|
BridgeNetworkState::Offline => chanora_core::NetworkState::Offline,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Notify the core of the latest OS-reported connectivity state.
|
|
/// Called by the Flutter side from `connectivity_plus` callbacks.
|
|
/// The core's supervisor uses this to (a) pre-charge the watchdog
|
|
/// on Offline and (b) short-circuit reconnect backoff on Online.
|
|
#[frb(sync)]
|
|
pub fn set_network_state(state: BridgeNetworkState) {
|
|
session().set_network_state(state.into());
|
|
}
|
|
|
|
// ---------- Events (A.6) ----------
|
|
|
|
/// Lifecycle event surfaced to Dart. Schema-controlled mirror of
|
|
/// [`chanora_core::SessionEvent`] — no core types cross the bridge.
|
|
#[derive(Debug, Clone)]
|
|
pub enum BridgeEvent {
|
|
/// Initial connect succeeded, or a reconnect attempt succeeded.
|
|
Connected {
|
|
/// Server name reported by the server snapshot.
|
|
server_name: String,
|
|
},
|
|
/// Connection lost; supervisor will retry.
|
|
Lost {
|
|
/// Reason classification from the protocol layer.
|
|
reason: String,
|
|
},
|
|
/// Supervisor is sleeping before its next reconnect attempt.
|
|
Reconnecting {
|
|
/// 1-based attempt counter for the current outage.
|
|
attempt: u32,
|
|
/// Seconds the supervisor will sleep before this attempt.
|
|
delay_secs: u32,
|
|
},
|
|
/// Session ended (user-requested disconnect or unrecoverable).
|
|
Disconnected {
|
|
/// Reason classification.
|
|
reason: String,
|
|
},
|
|
/// Audio engine started.
|
|
AudioStarted,
|
|
/// Audio engine stopped.
|
|
AudioStopped,
|
|
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3,
|
|
/// DEC-023..028). The fields carry only privacy-safe values per
|
|
/// DEC-027: the capability level, a stable backend identifier,
|
|
/// and the bound input class. No key codes, scan codes, or
|
|
/// virtual-key values cross this boundary.
|
|
PttCapability {
|
|
/// Stable capability identifier (`"L0Focused"`,
|
|
/// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`,
|
|
/// `"L3GlobalWithMouseButtons"`, or `"L4DeviceAware"`).
|
|
level: String,
|
|
/// Stable backend identifier (e.g. `"focused"`).
|
|
backend_id: String,
|
|
/// Coarse bound input class (e.g. `"keyboard"`,
|
|
/// `"mouse-side-button"`); empty when no binding is active.
|
|
bound_input_class: String,
|
|
},
|
|
/// Voice subsystem state snapshot (SDD-094). The Flutter
|
|
/// VoiceBar listens to this stream.
|
|
VoiceState {
|
|
/// True when the session is currently joined to a voice
|
|
/// channel and the audio engine is running.
|
|
in_channel: bool,
|
|
/// Active transmit mode.
|
|
transmit_mode: BridgeTransmitMode,
|
|
/// True when the hard-mute clamp is engaged.
|
|
mute: bool,
|
|
/// Current release-tail in milliseconds (0..=500).
|
|
release_tail_ms: u32,
|
|
/// Last confirmed authoritative channel id.
|
|
current_channel_id: Option<u64>,
|
|
/// Non-authoritative pending join target channel id.
|
|
pending_target_channel_id: Option<u64>,
|
|
/// Whether a new join intent is currently allowed.
|
|
can_join: bool,
|
|
/// Whether leave intent is currently allowed.
|
|
can_leave: bool,
|
|
/// Join projection sync state.
|
|
join_sync_state: BridgeVoiceJoinSyncState,
|
|
/// Last stable join error code, if any.
|
|
join_error_code: Option<BridgeVoiceJoinErrorCode>,
|
|
},
|
|
/// iOS audio interruption state (SDD-101).
|
|
InterruptionState {
|
|
/// True when interruption began, false when it ended.
|
|
began: bool,
|
|
/// Resume recommendation from the platform. False on begin.
|
|
should_resume: bool,
|
|
},
|
|
/// SDD-106 §5: resolved platform-level permission state. On
|
|
/// Android this is published by the JNI hook in
|
|
/// [`crate::permission_jni`] whenever
|
|
/// `AndroidPermissionRequester` reports a state transition
|
|
/// (initial grant, denial, permanent denial, or mid-session
|
|
/// revocation). The audio engine's `TransmitModeSelector`
|
|
/// observes the `RECORD_AUDIO` variant of this event as an
|
|
/// authoritative clamp on the transmit gate per SDD-106 §6
|
|
/// (and SRS-209's listen-only fail-safe).
|
|
///
|
|
/// Carries the canonical Android permission string in
|
|
/// `permission` (e.g. `"android.permission.RECORD_AUDIO"`)
|
|
/// and the resolved state in `state`. No raw user input,
|
|
/// timestamps, or other PII cross this boundary.
|
|
PermissionState {
|
|
/// Canonical Android permission identifier.
|
|
permission: String,
|
|
/// 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,
|
|
/// Poke notification strength, present only for poke messages.
|
|
poke_strength: Option<BridgePokeStrength>,
|
|
},
|
|
/// Human-readable server activity surfaced from protocol bookkeeping events.
|
|
ServerActivity {
|
|
/// TeamSpeak-style activity line.
|
|
message: String,
|
|
},
|
|
/// Audio route changed (speaker/earpiece/BT/wired).
|
|
AudioRouteChanged {
|
|
/// New audio output route.
|
|
route: BridgeAudioRoute,
|
|
},
|
|
/// A client moved to a different channel.
|
|
ClientMoved {
|
|
/// Unique client identifier.
|
|
client_id: u64,
|
|
/// Destination channel.
|
|
new_channel_id: u64,
|
|
},
|
|
/// A new client connected.
|
|
ClientJoined {
|
|
/// Unique client identifier.
|
|
client_id: u64,
|
|
/// Channel the client joined.
|
|
channel_id: u64,
|
|
/// Display nickname.
|
|
name: String,
|
|
/// Microphone muted state.
|
|
input_muted: bool,
|
|
/// Speaker muted state.
|
|
output_muted: bool,
|
|
/// True for server query (bot) clients.
|
|
is_server_query: bool,
|
|
/// Client's talk power value.
|
|
talk_power: i32,
|
|
/// Whether the server granted temporary talk power.
|
|
talk_power_granted: bool,
|
|
},
|
|
/// A client disconnected.
|
|
ClientLeft {
|
|
/// Unique client identifier.
|
|
client_id: u64,
|
|
/// Display nickname at time of disconnect.
|
|
name: String,
|
|
},
|
|
/// Client properties changed.
|
|
ClientUpdated {
|
|
/// Unique client identifier.
|
|
client_id: u64,
|
|
/// Microphone muted state.
|
|
input_muted: bool,
|
|
/// Speaker muted state.
|
|
output_muted: bool,
|
|
/// True for server query (bot) clients.
|
|
is_server_query: bool,
|
|
/// Client's talk power value.
|
|
talk_power: i32,
|
|
/// Whether the server granted temporary talk power.
|
|
talk_power_granted: bool,
|
|
},
|
|
/// A new channel appeared.
|
|
ChannelAdded {
|
|
/// Unique channel identifier.
|
|
id: u64,
|
|
/// Parent channel ID.
|
|
parent: u64,
|
|
/// Channel name.
|
|
name: String,
|
|
/// Predecessor channel ID within the same parent (TeamSpeak
|
|
/// linked-list ordering hint). Zero means first child.
|
|
order: i64,
|
|
/// Whether the channel requires a password.
|
|
has_password: bool,
|
|
/// Talk power required to speak; `None` means no restriction.
|
|
needed_talk_power: Option<i32>,
|
|
},
|
|
/// A channel was deleted.
|
|
ChannelRemoved {
|
|
/// Channel identifier.
|
|
id: u64,
|
|
},
|
|
/// Channel properties changed.
|
|
ChannelUpdated {
|
|
/// Unique channel identifier.
|
|
id: u64,
|
|
/// Channel name.
|
|
name: String,
|
|
/// Whether the channel requires a password.
|
|
has_password: bool,
|
|
/// Talk power required to speak; `None` means no restriction.
|
|
needed_talk_power: Option<i32>,
|
|
},
|
|
}
|
|
|
|
/// 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 poke notification strength.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum BridgePokeStrength {
|
|
/// Poke should be surfaced at full strength.
|
|
Strong,
|
|
/// Poke is rate-limited but below overflow severity.
|
|
Suppressed,
|
|
/// Poke remains suppressed after repeated suppressed pokes.
|
|
SuppressedOverflow,
|
|
}
|
|
|
|
impl From<chanora_core::PokeStrength> for BridgePokeStrength {
|
|
fn from(strength: chanora_core::PokeStrength) -> Self {
|
|
match strength {
|
|
chanora_core::PokeStrength::Strong => Self::Strong,
|
|
chanora_core::PokeStrength::Suppressed => Self::Suppressed,
|
|
chanora_core::PokeStrength::SuppressedOverflow => Self::SuppressedOverflow,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Bridge mirror of core join projection sync state.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum BridgeVoiceJoinSyncState {
|
|
/// Reducer is ready to accept channel actions.
|
|
Ready,
|
|
/// Reducer is waiting on initial snapshot reconciliation.
|
|
SynchronizingInitialSnapshot,
|
|
/// Reducer is waiting on reconnect snapshot reconciliation.
|
|
SynchronizingReconnect,
|
|
}
|
|
|
|
/// Bridge mirror of stable join error/status codes.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum BridgeVoiceJoinErrorCode {
|
|
/// Duplicate same-target join intent was coalesced.
|
|
DuplicateSameTargetCoalesced,
|
|
/// A different target was requested while one is already pending.
|
|
JoinAlreadyPendingDifferentTarget,
|
|
/// Join denied by server policy/permission.
|
|
JoinDenied,
|
|
/// Join failed due to protocol-level error.
|
|
JoinProtocolFailure,
|
|
/// Join failed due to transport/network error.
|
|
JoinNetworkFailure,
|
|
/// Join timed out awaiting confirmation.
|
|
JoinTimeout,
|
|
/// Pending join was superseded by user leave.
|
|
JoinSupersededByLeave,
|
|
/// Stale join outcome was ignored.
|
|
JoinStaleOutcomeIgnored,
|
|
/// Authoritative membership reconciled to different channel.
|
|
JoinReconciledDifferentChannel,
|
|
/// Join command was rejected before send acceptance.
|
|
JoinCommandRejectedBeforeSend,
|
|
/// Join intent rejected while reducer synchronizing.
|
|
JoinCannotStartWhileSynchronizing,
|
|
}
|
|
|
|
/// Schema-controlled mirror of the Kotlin
|
|
/// `AndroidPermissionRequester.PermissionState` sealed class
|
|
/// (SDD-106 §5). Crosses the bridge as an enum so the Dart side can
|
|
/// `switch` on it exhaustively without parsing strings.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum PermissionStateKind {
|
|
/// Permission granted by the user; capture may proceed.
|
|
Granted,
|
|
/// Permission denied (re-promptable).
|
|
Denied,
|
|
/// Permission permanently denied — the UI is expected to
|
|
/// deep-link to system settings (SDD-106 §3).
|
|
PermanentlyDenied,
|
|
/// Any state string that did not match the contract above.
|
|
/// Treated identically to `Denied` by the transmit clamp
|
|
/// (fail-safe per SRS-209).
|
|
Unknown,
|
|
}
|
|
|
|
impl PermissionStateKind {
|
|
/// Map the Kotlin-side `PermissionState.toString()` value to
|
|
/// the bridge enum. Unrecognised strings fall back to
|
|
/// [`PermissionStateKind::Unknown`].
|
|
#[frb(ignore)]
|
|
pub fn from_kotlin_str(s: &str) -> Self {
|
|
match s {
|
|
"Granted" => Self::Granted,
|
|
"Denied" => Self::Denied,
|
|
"PermanentlyDenied" => Self::PermanentlyDenied,
|
|
_ => Self::Unknown,
|
|
}
|
|
}
|
|
|
|
/// Mirror into the audio-engine clamp type (SDD-106 §6).
|
|
#[frb(ignore)]
|
|
pub fn to_permission_gate(self) -> chanora_audio::PermissionGate {
|
|
match self {
|
|
Self::Granted => chanora_audio::PermissionGate::Granted,
|
|
Self::Denied => chanora_audio::PermissionGate::Denied,
|
|
Self::PermanentlyDenied => chanora_audio::PermissionGate::PermanentlyDenied,
|
|
Self::Unknown => chanora_audio::PermissionGate::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<chanora_core::SessionEvent> for BridgeEvent {
|
|
fn from(e: chanora_core::SessionEvent) -> Self {
|
|
match e {
|
|
chanora_core::SessionEvent::Connected { server_name } => {
|
|
BridgeEvent::Connected { server_name }
|
|
}
|
|
chanora_core::SessionEvent::Lost { reason } => BridgeEvent::Lost { reason },
|
|
chanora_core::SessionEvent::Reconnecting {
|
|
attempt,
|
|
delay_secs,
|
|
} => BridgeEvent::Reconnecting {
|
|
attempt,
|
|
delay_secs,
|
|
},
|
|
chanora_core::SessionEvent::Disconnected { reason } => {
|
|
BridgeEvent::Disconnected { reason }
|
|
}
|
|
chanora_core::SessionEvent::AudioStarted => BridgeEvent::AudioStarted,
|
|
chanora_core::SessionEvent::AudioStopped => BridgeEvent::AudioStopped,
|
|
chanora_core::SessionEvent::PttCapability {
|
|
level,
|
|
backend_id,
|
|
bound_input_class,
|
|
} => BridgeEvent::PttCapability {
|
|
level,
|
|
backend_id,
|
|
bound_input_class,
|
|
},
|
|
chanora_core::SessionEvent::VoiceState {
|
|
in_channel,
|
|
transmit_mode,
|
|
mute,
|
|
release_tail_ms,
|
|
current_channel_id,
|
|
pending_target_channel_id,
|
|
can_join,
|
|
can_leave,
|
|
join_sync_state,
|
|
join_error_code,
|
|
} => BridgeEvent::VoiceState {
|
|
in_channel,
|
|
transmit_mode: transmit_mode_from_u8(transmit_mode),
|
|
mute,
|
|
release_tail_ms,
|
|
current_channel_id,
|
|
pending_target_channel_id,
|
|
can_join,
|
|
can_leave,
|
|
join_sync_state: map_join_sync_state(join_sync_state),
|
|
join_error_code: join_error_code.map(map_join_error_code),
|
|
},
|
|
chanora_core::SessionEvent::InterruptionState {
|
|
began,
|
|
should_resume,
|
|
} => BridgeEvent::InterruptionState {
|
|
began,
|
|
should_resume,
|
|
},
|
|
chanora_core::SessionEvent::ChatMessage {
|
|
sender_id,
|
|
sender_name,
|
|
message,
|
|
target,
|
|
poke_strength,
|
|
} => BridgeEvent::ChatMessage {
|
|
sender_id,
|
|
sender_name,
|
|
message,
|
|
target: target.into(),
|
|
poke_strength: poke_strength.map(Into::into),
|
|
},
|
|
chanora_core::SessionEvent::ServerActivity { message } => {
|
|
BridgeEvent::ServerActivity { message }
|
|
}
|
|
chanora_core::SessionEvent::AudioRouteChanged { route } => {
|
|
BridgeEvent::AudioRouteChanged {
|
|
route: route.into(),
|
|
}
|
|
}
|
|
chanora_core::SessionEvent::ClientMoved {
|
|
client_id,
|
|
new_channel_id,
|
|
} => BridgeEvent::ClientMoved {
|
|
client_id,
|
|
new_channel_id,
|
|
},
|
|
chanora_core::SessionEvent::ClientJoined {
|
|
client_id,
|
|
channel_id,
|
|
name,
|
|
input_muted,
|
|
output_muted,
|
|
is_server_query,
|
|
talk_power,
|
|
talk_power_granted,
|
|
} => BridgeEvent::ClientJoined {
|
|
client_id,
|
|
channel_id,
|
|
name,
|
|
input_muted,
|
|
output_muted,
|
|
is_server_query,
|
|
talk_power,
|
|
talk_power_granted,
|
|
},
|
|
chanora_core::SessionEvent::ClientLeft { client_id, name } => {
|
|
BridgeEvent::ClientLeft { client_id, name }
|
|
}
|
|
chanora_core::SessionEvent::ClientUpdated {
|
|
client_id,
|
|
input_muted,
|
|
output_muted,
|
|
is_server_query,
|
|
talk_power,
|
|
talk_power_granted,
|
|
} => BridgeEvent::ClientUpdated {
|
|
client_id,
|
|
input_muted,
|
|
output_muted,
|
|
is_server_query,
|
|
talk_power,
|
|
talk_power_granted,
|
|
},
|
|
chanora_core::SessionEvent::ChannelAdded {
|
|
id,
|
|
parent,
|
|
name,
|
|
order,
|
|
has_password,
|
|
needed_talk_power,
|
|
} => BridgeEvent::ChannelAdded {
|
|
id,
|
|
parent,
|
|
name,
|
|
order,
|
|
has_password,
|
|
needed_talk_power,
|
|
},
|
|
chanora_core::SessionEvent::ChannelRemoved { id } => BridgeEvent::ChannelRemoved { id },
|
|
chanora_core::SessionEvent::ChannelUpdated {
|
|
id,
|
|
name,
|
|
has_password,
|
|
needed_talk_power,
|
|
} => BridgeEvent::ChannelUpdated {
|
|
id,
|
|
name,
|
|
has_password,
|
|
needed_talk_power,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
fn map_join_sync_state(state: chanora_core::VoiceJoinSyncState) -> BridgeVoiceJoinSyncState {
|
|
match state {
|
|
chanora_core::VoiceJoinSyncState::Ready => BridgeVoiceJoinSyncState::Ready,
|
|
chanora_core::VoiceJoinSyncState::SynchronizingInitialSnapshot => {
|
|
BridgeVoiceJoinSyncState::SynchronizingInitialSnapshot
|
|
}
|
|
chanora_core::VoiceJoinSyncState::SynchronizingReconnect => {
|
|
BridgeVoiceJoinSyncState::SynchronizingReconnect
|
|
}
|
|
}
|
|
}
|
|
|
|
fn map_join_error_code(code: chanora_core::VoiceJoinErrorCode) -> BridgeVoiceJoinErrorCode {
|
|
match code {
|
|
chanora_core::VoiceJoinErrorCode::DuplicateSameTargetCoalesced => {
|
|
BridgeVoiceJoinErrorCode::DuplicateSameTargetCoalesced
|
|
}
|
|
chanora_core::VoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget => {
|
|
BridgeVoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget
|
|
}
|
|
chanora_core::VoiceJoinErrorCode::JoinDenied => BridgeVoiceJoinErrorCode::JoinDenied,
|
|
chanora_core::VoiceJoinErrorCode::JoinProtocolFailure => {
|
|
BridgeVoiceJoinErrorCode::JoinProtocolFailure
|
|
}
|
|
chanora_core::VoiceJoinErrorCode::JoinNetworkFailure => {
|
|
BridgeVoiceJoinErrorCode::JoinNetworkFailure
|
|
}
|
|
chanora_core::VoiceJoinErrorCode::JoinTimeout => BridgeVoiceJoinErrorCode::JoinTimeout,
|
|
chanora_core::VoiceJoinErrorCode::JoinSupersededByLeave => {
|
|
BridgeVoiceJoinErrorCode::JoinSupersededByLeave
|
|
}
|
|
chanora_core::VoiceJoinErrorCode::JoinStaleOutcomeIgnored => {
|
|
BridgeVoiceJoinErrorCode::JoinStaleOutcomeIgnored
|
|
}
|
|
chanora_core::VoiceJoinErrorCode::JoinReconciledDifferentChannel => {
|
|
BridgeVoiceJoinErrorCode::JoinReconciledDifferentChannel
|
|
}
|
|
chanora_core::VoiceJoinErrorCode::JoinCommandRejectedBeforeSend => {
|
|
BridgeVoiceJoinErrorCode::JoinCommandRejectedBeforeSend
|
|
}
|
|
chanora_core::VoiceJoinErrorCode::JoinCannotStartWhileSynchronizing => {
|
|
BridgeVoiceJoinErrorCode::JoinCannotStartWhileSynchronizing
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Subscribe to lifecycle events. Each call yields a fresh
|
|
/// subscription; multiple subscribers are supported. On slow
|
|
/// consumers, events are dropped rather than blocking the supervisor
|
|
/// (consistent with `tokio::sync::broadcast::Receiver` semantics).
|
|
pub fn events_stream(sink: StreamSink<BridgeEvent>) -> Result<(), BridgeError> {
|
|
let mut rx = session().subscribe_events();
|
|
let mut perm_rx = permission_events().subscribe();
|
|
runtime().spawn(async move {
|
|
loop {
|
|
tokio::select! {
|
|
core_evt = rx.recv() => match core_evt {
|
|
Ok(evt) => {
|
|
if sink.add(BridgeEvent::from(evt)).is_err() {
|
|
info!(target: "chanora_bridge", "events_stream: dart sink closed");
|
|
return;
|
|
}
|
|
}
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
warn!(target: "chanora_bridge", "events_stream: lagged, dropped {n} events");
|
|
continue;
|
|
}
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
|
info!(target: "chanora_bridge", "events_stream: source closed");
|
|
return;
|
|
}
|
|
},
|
|
// SDD-106 §5: forward platform permission events
|
|
// onto the same Dart-facing sink so subscribers see
|
|
// a unified stream.
|
|
perm_evt = perm_rx.recv() => match perm_evt {
|
|
Ok(evt) => {
|
|
if sink.add(evt).is_err() {
|
|
info!(target: "chanora_bridge", "events_stream: dart sink closed");
|
|
return;
|
|
}
|
|
}
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
warn!(target: "chanora_bridge", "events_stream: permission stream lagged, dropped {n} events");
|
|
continue;
|
|
}
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
|
// Permission channel never closes (static OnceLock sender),
|
|
// but handle defensively.
|
|
info!(target: "chanora_bridge", "events_stream: permission source closed");
|
|
return;
|
|
}
|
|
},
|
|
}
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// Read audio statistics. Errors if no connection or audio not started.
|
|
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
|
let (s, r, p, lvl) = runtime()
|
|
.spawn(async { session().audio_stats().await })
|
|
.await
|
|
.map_err(|e| task_join_error("audio_stats", e))??;
|
|
Ok(BridgeAudioStats {
|
|
frames_sent: s,
|
|
frames_received: r,
|
|
ptt_active: p,
|
|
input_level: lvl,
|
|
})
|
|
}
|
|
|
|
/// Subscribe to real-time microphone input level at ~30 Hz.
|
|
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
|
|
/// when the Dart subscriber cancels, the session is dropped, or
|
|
/// the session becomes persistently unavailable.
|
|
pub fn input_level_stream(sink: StreamSink<f32>) -> Result<(), BridgeError> {
|
|
runtime().spawn(async move {
|
|
let mut interval = tokio::time::interval(Duration::from_millis(33));
|
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
let mut consecutive_errors = 0u32;
|
|
loop {
|
|
interval.tick().await;
|
|
let level = match session().audio_stats().await {
|
|
Ok((_, _, _, lvl)) => {
|
|
consecutive_errors = 0;
|
|
lvl
|
|
}
|
|
Err(_) => {
|
|
consecutive_errors += 1;
|
|
if consecutive_errors >= 10 {
|
|
return;
|
|
}
|
|
-120.0
|
|
}
|
|
};
|
|
if sink.add(level).is_err() {
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// Apply the P1 audio-processing config.
|
|
pub async fn set_audio_processing_config(
|
|
config: BridgeAudioProcessingConfig,
|
|
) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_audio_processing_config(config.into()).await })
|
|
.await
|
|
.map_err(|e| task_join_error("set_audio_processing_config", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the current audio-processing config.
|
|
///
|
|
/// Returns the live config as last applied to the audio engine.
|
|
/// Returns a default config when no session is active.
|
|
pub async fn get_audio_processing_config() -> Result<BridgeAudioProcessingConfig, BridgeError> {
|
|
let config = runtime()
|
|
.spawn(async { session().get_audio_processing_config().await })
|
|
.await
|
|
.map_err(|e| task_join_error("get_audio_processing_config", e))??;
|
|
Ok(config.into())
|
|
}
|
|
|
|
/// Read P1 audio-processing diagnostics.
|
|
pub async fn audio_processing_stats() -> Result<BridgeAudioProcessingStats, BridgeError> {
|
|
let stats = runtime()
|
|
.spawn(async { session().audio_processing_stats().await })
|
|
.await
|
|
.map_err(|e| task_join_error("audio_processing_stats", e))??;
|
|
Ok(stats.into())
|
|
}
|
|
|
|
/// Audio device info from the platform.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BridgeAudioDevice {
|
|
/// Stable platform-reported device identifier.
|
|
pub id: String,
|
|
/// Human-readable device name.
|
|
pub name: String,
|
|
/// Additional device details useful for disambiguation.
|
|
pub details: String,
|
|
/// True if the OS reports this as the default device.
|
|
pub is_default: bool,
|
|
/// True if Chanora currently has this device pinned.
|
|
pub is_selected: 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 async fn list_audio_devices() -> Result<BridgeAudioDeviceList, BridgeError> {
|
|
let (list, selected_input, selected_output) = runtime()
|
|
.spawn(async move {
|
|
let selected_input = session().preferred_input_device().await;
|
|
let selected_output = session().preferred_output_device().await;
|
|
let list = chanora_audio::list_audio_devices();
|
|
(list, selected_input, selected_output)
|
|
})
|
|
.await
|
|
.map_err(|e| task_join_error("list_audio_devices", e))?;
|
|
|
|
Ok(BridgeAudioDeviceList {
|
|
input_devices: list
|
|
.input_devices
|
|
.into_iter()
|
|
.map(|d| BridgeAudioDevice {
|
|
is_selected: selected_input.as_ref().is_some_and(|id| id == &d.id),
|
|
id: d.id,
|
|
name: d.name,
|
|
details: d.details,
|
|
is_default: d.is_default,
|
|
})
|
|
.collect(),
|
|
output_devices: list
|
|
.output_devices
|
|
.into_iter()
|
|
.map(|d| BridgeAudioDevice {
|
|
is_selected: selected_output.as_ref().is_some_and(|id| id == &d.id),
|
|
id: d.id,
|
|
name: d.name,
|
|
details: d.details,
|
|
is_default: d.is_default,
|
|
})
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
/// Set the preferred input device by id. Takes effect on next
|
|
/// `start_audio`.
|
|
pub async fn set_input_device(id: Option<String>) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_input_device(id).await })
|
|
.await
|
|
.map_err(|e| task_join_error("set_input_device", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the preferred output device by id.
|
|
pub async fn set_output_device(id: Option<String>) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_output_device(id).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() {
|
|
return Err(BridgeError::InvalidCommand(
|
|
"vad model path must not be empty".to_string(),
|
|
));
|
|
}
|
|
runtime()
|
|
.spawn(async move { session().set_vad_model_path(path).await })
|
|
.await
|
|
.map_err(|e| task_join_error("set_vad_model_path", e))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Enable or disable audio debug WAV dumping.
|
|
pub async fn enable_audio_debug_wav_dump(enabled: bool) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_audio_debug_wav_dump(enabled).await })
|
|
.await
|
|
.map_err(|e| task_join_error("enable_audio_debug_wav_dump", e))?
|
|
.map_err(|e| BridgeError::Unmapped(format!("enable_audio_debug_wav_dump: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Select the iOS voice-processing mode.
|
|
pub async fn set_ios_voice_processing_mode(
|
|
mode: BridgeIosVoiceProcessingMode,
|
|
) -> Result<(), BridgeError> {
|
|
let config = BridgeAudioProcessingConfig {
|
|
route: BridgeAudioRoute::Speaker,
|
|
ios_mode: mode,
|
|
processing_backend: match mode {
|
|
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => {
|
|
BridgeAudioBackend::PlatformVoiceProcessing
|
|
}
|
|
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::WebrtcApm,
|
|
},
|
|
vad_backend: BridgeVadBackend::SileroOnnx,
|
|
aec: match mode {
|
|
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
|
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
|
|
},
|
|
ns: match mode {
|
|
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
|
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
|
|
},
|
|
agc: match mode {
|
|
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
|
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
|
|
},
|
|
hpf_enabled: true,
|
|
limiter_enabled: true,
|
|
vad_hangover_ms: 500,
|
|
vad_pre_roll_ms: 160,
|
|
vad_min_tx_ms: 200,
|
|
debug_wav_dump_enabled: false,
|
|
};
|
|
set_audio_processing_config(config).await
|
|
}
|
|
|
|
/// Set the preferred audio output route (Android/iOS).
|
|
#[frb(sync)]
|
|
pub fn set_audio_output_route(route: BridgeAudioRoute) {
|
|
dispatch_platform_audio_event(PlatformAudioEvent::AudioOutputRoute(route.into()));
|
|
}
|
|
/// Called from Flutter when the app enters background/foreground.
|
|
#[frb(sync)]
|
|
pub fn record_lifecycle_event(state: String) {
|
|
dispatch_platform_audio_event(PlatformAudioEvent::Lifecycle { state });
|
|
}
|