feat: integrate chat voice and diagnostics client

This commit is contained in:
Edison Jwa
2026-05-23 06:51:55 +09:00
parent 7e28791ec2
commit 7d5d8c2c90
93 changed files with 10273 additions and 3347 deletions
+1 -8
View File
@@ -28,6 +28,7 @@ audiopus = "0.3.0-rc.0"
# Connection type stays inside chanora_protocol.
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
rustfft = "6.2.0"
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
# Desktop audio I/O for Windows capture/playback and Linux capture.
@@ -35,11 +36,6 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
# AudioUnits via `coreaudio-rs` for the voice path.
cpal = "0.17.3"
[target.'cfg(not(target_os = "android"))'.dependencies]
# Desktop/iOS: native TLS maps to the platform TLS backend (Security.framework
# on Apple, SChannel on Windows, system OpenSSL on Linux/BSD).
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
# Direct CoreAudio AudioUnit access on Apple platforms (DEC-011 follow-up).
# cpal's Apple path does not expose the voice-processing controls Chanora
@@ -64,9 +60,6 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "n
ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "api-24"] }
[target.'cfg(target_os = "android")'.dependencies]
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
# native-tls for Apple targets where aws-lc/rustls is problematic for iOS.
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] }
# JNI bindings to flip Android's AudioManager into MODE_IN_COMMUNICATION
# when the voice-comm preset is requested. ndk_context is initialised
# by the bridge crate's android_init shim.
@@ -95,7 +95,7 @@ fn bench_capture_alloc_count(c: &mut Criterion) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, format!("{}", delta));
let _ = std::fs::write(&path, delta.to_string());
}
}
+53 -77
View File
@@ -50,7 +50,7 @@ use crate::mobile_voice_backend::{
AchievedPerformanceMode, AchievedSharingMode, AndroidAudioDiagnostics,
AndroidVoiceStreamConfig, AudioSessionId, BackendError, BackendEvent, BackendEventRx,
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
SharingModeChoice,
SharingModeChoice, VoiceAudioParams,
};
use chanora_protocol::OutPacket;
use tsclientlib::audio::AudioHandler;
@@ -102,8 +102,8 @@ impl RenderReferenceBuffer {
fn write(&self, frame: &[f32; RENDER_REF_SAMPLES]) {
let idx = self.write_idx.load(Ordering::Relaxed);
unsafe {
let slot =
&self.buf[idx] as *const [f32; RENDER_REF_SAMPLES] as *mut [f32; RENDER_REF_SAMPLES];
let slot = &self.buf[idx] as *const [f32; RENDER_REF_SAMPLES]
as *mut [f32; RENDER_REF_SAMPLES];
(*slot).copy_from_slice(frame);
}
self.write_idx
@@ -148,6 +148,7 @@ struct AndroidCaptureState {
ten_vad_worker: Option<crate::vad::TenOnnxVadWorker>,
current_vad_backend: crate::VadBackend,
silero_model_epoch: u64,
ten_model_epoch: u64,
capture_frame_seq: u64,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
webrtc_apm_processor: crate::processor::WebRtcApmProcessor,
@@ -208,6 +209,7 @@ impl AndroidCaptureState {
ten_vad_worker: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
silero_model_epoch: crate::vad::silero_model_epoch(),
ten_model_epoch: crate::vad::ten_model_epoch(),
capture_frame_seq: 0,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
@@ -227,8 +229,7 @@ impl AndroidCaptureState {
fn ingest_i16(&mut self, samples: &[i16]) {
let mut offset = 0;
while offset < samples.len() {
let remaining =
crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
let take = remaining.min(samples.len() - offset);
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
.copy_from_slice(&samples[offset..offset + take]);
@@ -249,11 +250,8 @@ impl AndroidCaptureState {
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
frame.copy_from_slice(
&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES],
);
self.pcm_accum
.drain(..crate::frame::FRAME_20MS_SAMPLES);
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
Ok(len) => {
crate::opus_voice::send_voip_frame(
@@ -289,18 +287,26 @@ impl AndroidCaptureState {
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
if self.fallback_warned_backend != Some(failed_backend) {
self.fallback_warned_backend = Some(failed_backend);
warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"android: VAD backend unavailable; using WebRTC fallback for runtime detection"
);
// Warm-up period: ONNX workers need ~100ms to process first frame.
// Don't flag as a problem if the capture has just started.
if self.capture_frame_seq < 128 {
info!(
target: "chanora_audio",
backend = failed_backend.as_str(),
seq = self.capture_frame_seq,
"android: VAD backend warming up; using WebRTC fallback"
);
} else {
warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"android: VAD backend unavailable; using WebRTC fallback for runtime detection"
);
}
}
}
fn process_10ms_capture_frame(
&mut self,
samples: &[i16; crate::frame::FRAME_10MS_SAMPLES],
) {
fn process_10ms_capture_frame(&mut self, samples: &[i16; crate::frame::FRAME_10MS_SAMPLES]) {
let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
*dst = crate::frame::i16_to_f32(src);
@@ -327,11 +333,15 @@ impl AndroidCaptureState {
// VAD backend switching (mirrors iOS Raw path).
let silero_epoch = crate::vad::silero_model_epoch();
let silero_changed = vad_backend == crate::VadBackend::SileroOnnx
&& silero_epoch != self.silero_model_epoch;
if vad_backend != self.current_vad_backend || silero_changed {
let ten_epoch = crate::vad::ten_model_epoch();
let silero_changed =
vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch;
let ten_changed =
vad_backend == crate::VadBackend::TenVad && ten_epoch != self.ten_model_epoch;
if vad_backend != self.current_vad_backend || silero_changed || ten_changed {
self.current_vad_backend = vad_backend;
self.silero_model_epoch = silero_epoch;
self.ten_model_epoch = ten_epoch;
self.fallback_warned_backend = None;
match vad_backend {
crate::VadBackend::SileroOnnx => {
@@ -376,52 +386,52 @@ impl AndroidCaptureState {
} else if vad_backend == crate::VadBackend::SileroOnnx {
if let Some(worker) = self.silero_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else if enqueued {
// Warm-up: worker dispatched but hasn't finished yet.
// Default to no-speech until first result arrives (~100ms).
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
}
} else if vad_backend == crate::VadBackend::TenVad {
if let Some(worker) = self.ten_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
}
} else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
@@ -572,37 +582,6 @@ impl AudioOutputCallback for OutputCallback {
}
}
/// Bundle of engine-owned state shared with the Oboe audio callbacks.
/// Mirrors the parameter set that iOS `IosVoiceUnit::start()` receives
/// from the engine (SDD-120 amendment: Android Oboe-only audio path).
pub struct VoiceAudioParams {
/// Opus-encoded voice packets sent on this channel toward the
/// protocol layer.
pub voice_out_tx: mpsc::Sender<OutPacket>,
/// PTT transmission gate — true when the user holds the PTT key.
pub transmit_active: Arc<AtomicBool>,
/// Counter incremented per encoded frame sent.
pub frames_sent: Arc<AtomicU32>,
/// Pre-encode amplitude scale (1.0 = unity).
pub mic_gain: f32,
/// AudioHandler that inbound decode+mix feeds into; the Oboe output
/// callback pulls mixed stereo f32 from it.
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
/// cross-thread read from the realtime audio callback).
pub output_gain: Arc<AtomicU32>,
/// True = output silence regardless of incoming voice frames.
pub output_muted: Arc<AtomicBool>,
/// Optional TransmitModeSelector for VoiceActivity transmit mode.
/// The capture callback calls set_voice_activity_open on this when
/// VAD detects speech. None means VoiceActivity mode is disabled.
pub voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
/// Shared audio-processing config (WebRTC APM flags, VAD backend).
pub audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
/// Shared audio-processing statistics for diagnostics.
pub audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
}
// --- The backend itself ------------------------------------------
/// Android voice-audio backend (SDD-111). Owns one input + one
@@ -647,8 +626,7 @@ impl AndroidVoiceUnit {
/// `params` bundles the engine-owned state shared with the Oboe
/// audio callbacks (SDD-120 amendment: Android Oboe-only audio
/// path — capture pipeline, playback pull, and PTT gate).
#[allow(clippy::too_many_arguments)]
pub fn open(
pub(crate) fn open(
cfg: &AndroidVoiceStreamConfig,
params: VoiceAudioParams,
) -> Result<Self, BackendError> {
@@ -761,9 +739,7 @@ impl AndroidVoiceUnit {
//
// For now: hardware effects require the system session ID.
// WebRTC APM software processing handles AEC/NS/AGC/HPF.
let session_id: Option<i32> = input_stream
.as_ref()
.and_then(|s| s.get_raw_session_id());
let session_id: Option<i32> = input_stream.as_ref().and_then(|s| s.get_raw_session_id());
// --- Open output stream (SDD-112) --------------------------
let output_builder = AudioStreamBuilder::default()
@@ -1558,7 +1534,7 @@ fn load_app_class<'local>(
Ok(c) => return Some(c),
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, class = slash_name, "android: find_class failed; retrying with app ClassLoader");
debug!(target: "chanora_audio", error = %e, class = slash_name, "android: find_class failed; retrying with app ClassLoader");
}
}
+7 -7
View File
@@ -207,12 +207,12 @@ impl AudioProcessingConfig {
.to_string(),
));
}
if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental {
if self.processing_backend != AudioBackend::WebrtcApm {
return Err(AudioError::InvalidAudioProcessingConfig(
"ios raw processing mode requires the WebRTC APM backend".to_string(),
));
}
if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental
&& self.processing_backend != AudioBackend::WebrtcApm
{
return Err(AudioError::InvalidAudioProcessingConfig(
"ios raw processing mode requires the WebRTC APM backend".to_string(),
));
}
Ok(())
}
@@ -246,7 +246,7 @@ mod tests {
assert_eq!(config.aec, EffectOwner::Platform);
assert_eq!(config.ns, EffectOwner::Platform);
assert_eq!(config.agc, EffectOwner::Platform);
assert_eq!(config.vad_backend, VadBackend::SileroOnnx);
assert_eq!(config.vad_backend, VadBackend::TenVad);
}
#[test]
+235 -167
View File
@@ -55,17 +55,98 @@ use audiopus::coder::Encoder as OpusEncoder;
pub struct SessionAudioId(pub u64);
/// Audio framing: 48 kHz mono, 20 ms = 960 samples per frame.
/// These constants are framing invariants of the engine and are
/// referenced from per-platform helpers (`try_open_capture` and
/// the CaptureState on cpal platforms; `ios_voice_unit` on iOS once
/// commits 3+4 land). The `allow(dead_code)` is here because in
/// the current commit the iOS VPIO callbacks are still no-op stubs
/// and don't reach these constants yet — they will in commit 3
/// when the input callback wires into CaptureState.
#[allow(dead_code)]
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
const SAMPLE_RATE: u32 = 48_000;
#[allow(dead_code)]
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
const FRAME_SAMPLES: usize = 48_000 / 50; // 960
/// List of available audio devices from the platform.
#[derive(Debug, Clone)]
pub struct AudioDeviceList {
/// Available input (capture) devices.
pub input_devices: Vec<AudioDeviceInfo>,
/// Available output (playback) devices.
pub output_devices: Vec<AudioDeviceInfo>,
}
/// Info about a single audio device.
#[derive(Debug, Clone)]
pub struct AudioDeviceInfo {
/// Human-readable device name from the OS.
pub name: String,
/// True if the OS reports this as the default device.
pub is_default: bool,
}
/// Enumerate available audio input and output devices.
/// On mobile platforms (iOS, Android) returns an empty list because
/// device selection is managed by the OS audio session.
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
pub fn list_audio_devices() -> AudioDeviceList {
use cpal::traits::HostTrait;
let mut list = AudioDeviceList {
input_devices: Vec::new(),
output_devices: Vec::new(),
};
let Ok(host) = cpal::default_host() else {
return list;
};
let default_in = host.default_input_device();
let default_out = host.default_output_device();
if let Ok(devices) = host.input_devices() {
for d in devices {
let name = d
.description()
.map(|n| n.name().to_owned())
.unwrap_or_default();
if !name.is_empty() {
let is_default = default_in
.as_ref()
.is_some_and(|di| di.description().is_ok_and(|dn| dn.name() == name.as_str()));
list.input_devices
.push(AudioDeviceInfo { name, is_default });
}
}
}
if let Ok(devices) = host.output_devices() {
for d in devices {
let name = d
.description()
.map(|n| n.name().to_owned())
.unwrap_or_default();
if !name.is_empty() {
let is_default = default_out
.as_ref()
.is_some_and(|di| di.description().is_ok_and(|dn| dn.name() == name.as_str()));
list.output_devices
.push(AudioDeviceInfo { name, is_default });
}
}
}
list
}
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
/// Enumerate available audio input and output devices.
pub fn list_audio_devices() -> AudioDeviceList {
AudioDeviceList {
input_devices: Vec::new(),
output_devices: Vec::new(),
}
}
/// Engine configuration.
#[derive(Clone)]
pub struct AudioEngineConfig {
@@ -91,6 +172,12 @@ pub struct AudioEngineConfig {
/// is rejected on Android because the P0 path intentionally has
/// no generic mobile-audio fallback.
pub mobile_voice_preset: bool,
/// Optional input device name override. When `None`, the system
/// default input device is used. Set to a device name from
/// [`list_audio_devices`] to pin a specific microphone.
pub input_device_name: Option<String>,
/// Optional output device name override.
pub output_device_name: Option<String>,
/// Optional selector used by P1 VoiceActivity to publish VAD state.
#[doc(hidden)]
pub voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
@@ -103,6 +190,8 @@ impl std::fmt::Debug for AudioEngineConfig {
.field("ptt_initial", &self.ptt_initial)
.field("effects", &self.effects)
.field("mobile_voice_preset", &self.mobile_voice_preset)
.field("input_device_name", &self.input_device_name)
.field("output_device_name", &self.output_device_name)
.field(
"voice_activity_selector",
&self.voice_activity_selector.as_ref().map(|_| "present"),
@@ -118,6 +207,8 @@ impl Default for AudioEngineConfig {
ptt_initial: false,
effects: crate::AudioEffects::default(),
mobile_voice_preset: true,
input_device_name: None,
output_device_name: None,
voice_activity_selector: None,
}
}
@@ -145,7 +236,6 @@ pub struct AudioEngine {
output_muted: Arc<AtomicBool>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
#[cfg(any(target_os = "ios", target_os = "macos"))]
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
#[cfg(any(target_os = "ios", target_os = "macos"))]
voice_out_tx: mpsc::Sender<OutPacket>,
@@ -204,23 +294,6 @@ pub struct AudioEngine {
/// denied microphone permission), PTT becomes a no-op and
/// `frames_sent` stays at 0.
capture_active: bool,
/// Missed-key-up watchdog (SDD-092). Dropping aborts the task.
/// The watchdog is independent of the PTT input backend — it
/// observes the gate directly. The platform input backend is
/// owned by `chanora_core::ptt::PttController` (SDD-088), not
/// by the engine.
///
/// In the post-rc.7 architecture this field is unused: the
/// missed-key-up watchdog now lives on the session and
/// subscribes to `TransmitModeSelector::subscribe_ptt_held`
/// rather than the gate. Watching the gate caused the watchdog
/// to fire in Continuous mode (where the gate is intentionally
/// pinned to `true`) which clearing surfaced as the bug
/// "Continuous transmission disabled after some time". The
/// field stays here as `None` for now to preserve the existing
/// engine-stop teardown flow; a follow-up commit can remove it
/// entirely.
ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog>,
}
// cpal::Stream is not Send. We keep the engine pinned to the thread
@@ -241,7 +314,6 @@ unsafe impl Send for AudioEngine {}
unsafe impl Sync for AudioEngine {}
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[allow(dead_code)]
enum IosVoiceBackend {
Vpio(crate::ios_voice_unit::IosVoiceUnit),
#[cfg(target_os = "ios")]
@@ -260,7 +332,9 @@ impl IosVoiceBackend {
}
#[cfg(target_os = "macos")]
{
Ok(())
match self {
Self::Vpio(_unit) => Ok(()),
}
}
}
@@ -274,41 +348,23 @@ impl IosVoiceBackend {
}
#[cfg(target_os = "macos")]
{
Ok(())
match self {
Self::Vpio(_unit) => Ok(()),
}
}
}
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[allow(clippy::too_many_arguments)]
fn open_ios_voice_backend(
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_flag_for_capture: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
params: crate::mobile_voice_backend::VoiceAudioParams,
) -> Result<IosVoiceBackend, AudioError> {
let _cfg = audio_processing_config.lock().unwrap().clone();
let _cfg = params.audio_processing_config.lock().unwrap().clone();
#[cfg(target_os = "ios")]
{
if _cfg.ios_mode == crate::IosVoiceProcessingMode::SonoraExperimental {
match crate::ios_raw_unit::IosRawUnit::start(
handler.clone(),
output_gain.clone(),
output_muted.clone(),
voice_out_tx.clone(),
transmit_flag_for_capture.clone(),
frames_sent.clone(),
mic_gain,
voice_activity_selector.clone(),
audio_processing_config.clone(),
audio_processing_stats.clone(),
) {
let raw_params = params.clone();
match crate::ios_raw_unit::IosRawUnit::start(raw_params) {
Ok(unit) => {
info!(target: "chanora_audio", "ios: RemoteIO/WebRTC APM backend selected");
return Ok(IosVoiceBackend::Raw(unit));
@@ -324,18 +380,7 @@ fn open_ios_voice_backend(
}
}
let unit = crate::ios_voice_unit::IosVoiceUnit::start(
handler,
output_gain,
output_muted,
voice_out_tx,
transmit_flag_for_capture,
frames_sent,
mic_gain,
voice_activity_selector,
audio_processing_config,
audio_processing_stats,
)?;
let unit = crate::ios_voice_unit::IosVoiceUnit::start(params)?;
Ok(IosVoiceBackend::Vpio(unit))
}
@@ -361,23 +406,40 @@ impl AudioEngine {
voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
#[allow(clippy::needless_return)]
// Apple platforms route to a separate backend (VoiceProcessingIO
// via coreaudio-rs) because cpal does not expose the native
// voice-processing AudioUnit controls Chanora needs for VoIP.
// Windows and Linux stay on the cpal / SDL flow below.
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
return Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate);
}
#[cfg(target_os = "android")]
{
return Self::start_with_gate_android(cfg, voice_out_tx, voice_in_rx, transmit_gate);
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
{
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
Self::start_with_gate_platform(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
// Apple platforms route to a separate backend (VoiceProcessingIO
// via coreaudio-rs) because cpal does not expose the native
// voice-processing AudioUnit controls Chanora needs for VoIP.
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn start_with_gate_platform(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
#[cfg(target_os = "android")]
fn start_with_gate_platform(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
Self::start_with_gate_android(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn start_with_gate_platform(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
/// Non-Apple/non-Android implementation: cpal capture + (cpal | SDL2) output.
@@ -398,12 +460,45 @@ impl AudioEngine {
host_id = ?host.id(),
"starting audio engine: cpal host selected"
);
let in_dev = host
.default_input_device()
.ok_or(AudioError::NoInputDevice)?;
let out_dev = host
.default_output_device()
.ok_or(AudioError::NoOutputDevice)?;
/// Helper: find a device by name, falling back to default.
fn find_device(
host: &cpal::Host,
default_fn: fn(&cpal::Host) -> Option<cpal::Device>,
all_fn: fn(&cpal::Host) -> Result<cpal::Devices, cpal::DevicesError>,
prefer: Option<&str>,
) -> Option<cpal::Device> {
if let Some(name) = prefer {
if let Ok(devices) = all_fn(host) {
for d in devices {
let dn = d
.description()
.map(|n| n.name().to_owned())
.unwrap_or_default();
if dn == name {
return Some(d);
}
}
}
}
default_fn(host)
}
let in_dev = find_device(
&host,
cpal::Host::default_input_device,
cpal::Host::input_devices,
cfg.input_device_name.as_deref(),
)
.ok_or(AudioError::NoInputDevice)?;
let out_dev = find_device(
&host,
cpal::Host::default_output_device,
cpal::Host::output_devices,
cfg.output_device_name.as_deref(),
)
.ok_or(AudioError::NoOutputDevice)?;
info!(
target: "chanora_audio",
@@ -611,19 +706,6 @@ impl AudioEngine {
}
});
// Select and arm the desktop PTT backend is no longer the
// engine's job (SDD-088). The PTT controller lives in
// `chanora_core::ptt::PttController`; the engine is
// responsible only for the cpal streams and the
// missed-key-up watchdog (SDD-092).
// The engine no longer spawns a watchdog against the
// gate (see comment on the `ptt_watchdog` field for the
// rationale). The session spawns the watchdog against the
// selector's `ptt_held` signal instead, so it never fires
// in Continuous mode.
let ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog> = None;
Ok(Self {
transmit_gate,
frames_sent,
@@ -632,11 +714,11 @@ impl AudioEngine {
output_muted,
audio_processing_config,
audio_processing_stats,
audio_handler,
_input_stream: Mutex::new(input_stream),
_output_stream: Mutex::new(Some(output_stream)),
shutdown_tx: Some(shutdown_tx),
capture_active,
ptt_watchdog,
})
}
@@ -716,7 +798,7 @@ impl AudioEngine {
effects: cfg.effects,
..Default::default()
};
let params = crate::android_voice_unit::VoiceAudioParams {
let params = crate::mobile_voice_backend::VoiceAudioParams {
voice_out_tx,
transmit_active: transmit_flag_for_capture,
frames_sent: frames_sent.clone(),
@@ -841,8 +923,6 @@ impl AudioEngine {
}
});
let ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog> = None;
Ok(Self {
transmit_gate,
frames_sent,
@@ -851,11 +931,11 @@ impl AudioEngine {
output_muted,
audio_processing_config,
audio_processing_stats,
audio_handler,
_android_voice_unit: Mutex::new(Some(android_voice_unit)),
audio_mode_stack: Mutex::new(audio_mode_stack),
shutdown_tx: Some(shutdown_tx),
capture_active,
ptt_watchdog,
})
}
@@ -907,18 +987,19 @@ impl AudioEngine {
// Construct the live iOS voice backend. Platform VPIO stays
// the default shipping path; Sonora/RemoteIO remains opt-in.
let ios_voice_backend = open_ios_voice_backend(
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
voice_out_tx_for_backend,
transmit_flag_for_capture,
frames_sent.clone(),
cfg.mic_gain,
cfg.voice_activity_selector.clone(),
audio_processing_config.clone(),
audio_processing_stats.clone(),
)?;
let ios_voice_backend =
open_ios_voice_backend(crate::mobile_voice_backend::VoiceAudioParams {
handler: audio_handler.clone(),
output_gain: output_gain.clone(),
output_muted: output_muted.clone(),
voice_out_tx: voice_out_tx_for_backend,
transmit_active: transmit_flag_for_capture,
frames_sent: frames_sent.clone(),
mic_gain: cfg.mic_gain,
voice_activity_selector: cfg.voice_activity_selector.clone(),
audio_processing_config: audio_processing_config.clone(),
audio_processing_stats: audio_processing_stats.clone(),
})?;
// Capture is always considered active on iOS — VPIO's
// input element is wired up by the AudioUnit itself, no
@@ -959,8 +1040,6 @@ impl AudioEngine {
}
});
let ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog> = None;
Ok(Self {
transmit_gate,
frames_sent,
@@ -976,7 +1055,6 @@ impl AudioEngine {
_ios_voice_backend: Mutex::new(Some(ios_voice_backend)),
shutdown_tx: Some(shutdown_tx),
capture_active,
ptt_watchdog,
})
}
@@ -985,12 +1063,6 @@ impl AudioEngine {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
// The platform PTT backend is no longer owned by the
// engine (SDD-088); its lifecycle is managed by
// `chanora_core::ptt::PttController`. The engine only
// needs to abort its watchdog and drop the audio streams.
// Aborting the watchdog cancels its tokio task.
self.ptt_watchdog.take();
// Drop the streams, which stops their callback threads.
// Each platform has a slightly different backend; the
// common contract is that dropping the wrapper stops
@@ -1088,18 +1160,18 @@ impl AudioEngine {
pub fn ios_restart_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
let backend = open_ios_voice_backend(
self.audio_handler.clone(),
self.output_gain.clone(),
self.output_muted.clone(),
self.voice_out_tx.clone(),
self.transmit_gate.flag_arc(),
self.frames_sent.clone(),
self.mic_gain,
self.voice_activity_selector.clone(),
self.audio_processing_config.clone(),
self.audio_processing_stats.clone(),
)?;
let backend = open_ios_voice_backend(crate::mobile_voice_backend::VoiceAudioParams {
handler: self.audio_handler.clone(),
output_gain: self.output_gain.clone(),
output_muted: self.output_muted.clone(),
voice_out_tx: self.voice_out_tx.clone(),
transmit_active: self.transmit_gate.flag_arc(),
frames_sent: self.frames_sent.clone(),
mic_gain: self.mic_gain,
voice_activity_selector: self.voice_activity_selector.clone(),
audio_processing_config: self.audio_processing_config.clone(),
audio_processing_stats: self.audio_processing_stats.clone(),
})?;
let mut guard = self._ios_voice_backend.lock().unwrap();
*guard = Some(backend);
Ok(())
@@ -1166,33 +1238,6 @@ impl AudioEngine {
&self.transmit_gate
}
/// Privacy-safe descriptor of the engine's PTT view. The
/// platform backend lives in `chanora_core::ptt::PttController`
/// (SDD-088); the engine itself no longer owns it. This getter
/// always returns the universal Focused fallback descriptor
/// and is retained only for legacy callers that constructed
/// engines directly without a controller (tests, headless
/// diagnostics).
pub fn ptt_descriptor(&self) -> crate::ptt::PttBackendDescriptor {
crate::ptt::PttBackendDescriptor::focused()
}
/// Legacy alias for [`Self::set_transmit_active`]. Retained so
/// the existing bridge `set_ptt` command and the existing
/// Flutter UI continue to compile during the v0.9.3 PTT
/// migration (SRS-201 splits the conceptual `ptt` flag into
/// `transmit_active` / `capture_active`).
#[doc(hidden)]
pub fn set_ptt(&self, active: bool) {
self.set_transmit_active(active);
}
/// Legacy alias for [`Self::transmit_active`].
#[doc(hidden)]
pub fn ptt(&self) -> bool {
self.transmit_active()
}
/// True if the capture stream opened. When false, the engine
/// runs in playback-only mode and the transmit gate is a
/// no-op (no frames will ever be encoded).
@@ -1268,6 +1313,29 @@ impl AudioEngine {
pub fn output_gain(&self) -> f32 {
f32::from_bits(self.output_gain.load(Ordering::Relaxed))
}
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. Values above `1.0` amplify and may clip. Clamped to
/// `0.0..4.0`.
pub fn set_client_volume(&self, client_id: u64, volume: f32) {
let clamped = volume.clamp(0.0, 4.0);
match self.audio_handler.lock() {
Ok(mut h) => {
if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) {
q.volume = clamped;
}
}
Err(e) => {
tracing::warn!(
target: "chanora_audio",
client_id,
volume = clamped,
error = %e,
"set_client_volume: audio_handler lock poisoned — volume not applied"
);
}
}
}
}
impl Drop for AudioEngine {
+52 -53
View File
@@ -44,9 +44,8 @@ mod inner {
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use tokio::sync::mpsc;
use tracing::{info, warn};
use tsclientlib::audio::AudioHandler;
use crate::engine::SessionAudioId;
use crate::mobile_voice_backend::VoiceAudioParams;
use crate::processor::AudioProcessor;
use crate::AudioError;
use chanora_protocol::OutPacket;
@@ -121,6 +120,7 @@ mod inner {
ten_vad_worker: Option<crate::vad::TenOnnxVadWorker>,
current_vad_backend: crate::VadBackend,
silero_model_epoch: u64,
ten_model_epoch: u64,
capture_frame_seq: u64,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
/// Processing config — retained for route-change reloads.
@@ -136,18 +136,12 @@ mod inner {
impl RawCaptureState {
fn new(
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
params: &VoiceAudioParams,
render_reference: Arc<RenderReferenceBuffer>,
) -> Result<Self, AudioError> {
let encoder = crate::opus_voice::new_voip_encoder("ios raw")?;
let webrtc_apm_config = audio_processing_config
let webrtc_apm_config = params
.audio_processing_config
.lock()
.map(|cfg| crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(&cfg))
.unwrap_or_default();
@@ -155,24 +149,25 @@ mod inner {
encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
output_muted,
frames_sent,
mic_gain,
voice_activity_selector,
voice_out_tx: params.voice_out_tx.clone(),
transmit_active: params.transmit_active.clone(),
output_muted: params.output_muted.clone(),
frames_sent: params.frames_sent.clone(),
mic_gain: params.mic_gain,
voice_activity_selector: params.voice_activity_selector.clone(),
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_vad_worker: None,
ten_vad_worker: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
silero_model_epoch: crate::vad::silero_model_epoch(),
ten_model_epoch: crate::vad::ten_model_epoch(),
capture_frame_seq: 0,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
audio_processing_config,
audio_processing_config: params.audio_processing_config.clone(),
webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
webrtc_apm_config,
)?,
audio_processing_stats,
audio_processing_stats: params.audio_processing_stats.clone(),
render_reference,
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: 0,
@@ -184,11 +179,20 @@ mod inner {
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
if self.fallback_warned_backend != Some(failed_backend) {
self.fallback_warned_backend = Some(failed_backend);
tracing::warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"VAD backend unavailable; using WebRTC fallback for runtime detection"
);
if self.capture_frame_seq < 128 {
tracing::info!(
target: "chanora_audio",
backend = failed_backend.as_str(),
seq = self.capture_frame_seq,
"VAD backend warming up; using WebRTC fallback"
);
} else {
tracing::warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"VAD backend unavailable; using WebRTC fallback for runtime detection"
);
}
}
}
@@ -286,11 +290,15 @@ mod inner {
// Switch VAD backend when config changes.
let silero_epoch = crate::vad::silero_model_epoch();
let ten_epoch = crate::vad::ten_model_epoch();
let silero_changed = vad_backend == crate::VadBackend::SileroOnnx
&& silero_epoch != self.silero_model_epoch;
if vad_backend != self.current_vad_backend || silero_changed {
let ten_changed =
vad_backend == crate::VadBackend::TenVad && ten_epoch != self.ten_model_epoch;
if vad_backend != self.current_vad_backend || silero_changed || ten_changed {
self.current_vad_backend = vad_backend;
self.silero_model_epoch = silero_epoch;
self.ten_model_epoch = ten_epoch;
self.fallback_warned_backend = None;
match vad_backend {
crate::VadBackend::SileroOnnx => {
@@ -335,12 +343,17 @@ mod inner {
} else if vad_backend == crate::VadBackend::SileroOnnx {
if let Some(worker) = self.silero_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
@@ -357,12 +370,17 @@ mod inner {
} else if vad_backend == crate::VadBackend::TenVad {
if let Some(worker) = self.ten_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
@@ -422,22 +440,10 @@ mod inner {
impl IosRawUnit {
/// Open a RemoteIO AudioUnit, install render + input callbacks, start.
#[allow(clippy::too_many_arguments)]
pub fn start(
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Result<Self, AudioError> {
pub(crate) fn start(params: VoiceAudioParams) -> Result<Self, AudioError> {
// INV_010: reject if config requests VPIO (that's IosVoiceUnit's job).
{
let cfg = audio_processing_config.lock().unwrap();
let cfg = params.audio_processing_config.lock().unwrap();
if cfg.ios_mode == crate::IosVoiceProcessingMode::PlatformVoiceProcessing {
return Err(AudioError::InvalidAudioProcessingConfig(
"IosRawUnit requires raw WebRTC APM mode".to_string(),
@@ -470,17 +476,7 @@ mod inner {
let render_ref_buf = RenderReferenceBuffer::new();
let render_ref_for_capture = render_ref_buf.clone();
let mut capture_state = RawCaptureState::new(
voice_out_tx,
transmit_active,
output_muted.clone(),
frames_sent,
mic_gain,
voice_activity_selector,
audio_processing_config,
audio_processing_stats.clone(),
render_ref_for_capture,
)?;
let mut capture_state = RawCaptureState::new(&params, render_ref_for_capture)?;
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
capture_state.ingest_i16(args.data.buffer);
@@ -489,7 +485,10 @@ mod inner {
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
let mut scratch: Vec<f32> = Vec::with_capacity(2048);
let stats_render = audio_processing_stats.clone();
let handler = params.handler.clone();
let output_gain = params.output_gain.clone();
let output_muted = params.output_muted.clone();
let stats_render = params.audio_processing_stats.clone();
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
let out = args.data.buffer;
+55 -59
View File
@@ -81,9 +81,8 @@ use coreaudio::audio_unit::IOType;
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use tsclientlib::audio::AudioHandler;
use crate::engine::SessionAudioId;
use crate::mobile_voice_backend::VoiceAudioParams;
use crate::AudioError;
use chanora_protocol::OutPacket;
@@ -152,6 +151,8 @@ struct IosCaptureState {
current_vad_backend: crate::VadBackend,
/// Last observed configured Silero model epoch.
silero_model_epoch: u64,
/// Last observed configured TEN model epoch.
ten_model_epoch: u64,
fallback_warned_backend: Option<crate::VadBackend>,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
@@ -172,16 +173,8 @@ impl IosCaptureState {
/// Encoder configuration is the same as cpal-side
/// `try_open_capture` (engine.rs) so audio quality is platform-
/// neutral.
#[allow(clippy::too_many_arguments)]
fn new(
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
params: &VoiceAudioParams,
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
) -> Result<Self, AudioError> {
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
@@ -190,22 +183,23 @@ impl IosCaptureState {
encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
output_muted,
frames_sent,
mic_gain,
voice_activity_selector,
voice_out_tx: params.voice_out_tx.clone(),
transmit_active: params.transmit_active.clone(),
output_muted: params.output_muted.clone(),
frames_sent: params.frames_sent.clone(),
mic_gain: params.mic_gain,
voice_activity_selector: params.voice_activity_selector.clone(),
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_vad_worker: None,
ten_vad: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
silero_model_epoch: crate::vad::silero_model_epoch(),
ten_model_epoch: crate::vad::ten_model_epoch(),
fallback_warned_backend: None,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
audio_processing_config,
audio_processing_config: params.audio_processing_config.clone(),
sonora_processor: crate::processor::SonoraProcessor::new(),
audio_processing_stats,
audio_processing_stats: params.audio_processing_stats.clone(),
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: 0,
pre_roll_buf: [[0_i16; crate::frame::FRAME_10MS_SAMPLES]; PRE_ROLL_FRAMES],
@@ -222,11 +216,20 @@ impl IosCaptureState {
return;
}
self.fallback_warned_backend = Some(failed_backend);
tracing::warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"VAD backend unavailable; using WebRTC fallback for runtime detection"
);
if self.capture_frame_seq < 128 {
tracing::info!(
target: "chanora_audio",
backend = failed_backend.as_str(),
seq = self.capture_frame_seq,
"VAD backend warming up; using WebRTC fallback"
);
} else {
tracing::warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"VAD backend unavailable; using WebRTC fallback for runtime detection"
);
}
}
/// Consume the i16 mono buffer delivered by VPIO, accumulate
@@ -360,8 +363,11 @@ impl IosCaptureState {
// Switch VAD backend when the config changes.
let silero_model_epoch = crate::vad::silero_model_epoch();
let ten_model_epoch = crate::vad::ten_model_epoch();
let silero_model_changed = vad_backend == crate::VadBackend::SileroOnnx
&& silero_model_epoch != self.silero_model_epoch;
let ten_model_changed =
vad_backend == crate::VadBackend::TenVad && ten_model_epoch != self.ten_model_epoch;
if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() {
if debug_wav_dump_enabled {
@@ -376,10 +382,11 @@ impl IosCaptureState {
}
}
if vad_backend != self.current_vad_backend || silero_model_changed {
if vad_backend != self.current_vad_backend || silero_model_changed || ten_model_changed {
self.current_vad_backend = vad_backend;
self.fallback_warned_backend = None;
self.silero_model_epoch = silero_model_epoch;
self.ten_model_epoch = ten_model_epoch;
match vad_backend {
crate::VadBackend::SileroOnnx => {
// Attempt to load Silero model from the well-known
@@ -467,12 +474,17 @@ impl IosCaptureState {
} else if vad_backend == crate::VadBackend::SileroOnnx {
if let Some(worker) = self.silero_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
if !worker.is_stale(capture_seq) {
let probability = worker.latest_probability();
crate::vad::VadOutput {
probability,
speech: probability >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
@@ -486,12 +498,17 @@ impl IosCaptureState {
} else if vad_backend == crate::VadBackend::TenVad {
if let Some(worker) = self.ten_vad.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
if !worker.is_stale(capture_seq) {
let probability = worker.latest_probability();
crate::vad::VadOutput {
probability,
speech: probability >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(crate::VadBackend::TenVad);
@@ -617,19 +634,7 @@ impl IosVoiceUnit {
///
/// Capture wiring landed in commit 3; playback wiring landed
/// in commit 4. Route-change observation is commit 5.
#[allow(clippy::too_many_arguments)]
pub fn start(
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Result<Self, AudioError> {
pub(crate) fn start(params: VoiceAudioParams) -> Result<Self, AudioError> {
// Construct the VoiceProcessingIO AudioUnit. cpal exposes
// `Default::default()` which on iOS picks the inferior
// RemoteIO unit; we explicitly pick VPIO. `coreaudio-rs`
@@ -721,7 +726,7 @@ impl IosVoiceUnit {
// because the input callback is the sole writer/reader on
// the audio thread.
let wav_recorder = Arc::new(Mutex::new({
let cfg = audio_processing_config.lock().unwrap().clone();
let cfg = params.audio_processing_config.lock().unwrap().clone();
if cfg.debug_wav_dump_enabled {
Some(crate::debug_wav::WavDebugRecorder::start(
cfg.route,
@@ -731,17 +736,7 @@ impl IosVoiceUnit {
None
}
}));
let mut capture_state = IosCaptureState::new(
voice_out_tx,
transmit_active,
output_muted.clone(),
frames_sent,
mic_gain,
voice_activity_selector,
audio_processing_config,
audio_processing_stats.clone(),
wav_recorder.clone(),
)?;
let mut capture_state = IosCaptureState::new(&params, wav_recorder.clone())?;
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
// VPIO with our pinned stream format delivers
@@ -806,9 +801,10 @@ impl IosVoiceUnit {
// buffer. Same as Linux/SDL, just stereo-f32 -> mono-i16
// converted at the boundary.
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048);
let handler_for_render = handler.clone();
let output_gain_for_render = output_gain.clone();
let output_muted_for_render = output_muted.clone();
let handler_for_render = params.handler.clone();
let output_gain_for_render = params.output_gain.clone();
let output_muted_for_render = params.output_muted.clone();
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
let wav_recorder_for_render = wav_recorder.clone();
// Diagnostic counters (sampled every 100 callbacks ~= 2 s).
let mut cb_count: u64 = 0;
@@ -841,7 +837,7 @@ impl IosVoiceUnit {
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
}
Err(std::sync::TryLockError::WouldBlock) => {
audio_processing_stats.increment_callback_xrun();
audio_processing_stats_for_render.increment_callback_xrun();
// scratch_stereo is already zeroed above.
}
Err(std::sync::TryLockError::Poisoned(e)) => {
@@ -859,9 +855,9 @@ impl IosVoiceUnit {
muted,
);
if mix_stats.clipped_samples > 0 {
audio_processing_stats.add_clipped_samples(mix_stats.clipped_samples);
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples);
}
audio_processing_stats.update_render(
audio_processing_stats_for_render.update_render(
crate::frame::dbfs(&scratch_stereo[..needed]),
num_frames as u32,
);
@@ -895,7 +891,7 @@ impl IosVoiceUnit {
if mix_stats.peak_i16 > 0 {
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
} else {
audio_processing_stats.increment_output_underrun();
audio_processing_stats_for_render.increment_output_underrun();
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
}
+2 -1
View File
@@ -62,7 +62,8 @@ pub use audio_processing::{
AudioBackend, AudioProcessingConfig, AudioProcessingStats, AudioRoute, EffectOwner,
IosVoiceProcessingMode, SharedAudioProcessingStats, VadBackend,
};
pub use engine::{AudioEngine, AudioEngineConfig};
pub use engine::list_audio_devices;
pub use engine::{AudioDeviceInfo, AudioDeviceList, AudioEngine, AudioEngineConfig};
// SDD-120 §3 bench seam — `#[doc(hidden)]` re-export so the criterion
// bench harness under `crates/chanora_audio/benches/` can construct a
@@ -17,13 +17,16 @@
// so the engine can hold a single `Box<dyn MobileVoiceAudioBackend>`
// across iOS and Android.
#![allow(dead_code)]
use std::fmt;
use std::sync::atomic::{AtomicBool, AtomicU32};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use tsclientlib::audio::AudioHandler;
use crate::engine::SessionAudioId;
use crate::AudioEffects;
use chanora_protocol::OutPacket;
/// Events the audio-callback thread or platform JNI listener can post
/// to the tokio side of the engine (SDD-115). Audio callbacks MUST
@@ -63,6 +66,36 @@ pub type BackendEventTx = mpsc::UnboundedSender<BackendEvent>;
/// against the active capture session.
pub type AudioSessionId = i32;
/// Engine-owned state shared with mobile voice audio callbacks.
#[derive(Clone)]
pub(crate) struct VoiceAudioParams {
/// Opus-encoded voice packets sent on this channel toward the
/// protocol layer.
pub voice_out_tx: mpsc::Sender<OutPacket>,
/// PTT transmission gate — true when the user holds the PTT key.
pub transmit_active: Arc<AtomicBool>,
/// Counter incremented per encoded frame sent.
pub frames_sent: Arc<AtomicU32>,
/// Pre-encode amplitude scale (1.0 = unity).
pub mic_gain: f32,
/// AudioHandler that inbound decode+mix feeds into; the output
/// callback pulls mixed stereo f32 from it.
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
/// cross-thread read from the realtime audio callback).
pub output_gain: Arc<AtomicU32>,
/// True = output silence regardless of incoming voice frames.
pub output_muted: Arc<AtomicBool>,
/// Optional TransmitModeSelector for VoiceActivity transmit mode.
/// The capture callback calls set_voice_activity_open on this when
/// VAD detects speech. None means VoiceActivity mode is disabled.
pub voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
/// Shared audio-processing config (WebRTC APM flags, VAD backend).
pub audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
/// Shared audio-processing statistics for diagnostics.
pub audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
}
/// Achieved performance-mode reported by the platform after stream
/// open (SDD-112 item 4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -734,12 +767,18 @@ mod tests {
/// open()/start()/stop()/close()).
#[test]
fn swe4_uv_047_backend_error_display() {
assert!(format!("{}", BackendError::OpenFailed("x".into())).contains("open failed"));
assert!(
format!("{}", BackendError::LifecycleFailed("y".into())).contains("lifecycle failed")
);
assert!(format!("{}", BackendError::ErrorDisconnected).contains("disconnected"));
assert!(format!("{}", BackendError::Platform("z".into())).contains("platform"));
assert!(BackendError::OpenFailed("x".into())
.to_string()
.contains("open failed"));
assert!(BackendError::LifecycleFailed("y".into())
.to_string()
.contains("lifecycle failed"));
assert!(BackendError::ErrorDisconnected
.to_string()
.contains("disconnected"));
assert!(BackendError::Platform("z".into())
.to_string()
.contains("platform"));
}
/// SWE4-UV-047: achieved enums Display stably (used in
@@ -748,15 +787,15 @@ mod tests {
#[test]
fn swe4_uv_047_achieved_enum_display_is_stable() {
assert_eq!(
format!("{}", AchievedPerformanceMode::LowLatency),
AchievedPerformanceMode::LowLatency.to_string(),
"LowLatency"
);
assert_eq!(
format!("{}", AchievedPerformanceMode::PowerSaving),
AchievedPerformanceMode::PowerSaving.to_string(),
"PowerSaving"
);
assert_eq!(format!("{}", AchievedPerformanceMode::None), "None");
assert_eq!(format!("{}", AchievedSharingMode::Exclusive), "Exclusive");
assert_eq!(format!("{}", AchievedSharingMode::Shared), "Shared");
assert_eq!(AchievedPerformanceMode::None.to_string(), "None");
assert_eq!(AchievedSharingMode::Exclusive.to_string(), "Exclusive");
assert_eq!(AchievedSharingMode::Shared.to_string(), "Shared");
}
}
-9
View File
@@ -136,15 +136,6 @@ impl SdlOutput {
_subsystem: subsystem,
})
}
/// Pause the SDL device. Used by the engine on hard mute /
/// shutdown if we ever want to stop the callback firing while
/// keeping the device handle alive. Not currently invoked —
/// the engine drops `SdlOutput` entirely on stop.
#[allow(dead_code)]
pub fn pause(&self) {
self.device.pause();
}
}
impl Drop for SdlOutput {
+9 -14
View File
@@ -76,6 +76,8 @@ pub struct SileroOnnxVad {
enum SileroInner {
Onnx(OnnxSession),
#[cfg(test)]
Stub,
}
struct OnnxSession {
@@ -179,9 +181,13 @@ impl SileroOnnxVad {
use ort::value::Value;
use tracing::error;
let SileroInner::Onnx(ref mut inner) = self.inner else {
return self.last_probability;
#[cfg(test)]
let inner = match self.inner {
SileroInner::Onnx(ref mut inner) => inner,
SileroInner::Stub => return self.last_probability,
};
#[cfg(not(test))]
let SileroInner::Onnx(ref mut inner) = self.inner;
debug_assert_eq!(audio_frame.len(), SILERO_FRAME_16K);
@@ -259,17 +265,6 @@ impl SileroOnnxVad {
}
}
#[cfg(target_os = "macos")]
pub(crate) fn bundled_onnxruntime_path_for_vad() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let app_dir = exe.parent()?;
let framework = app_dir
.join("Frameworks")
.join("onnxruntime.framework")
.join("onnxruntime");
framework.exists().then_some(framework)
}
impl VoiceActivityDetector for SileroOnnxVad {
/// Accept one 10 ms **16 kHz** f32 mono frame (160 samples).
///
@@ -333,7 +328,7 @@ impl SileroOnnxVadWorker {
let latest_probability = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
let latest_processed_seq = Arc::new(AtomicU64::new(u64::MAX));
let alive = Arc::new(AtomicBool::new(true));
let (tx, rx) = std::sync::mpsc::sync_channel::<SileroFrameMessage>(32);
let (tx, rx) = std::sync::mpsc::sync_channel::<SileroFrameMessage>(64);
let latest_probability_for_thread = latest_probability.clone();
let latest_processed_seq_for_thread = latest_processed_seq.clone();
let alive_for_thread = alive.clone();
+52 -32
View File
@@ -8,6 +8,8 @@
use crate::frame::f32_to_i16;
use rustfft::{num_complex::Complex32, FftPlanner};
use super::resampler::{Downsampler48to16, INPUT_FRAME_10MS};
use super::{VadOutput, VoiceActivityDetector};
@@ -49,6 +51,8 @@ pub struct TenOnnxVad {
feature_stack: [[f32; FEATURE_LEN]; CONTEXT],
states: [[f32; HIDDEN]; 4],
mel_filters: Vec<[f32; N_BINS]>,
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
fft_buffer: Vec<Complex32>,
last_probability: f32,
last_speech: bool,
}
@@ -75,6 +79,8 @@ impl TenOnnxVad {
return None;
}
};
let mut fft_planner = FftPlanner::<f32>::new();
let fft = fft_planner.plan_fft_forward(FFT_SIZE);
tracing::info!(target: "chanora_audio", path = model_path, "TEN VAD ONNX model loaded");
Some(Self {
session,
@@ -84,6 +90,8 @@ impl TenOnnxVad {
feature_stack: [[0.0; FEATURE_LEN]; CONTEXT],
states: [[0.0; HIDDEN]; 4],
mel_filters: build_mel_filters(),
fft,
fft_buffer: vec![Complex32::ZERO; FFT_SIZE],
last_probability: 0.0,
last_speech: false,
})
@@ -104,7 +112,12 @@ impl TenOnnxVad {
self.sample_fifo.drain(..excess);
}
let feature = compute_feature(&self.mel_filters, &frame);
let feature = compute_feature(
&self.mel_filters,
self.fft.as_ref(),
&mut self.fft_buffer,
&frame,
);
self.feature_stack.copy_within(1..CONTEXT, 0);
self.feature_stack[CONTEXT - 1] = feature;
self.run_onnx();
@@ -169,19 +182,13 @@ impl TenOnnxVad {
}
}
#[cfg(target_os = "macos")]
fn bundled_onnxruntime_path() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let app_dir = exe.parent()?;
let framework = app_dir
.join("Frameworks")
.join("onnxruntime.framework")
.join("onnxruntime");
framework.exists().then_some(framework)
}
fn compute_feature(mel_filters: &[[f32; N_BINS]], frame: &[f32]) -> [f32; FEATURE_LEN] {
let power = power_spectrum(frame);
fn compute_feature(
mel_filters: &[[f32; N_BINS]],
fft: &dyn rustfft::Fft<f32>,
fft_buffer: &mut [Complex32],
frame: &[f32],
) -> [f32; FEATURE_LEN] {
let power = power_spectrum(fft, fft_buffer, frame);
let mut feature = [0.0; FEATURE_LEN];
for band in 0..MEL_BANDS {
let energy = mel_filters[band]
@@ -245,33 +252,43 @@ fn build_mel_filters() -> Vec<[f32; N_BINS]> {
let left = bins[band];
let center = bins[band + 1].max(left + 1);
let right = bins[band + 2].max(center + 1).min(N_BINS - 1);
for i in left..center.min(N_BINS) {
filters[band][i] = (i - left) as f32 / (center - left) as f32;
for (i, weight) in filters[band]
.iter_mut()
.enumerate()
.take(center.min(N_BINS))
.skip(left)
{
*weight = (i - left) as f32 / (center - left) as f32;
}
for i in center..=right {
filters[band][i] = (right - i) as f32 / (right - center).max(1) as f32;
for (i, weight) in filters[band]
.iter_mut()
.enumerate()
.take(right + 1)
.skip(center)
{
*weight = (right - i) as f32 / (right - center).max(1) as f32;
}
}
filters
}
fn power_spectrum(frame: &[f32]) -> [f32; N_BINS] {
let mut windowed = [0.0_f32; FFT_SIZE];
fn power_spectrum(
fft: &dyn rustfft::Fft<f32>,
fft_buffer: &mut [Complex32],
frame: &[f32],
) -> [f32; N_BINS] {
debug_assert_eq!(fft_buffer.len(), FFT_SIZE);
fft_buffer.fill(Complex32::ZERO);
for (idx, sample) in frame.iter().take(WINDOW_16K).enumerate() {
let hann = 0.5 - 0.5 * (2.0 * std::f32::consts::PI * idx as f32 / WINDOW_16K as f32).cos();
windowed[idx] = f32_to_i16(*sample) as f32 * hann;
fft_buffer[idx].re = f32_to_i16(*sample) as f32 * hann;
}
fft.process(fft_buffer);
let mut out = [0.0_f32; N_BINS];
for (k, dst) in out.iter_mut().enumerate() {
let mut re = 0.0_f32;
let mut im = 0.0_f32;
for (n, &x) in windowed.iter().enumerate() {
let phase = -2.0 * std::f32::consts::PI * k as f32 * n as f32 / FFT_SIZE as f32;
re += x * phase.cos();
im += x * phase.sin();
}
*dst = re * re + im * im;
for (dst, bin) in out.iter_mut().zip(fft_buffer.iter()) {
*dst = bin.norm_sqr();
}
out
}
@@ -340,7 +357,7 @@ impl TenOnnxVadWorker {
let latest_probability = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
let latest_processed_seq = Arc::new(AtomicU64::new(u64::MAX));
let alive = Arc::new(AtomicBool::new(true));
let (tx, rx) = std::sync::mpsc::sync_channel::<TenFrameMessage>(32);
let (tx, rx) = std::sync::mpsc::sync_channel::<TenFrameMessage>(128);
let prob_arc = latest_probability.clone();
let seq_arc = latest_processed_seq.clone();
let alive_arc = alive.clone();
@@ -425,8 +442,11 @@ mod tests {
#[test]
fn preprocessing_produces_finite_features() {
let filters = build_mel_filters();
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(FFT_SIZE);
let mut fft_buffer = vec![Complex32::ZERO; FFT_SIZE];
let frame = vec![0.0_f32; WINDOW_16K];
let feature = compute_feature(&filters, &frame);
let feature = compute_feature(&filters, fft.as_ref(), &mut fft_buffer, &frame);
assert!(feature.iter().all(|v| v.is_finite()));
}
}
-1
View File
@@ -17,7 +17,6 @@ crate-type = ["cdylib", "staticlib", "rlib"]
[dependencies]
chanora_core = { path = "../../core/chanora_core" }
chanora_protocol = { path = "../chanora_protocol" }
chanora_audio = { path = "../chanora_audio" }
flutter_rust_bridge = "=2.12.0"
thiserror.workspace = true
+249 -40
View File
@@ -80,7 +80,10 @@ fn session() -> &'static chanora_core::ChanoraSession {
fn log_sink() -> &'static chanora_core::InMemoryLogSink {
static SINK: OnceLock<chanora_core::InMemoryLogSink> = OnceLock::new();
SINK.get_or_init(|| {
chanora_core::InMemoryLogSink::new(500, chanora_core::Redactor::with_default_policy())
chanora_core::InMemoryLogSink::new(
chanora_core::DEFAULT_LOG_CAPACITY,
chanora_core::Redactor::with_default_policy(),
)
})
}
@@ -342,6 +345,9 @@ pub struct BridgeChannel {
pub order: i64,
/// True when the server marks the channel as password-protected.
pub has_password: bool,
/// Talk power threshold required to speak in this channel.
/// None means no talk-power restriction.
pub needed_talk_power: Option<i32>,
}
/// Client as seen by Dart.
@@ -361,6 +367,10 @@ pub struct BridgeClient {
pub is_speaking: bool,
/// True for TeamSpeak ServerQuery clients.
pub is_server_query: bool,
/// Current talk power value assigned by the server.
pub talk_power: i32,
/// True when the server has granted talk power regardless of numeric value.
pub talk_power_granted: bool,
}
/// Server snapshot as seen by Dart.
@@ -384,8 +394,8 @@ pub struct BridgeSnapshot {
pub own_client_id: u64,
}
impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
fn from(s: chanora_protocol::ServerSnapshot) -> Self {
impl From<chanora_core::ServerSnapshot> for BridgeSnapshot {
fn from(s: chanora_core::ServerSnapshot) -> Self {
Self {
server_name: s.server_name,
welcome_message: s.welcome_message,
@@ -400,6 +410,7 @@ impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
name: c.name,
order: c.order,
has_password: c.has_password,
needed_talk_power: c.needed_talk_power,
})
.collect(),
clients: s
@@ -413,6 +424,8 @@ impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
output_muted: c.output_muted,
is_speaking: c.is_speaking,
is_server_query: c.is_server_query,
talk_power: c.talk_power,
talk_power_granted: c.talk_power_granted,
})
.collect(),
own_client_id: s.own_client_id,
@@ -501,24 +514,6 @@ pub fn handle_route_change(route: BridgeAudioRoute) {
}
}
/// Handle iOS AVAudioSession media-services reset (legacy, no route arg).
///
/// Called by the existing FRB-generated Dart binding. Uses
/// `AudioRoute::Unknown` which triggers a route-change recompute.
/// The AppDelegate now also calls `handle_media_services_reset_with_route`
/// directly after rebuilding the session.
#[frb(sync)]
pub fn handle_media_services_reset() {
let result = runtime().block_on(async {
session()
.ios_handle_media_services_reset(chanora_audio::AudioRoute::Unknown)
.await
});
if let Err(e) = result {
warn!(target: "chanora_bridge", error = %e, "iOS media-services reset handling failed");
}
}
/// Handle iOS AVAudioSession media-services reset with the current
/// route class. Called by AppDelegate after rebuilding the session.
///
@@ -552,11 +547,11 @@ pub fn handle_interruption_ended(should_resume: bool) {
}
}
/// Set the push-to-talk state.
/// Set the focused/on-screen push-to-talk hold state.
///
/// Superseded in v1 by [`set_transmit_mode`] + the binding capture
/// dialog. Retained so legacy callers and integration tests keep
/// working; the new VoiceBar UI no longer invokes this.
/// Binding capture chooses which physical key drives PTT, while this
/// command carries the actual press/release edge for fallback focused
/// keyboard handling and touch controls.
pub async fn set_ptt(active: bool) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_ptt(active).await })
@@ -691,6 +686,46 @@ pub enum BridgePttInputClass {
MouseSideButton,
}
/// Privacy-safe PTT capability descriptor for the UI.
#[derive(Debug, Clone)]
pub struct BridgePttDescriptor {
/// Stable capability level name.
pub level: String,
/// Stable backend identifier.
pub backend_id: String,
/// Coarse bound input class; empty when no binding is active.
pub bound_input_class: String,
}
impl From<chanora_core::PttDescriptorSnapshot> for BridgePttDescriptor {
fn from(desc: chanora_core::PttDescriptorSnapshot) -> Self {
Self {
level: desc.level,
backend_id: desc.backend_id,
bound_input_class: desc.bound_input_class,
}
}
}
/// Persisted PTT binding display state for the UI.
#[derive(Debug, Clone)]
pub struct BridgePttBinding {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
pub input_class: String,
/// Display-only key label; empty when no binding is active.
pub key_label: String,
}
impl From<chanora_core::PersistedPttBinding> for BridgePttBinding {
fn from(binding: chanora_core::PersistedPttBinding) -> Self {
Self {
input_class: binding.input_class,
key_label: binding.key_label,
}
}
}
impl From<BridgePttInputClass> for chanora_core::PttInputClass {
fn from(c: BridgePttInputClass) -> Self {
match c {
@@ -721,27 +756,34 @@ pub async fn set_ptt_binding(
Ok(())
}
/// Read the current PTT capability descriptor. Returns a
/// `(level, backend_id, bound_input_class)` triple matching the
/// privacy-safe `BridgeEvent::PttCapability` event shape; useful
/// for the initial UI render before the first event arrives.
pub async fn ptt_descriptor() -> (String, String, String) {
/// Read the current PTT capability descriptor. Matches the privacy-safe
/// `BridgeEvent::PttCapability` event shape; useful for the initial UI
/// render before the first event arrives.
pub async fn ptt_descriptor() -> BridgePttDescriptor {
runtime()
.spawn(async { session().ptt_descriptor().await })
.await
.unwrap_or_else(|_| (String::new(), String::new(), String::new()))
.map(Into::into)
.unwrap_or_else(|_| BridgePttDescriptor {
level: String::new(),
backend_id: String::new(),
bound_input_class: String::new(),
})
}
/// Return the persisted PTT binding as a
/// `(input_class, platform_key)` pair so the UI can hydrate its
/// display state at launch (e.g. show "PTT: Space" next to the
/// badge before the user re-opens the binding dialog). Empty
/// strings mean no binding has been persisted yet.
pub async fn get_ptt_binding() -> (String, String) {
/// Return the persisted PTT binding so the UI can hydrate its display
/// state at launch (e.g. show "PTT: Space" next to the badge before the
/// user re-opens the binding dialog). Empty strings mean no binding has
/// been persisted yet.
pub async fn get_ptt_binding() -> BridgePttBinding {
runtime()
.spawn(async { session().get_ptt_binding().await })
.await
.unwrap_or_else(|_| (String::new(), String::new()))
.map(Into::into)
.unwrap_or_else(|_| BridgePttBinding {
input_class: String::new(),
key_label: String::new(),
})
}
/// Move our own client to `channel_id`. Optional channel password
@@ -794,6 +836,36 @@ pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> {
Ok(())
}
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. No-op when client has no active voice queue. Volume is
/// applied directly to the tsclientlib AudioQueue and takes effect
/// immediately on the next render callback.
pub async fn set_client_volume(client_id: u64, volume: f32) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_client_volume(client_id, volume).await })
.await
.map_err(|e| task_join_error("set_client_volume", e))??;
Ok(())
}
/// Send a text message to the specified target.
pub async fn send_chat_message(
message: String,
target: BridgeMessageTarget,
) -> Result<(), BridgeError> {
let target_core: chanora_core::MessageTarget = match target {
BridgeMessageTarget::Server => chanora_core::MessageTarget::Server,
BridgeMessageTarget::Channel => chanora_core::MessageTarget::Channel,
BridgeMessageTarget::Client(id) => chanora_core::MessageTarget::Client(id),
BridgeMessageTarget::Poke(id) => chanora_core::MessageTarget::Poke(id),
};
runtime()
.spawn(async move { session().send_text_message(message, target_core).await })
.await
.map_err(|e| task_join_error("send_chat_message", e))??;
Ok(())
}
/// Statistics from the audio engine.
#[derive(Debug, Clone)]
pub struct BridgeAudioStats {
@@ -1164,8 +1236,14 @@ pub fn export_diagnostics() -> String {
let android_audio_yaml =
chanora_audio::mobile_voice_backend::current_android_audio_diagnostics()
.map(|d| d.to_yaml_fragment());
let network_info = runtime().block_on(async { session().network_diagnostics_summary().await });
let protocol_events = runtime().block_on(async { session().drain_protocol_events().await });
match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) {
Ok(exp) => exp.with_android_audio(android_audio_yaml).to_text(),
Ok(exp) => exp
.with_android_audio(android_audio_yaml)
.with_network_info(Some(network_info))
.with_protocol_events(protocol_events)
.to_text(),
Err(e) => format!("(diagnostic export failed: {e})"),
}
}
@@ -1417,6 +1495,46 @@ pub enum BridgeEvent {
/// Resolved permission state.
state: PermissionStateKind,
},
/// A text message received from the server.
ChatMessage {
/// Client id of the sender.
sender_id: u64,
/// Nickname of the sender.
sender_name: String,
/// Message content.
message: String,
/// Target scope (server/channel/private/poke).
target: BridgeMessageTarget,
},
/// Audio route changed (speaker/earpiece/BT/wired).
AudioRouteChanged {
/// The new audio route.
route: BridgeAudioRoute,
},
}
/// Bridge message target scope.
#[derive(Debug, Clone, Copy)]
pub enum BridgeMessageTarget {
/// Broadcast to entire server.
Server,
/// Broadcast to current channel.
Channel,
/// Private message to a specific client.
Client(u64),
/// Poke a specific client.
Poke(u64),
}
impl From<chanora_core::MessageTarget> for BridgeMessageTarget {
fn from(t: chanora_core::MessageTarget) -> Self {
match t {
chanora_core::MessageTarget::Server => Self::Server,
chanora_core::MessageTarget::Channel => Self::Channel,
chanora_core::MessageTarget::Client(id) => Self::Client(id),
chanora_core::MessageTarget::Poke(id) => Self::Poke(id),
}
}
}
/// Bridge mirror of core join projection sync state.
@@ -1563,6 +1681,22 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
began,
should_resume,
},
chanora_core::SessionEvent::ChatMessage {
sender_id,
sender_name,
message,
target,
} => BridgeEvent::ChatMessage {
sender_id,
sender_name,
message,
target: target.into(),
},
chanora_core::SessionEvent::AudioRouteChanged { route } => {
BridgeEvent::AudioRouteChanged {
route: route.into(),
}
}
}
}
}
@@ -1711,6 +1845,66 @@ pub async fn audio_processing_stats() -> Result<BridgeAudioProcessingStats, Brid
Ok(stats.into())
}
/// Audio device info from the platform.
#[derive(Debug, Clone)]
pub struct BridgeAudioDevice {
/// Human-readable device name.
pub name: String,
/// True if the OS reports this as the default device.
pub is_default: bool,
}
/// List of available audio devices.
#[derive(Debug, Clone)]
pub struct BridgeAudioDeviceList {
/// Available input devices.
pub input_devices: Vec<BridgeAudioDevice>,
/// Available output devices.
pub output_devices: Vec<BridgeAudioDevice>,
}
/// List available audio input and output devices from the platform.
pub fn list_audio_devices() -> BridgeAudioDeviceList {
let list = chanora_audio::list_audio_devices();
BridgeAudioDeviceList {
input_devices: list
.input_devices
.into_iter()
.map(|d| BridgeAudioDevice {
name: d.name,
is_default: d.is_default,
})
.collect(),
output_devices: list
.output_devices
.into_iter()
.map(|d| BridgeAudioDevice {
name: d.name,
is_default: d.is_default,
})
.collect(),
}
}
/// Set the preferred input device by name. Takes effect on next
/// `start_audio`.
pub async fn set_input_device(name: Option<String>) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_input_device(name).await })
.await
.map_err(|e| task_join_error("set_input_device", e))??;
Ok(())
}
/// Set the preferred output device by name.
pub async fn set_output_device(name: Option<String>) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_output_device(name).await })
.await
.map_err(|e| task_join_error("set_output_device", e))??;
Ok(())
}
/// Configure the VAD model path.
pub async fn set_vad_model_path(path: String) -> Result<(), BridgeError> {
if path.trim().is_empty() {
@@ -1733,7 +1927,7 @@ pub async fn set_ten_vad_model_path(path: String) -> Result<(), BridgeError> {
));
}
runtime()
.spawn(async move { chanora_audio::vad::set_ten_model_path(&path).map_err(|e| e) })
.spawn(async move { chanora_audio::vad::set_ten_model_path(&path) })
.await
.map_err(|e| task_join_error("set_ten_vad_model_path", e))?
.map_err(|e| BridgeError::Unmapped(format!("set_ten_vad_model_path: {e}")))?;
@@ -1785,3 +1979,18 @@ pub async fn set_ios_voice_processing_mode(
};
set_audio_processing_config(config).await
}
/// Set the preferred audio output route (Android/iOS).
#[frb(sync)]
pub fn set_audio_output_route(route: BridgeAudioRoute) {
runtime().block_on(async {
let _ = session().ios_handle_route_change(route.into()).await;
});
}
/// Called from Flutter when the app enters background/foreground.
#[frb(sync)]
pub fn record_lifecycle_event(state: String) {
runtime().block_on(async {
session().record_lifecycle_event(&state).await;
});
}
+639 -90
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -436507436;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1433826599;
// Section: executor
@@ -602,37 +602,6 @@ fn wire__crate__api__handle_interruption_ended_impl(
},
)
}
fn wire__crate__api__handle_media_services_reset_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "handle_media_services_reset",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::handle_media_services_reset();
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__handle_media_services_reset_with_route_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
@@ -768,6 +737,38 @@ fn wire__crate__api__is_connected_impl(
},
)
}
fn wire__crate__api__list_audio_devices_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "list_audio_devices",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| {
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok(crate::api::list_audio_devices())?;
Ok(output_ok)
})())
}
},
)
}
fn wire__crate__api__list_bookmarks_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -905,6 +906,108 @@ fn wire__crate__api__ptt_descriptor_impl(
},
)
}
fn wire__crate__api__record_lifecycle_event_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "record_lifecycle_event",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_state = <String>::sse_decode(&mut deserializer);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::record_lifecycle_event(api_state);
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__send_chat_message_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "send_chat_message",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_message = <String>::sse_decode(&mut deserializer);
let api_target = <crate::api::BridgeMessageTarget>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok =
crate::api::send_chat_message(api_message, api_target).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__set_audio_output_route_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "set_audio_output_route",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_route = <crate::api::BridgeAudioRoute>::sse_decode(&mut deserializer);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::set_audio_output_route(api_route);
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__set_audio_processing_config_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -942,6 +1045,44 @@ fn wire__crate__api__set_audio_processing_config_impl(
},
)
}
fn wire__crate__api__set_client_volume_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "set_client_volume",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_client_id = <u64>::sse_decode(&mut deserializer);
let api_volume = <f32>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok =
crate::api::set_client_volume(api_client_id, api_volume).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__set_hard_mute_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -978,6 +1119,42 @@ fn wire__crate__api__set_hard_mute_impl(
},
)
}
fn wire__crate__api__set_input_device_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "set_input_device",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_name = <Option<String>>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::set_input_device(api_name).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__set_input_muted_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1083,6 +1260,42 @@ fn wire__crate__api__set_network_state_impl(
},
)
}
fn wire__crate__api__set_output_device_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "set_output_device",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_name = <Option<String>>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::set_output_device(api_name).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__set_output_gain_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1567,6 +1780,30 @@ impl SseDecode for crate::api::BridgeAudioBackend {
}
}
impl SseDecode for crate::api::BridgeAudioDevice {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_name = <String>::sse_decode(deserializer);
let mut var_isDefault = <bool>::sse_decode(deserializer);
return crate::api::BridgeAudioDevice {
name: var_name,
is_default: var_isDefault,
};
}
}
impl SseDecode for crate::api::BridgeAudioDeviceList {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_inputDevices = <Vec<crate::api::BridgeAudioDevice>>::sse_decode(deserializer);
let mut var_outputDevices = <Vec<crate::api::BridgeAudioDevice>>::sse_decode(deserializer);
return crate::api::BridgeAudioDeviceList {
input_devices: var_inputDevices,
output_devices: var_outputDevices,
};
}
}
impl SseDecode for crate::api::BridgeAudioProcessingConfig {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1704,12 +1941,14 @@ impl SseDecode for crate::api::BridgeChannel {
let mut var_name = <String>::sse_decode(deserializer);
let mut var_order = <i64>::sse_decode(deserializer);
let mut var_hasPassword = <bool>::sse_decode(deserializer);
let mut var_neededTalkPower = <Option<i32>>::sse_decode(deserializer);
return crate::api::BridgeChannel {
id: var_id,
parent: var_parent,
name: var_name,
order: var_order,
has_password: var_hasPassword,
needed_talk_power: var_neededTalkPower,
};
}
}
@@ -1724,6 +1963,8 @@ impl SseDecode for crate::api::BridgeClient {
let mut var_outputMuted = <bool>::sse_decode(deserializer);
let mut var_isSpeaking = <bool>::sse_decode(deserializer);
let mut var_isServerQuery = <bool>::sse_decode(deserializer);
let mut var_talkPower = <i32>::sse_decode(deserializer);
let mut var_talkPowerGranted = <bool>::sse_decode(deserializer);
return crate::api::BridgeClient {
id: var_id,
channel: var_channel,
@@ -1732,6 +1973,8 @@ impl SseDecode for crate::api::BridgeClient {
output_muted: var_outputMuted,
is_speaking: var_isSpeaking,
is_server_query: var_isServerQuery,
talk_power: var_talkPower,
talk_power_granted: var_talkPowerGranted,
};
}
}
@@ -1891,6 +2134,22 @@ impl SseDecode for crate::api::BridgeEvent {
state: var_state,
};
}
11 => {
let mut var_senderId = <u64>::sse_decode(deserializer);
let mut var_senderName = <String>::sse_decode(deserializer);
let mut var_message = <String>::sse_decode(deserializer);
let mut var_target = <crate::api::BridgeMessageTarget>::sse_decode(deserializer);
return crate::api::BridgeEvent::ChatMessage {
sender_id: var_senderId,
sender_name: var_senderName,
message: var_message,
target: var_target,
};
}
12 => {
let mut var_route = <crate::api::BridgeAudioRoute>::sse_decode(deserializer);
return crate::api::BridgeEvent::AudioRouteChanged { route: var_route };
}
_ => {
unimplemented!("");
}
@@ -1913,6 +2172,32 @@ impl SseDecode for crate::api::BridgeIosVoiceProcessingMode {
}
}
impl SseDecode for crate::api::BridgeMessageTarget {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut tag_ = <i32>::sse_decode(deserializer);
match tag_ {
0 => {
return crate::api::BridgeMessageTarget::Server;
}
1 => {
return crate::api::BridgeMessageTarget::Channel;
}
2 => {
let mut var_field0 = <u64>::sse_decode(deserializer);
return crate::api::BridgeMessageTarget::Client(var_field0);
}
3 => {
let mut var_field0 = <u64>::sse_decode(deserializer);
return crate::api::BridgeMessageTarget::Poke(var_field0);
}
_ => {
unimplemented!("");
}
}
}
}
impl SseDecode for crate::api::BridgeNetworkState {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1926,6 +2211,32 @@ impl SseDecode for crate::api::BridgeNetworkState {
}
}
impl SseDecode for crate::api::BridgePttBinding {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_inputClass = <String>::sse_decode(deserializer);
let mut var_keyLabel = <String>::sse_decode(deserializer);
return crate::api::BridgePttBinding {
input_class: var_inputClass,
key_label: var_keyLabel,
};
}
}
impl SseDecode for crate::api::BridgePttDescriptor {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_level = <String>::sse_decode(deserializer);
let mut var_backendId = <String>::sse_decode(deserializer);
let mut var_boundInputClass = <String>::sse_decode(deserializer);
return crate::api::BridgePttDescriptor {
level: var_level,
backend_id: var_backendId,
bound_input_class: var_boundInputClass,
};
}
}
impl SseDecode for crate::api::BridgePttInputClass {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2044,6 +2355,18 @@ impl SseDecode for i64 {
}
}
impl SseDecode for Vec<crate::api::BridgeAudioDevice> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut len_ = <i32>::sse_decode(deserializer);
let mut ans_ = Vec::with_capacity(len_ as usize);
for idx_ in 0..len_ {
ans_.push(<crate::api::BridgeAudioDevice>::sse_decode(deserializer));
}
return ans_;
}
}
impl SseDecode for Vec<crate::api::BridgeBookmark> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2092,6 +2415,17 @@ impl SseDecode for Vec<u8> {
}
}
impl SseDecode for Option<String> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
if (<bool>::sse_decode(deserializer)) {
return Some(<String>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for Option<crate::api::BridgeVoiceJoinErrorCode> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2105,6 +2439,17 @@ impl SseDecode for Option<crate::api::BridgeVoiceJoinErrorCode> {
}
}
impl SseDecode for Option<i32> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
if (<bool>::sse_decode(deserializer)) {
return Some(<i32>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for Option<u64> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2130,25 +2475,6 @@ impl SseDecode for crate::api::PermissionStateKind {
}
}
impl SseDecode for (String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_field0 = <String>::sse_decode(deserializer);
let mut var_field1 = <String>::sse_decode(deserializer);
return (var_field0, var_field1);
}
}
impl SseDecode for (String, String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_field0 = <String>::sse_decode(deserializer);
let mut var_field1 = <String>::sse_decode(deserializer);
let mut var_field2 = <String>::sse_decode(deserializer);
return (var_field0, var_field1, var_field2);
}
}
impl SseDecode for u32 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2197,29 +2523,34 @@ fn pde_ffi_dispatcher_primary_impl(
12 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
29 => {
27 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
34 => {
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
}
31 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
36 => wire__crate__api__set_ten_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
36 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_ten_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2235,15 +2566,16 @@ fn pde_ffi_dispatcher_sync_impl(
10 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
15 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
16 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
17 => wire__crate__api__handle_media_services_reset_impl(ptr, rust_vec_len, data_len),
18 => wire__crate__api__handle_media_services_reset_with_route_impl(
17 => wire__crate__api__handle_media_services_reset_with_route_impl(
ptr,
rust_vec_len,
data_len,
),
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
18 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
23 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
26 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
28 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2274,6 +2606,45 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioBackend>
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioDevice {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.name.into_into_dart().into_dart(),
self.is_default.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeAudioDevice {}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioDevice>
for crate::api::BridgeAudioDevice
{
fn into_into_dart(self) -> crate::api::BridgeAudioDevice {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioDeviceList {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.input_devices.into_into_dart().into_dart(),
self.output_devices.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgeAudioDeviceList
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioDeviceList>
for crate::api::BridgeAudioDeviceList
{
fn into_into_dart(self) -> crate::api::BridgeAudioDeviceList {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioProcessingConfig {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@@ -2414,6 +2785,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeChannel {
self.name.into_into_dart().into_dart(),
self.order.into_into_dart().into_dart(),
self.has_password.into_into_dart().into_dart(),
self.needed_talk_power.into_into_dart().into_dart(),
]
.into_dart()
}
@@ -2435,6 +2807,8 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeClient {
self.output_muted.into_into_dart().into_dart(),
self.is_speaking.into_into_dart().into_dart(),
self.is_server_query.into_into_dart().into_dart(),
self.talk_power.into_into_dart().into_dart(),
self.talk_power_granted.into_into_dart().into_dart(),
]
.into_dart()
}
@@ -2586,6 +2960,22 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
state.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::ChatMessage {
sender_id,
sender_name,
message,
target,
} => [
11.into_dart(),
sender_id.into_into_dart().into_dart(),
sender_name.into_into_dart().into_dart(),
message.into_into_dart().into_dart(),
target.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::AudioRouteChanged { route } => {
[12.into_dart(), route.into_into_dart().into_dart()].into_dart()
}
_ => {
unimplemented!("");
}
@@ -2620,6 +3010,35 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeIosVoiceProcessingMode>
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeMessageTarget {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
crate::api::BridgeMessageTarget::Server => [0.into_dart()].into_dart(),
crate::api::BridgeMessageTarget::Channel => [1.into_dart()].into_dart(),
crate::api::BridgeMessageTarget::Client(field0) => {
[2.into_dart(), field0.into_into_dart().into_dart()].into_dart()
}
crate::api::BridgeMessageTarget::Poke(field0) => {
[3.into_dart(), field0.into_into_dart().into_dart()].into_dart()
}
_ => {
unimplemented!("");
}
}
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgeMessageTarget
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeMessageTarget>
for crate::api::BridgeMessageTarget
{
fn into_into_dart(self) -> crate::api::BridgeMessageTarget {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeNetworkState {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
@@ -2642,6 +3061,46 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeNetworkState>
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgePttBinding {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.input_class.into_into_dart().into_dart(),
self.key_label.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgePttBinding {}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgePttBinding>
for crate::api::BridgePttBinding
{
fn into_into_dart(self) -> crate::api::BridgePttBinding {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgePttDescriptor {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.level.into_into_dart().into_dart(),
self.backend_id.into_into_dart().into_dart(),
self.bound_input_class.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgePttDescriptor
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgePttDescriptor>
for crate::api::BridgePttDescriptor
{
fn into_into_dart(self) -> crate::api::BridgePttDescriptor {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgePttInputClass {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
@@ -2851,6 +3310,22 @@ impl SseEncode for crate::api::BridgeAudioBackend {
}
}
impl SseEncode for crate::api::BridgeAudioDevice {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.name, serializer);
<bool>::sse_encode(self.is_default, serializer);
}
}
impl SseEncode for crate::api::BridgeAudioDeviceList {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<Vec<crate::api::BridgeAudioDevice>>::sse_encode(self.input_devices, serializer);
<Vec<crate::api::BridgeAudioDevice>>::sse_encode(self.output_devices, serializer);
}
}
impl SseEncode for crate::api::BridgeAudioProcessingConfig {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -2946,6 +3421,7 @@ impl SseEncode for crate::api::BridgeChannel {
<String>::sse_encode(self.name, serializer);
<i64>::sse_encode(self.order, serializer);
<bool>::sse_encode(self.has_password, serializer);
<Option<i32>>::sse_encode(self.needed_talk_power, serializer);
}
}
@@ -2959,6 +3435,8 @@ impl SseEncode for crate::api::BridgeClient {
<bool>::sse_encode(self.output_muted, serializer);
<bool>::sse_encode(self.is_speaking, serializer);
<bool>::sse_encode(self.is_server_query, serializer);
<i32>::sse_encode(self.talk_power, serializer);
<bool>::sse_encode(self.talk_power_granted, serializer);
}
}
@@ -3105,6 +3583,22 @@ impl SseEncode for crate::api::BridgeEvent {
<String>::sse_encode(permission, serializer);
<crate::api::PermissionStateKind>::sse_encode(state, serializer);
}
crate::api::BridgeEvent::ChatMessage {
sender_id,
sender_name,
message,
target,
} => {
<i32>::sse_encode(11, serializer);
<u64>::sse_encode(sender_id, serializer);
<String>::sse_encode(sender_name, serializer);
<String>::sse_encode(message, serializer);
<crate::api::BridgeMessageTarget>::sse_encode(target, serializer);
}
crate::api::BridgeEvent::AudioRouteChanged { route } => {
<i32>::sse_encode(12, serializer);
<crate::api::BridgeAudioRoute>::sse_encode(route, serializer);
}
_ => {
unimplemented!("");
}
@@ -3128,6 +3622,31 @@ impl SseEncode for crate::api::BridgeIosVoiceProcessingMode {
}
}
impl SseEncode for crate::api::BridgeMessageTarget {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
match self {
crate::api::BridgeMessageTarget::Server => {
<i32>::sse_encode(0, serializer);
}
crate::api::BridgeMessageTarget::Channel => {
<i32>::sse_encode(1, serializer);
}
crate::api::BridgeMessageTarget::Client(field0) => {
<i32>::sse_encode(2, serializer);
<u64>::sse_encode(field0, serializer);
}
crate::api::BridgeMessageTarget::Poke(field0) => {
<i32>::sse_encode(3, serializer);
<u64>::sse_encode(field0, serializer);
}
_ => {
unimplemented!("");
}
}
}
}
impl SseEncode for crate::api::BridgeNetworkState {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3145,6 +3664,23 @@ impl SseEncode for crate::api::BridgeNetworkState {
}
}
impl SseEncode for crate::api::BridgePttBinding {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.input_class, serializer);
<String>::sse_encode(self.key_label, serializer);
}
}
impl SseEncode for crate::api::BridgePttDescriptor {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.level, serializer);
<String>::sse_encode(self.backend_id, serializer);
<String>::sse_encode(self.bound_input_class, serializer);
}
}
impl SseEncode for crate::api::BridgePttInputClass {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3274,6 +3810,16 @@ impl SseEncode for i64 {
}
}
impl SseEncode for Vec<crate::api::BridgeAudioDevice> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i32>::sse_encode(self.len() as _, serializer);
for item in self {
<crate::api::BridgeAudioDevice>::sse_encode(item, serializer);
}
}
}
impl SseEncode for Vec<crate::api::BridgeBookmark> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3314,6 +3860,16 @@ impl SseEncode for Vec<u8> {
}
}
impl SseEncode for Option<String> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<String>::sse_encode(value, serializer);
}
}
}
impl SseEncode for Option<crate::api::BridgeVoiceJoinErrorCode> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3324,6 +3880,16 @@ impl SseEncode for Option<crate::api::BridgeVoiceJoinErrorCode> {
}
}
impl SseEncode for Option<i32> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<i32>::sse_encode(value, serializer);
}
}
}
impl SseEncode for Option<u64> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3352,23 +3918,6 @@ impl SseEncode for crate::api::PermissionStateKind {
}
}
impl SseEncode for (String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.0, serializer);
<String>::sse_encode(self.1, serializer);
}
}
impl SseEncode for (String, String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.0, serializer);
<String>::sse_encode(self.1, serializer);
<String>::sse_encode(self.2, serializer);
}
}
impl SseEncode for u32 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
+5 -4
View File
@@ -102,13 +102,14 @@ impl From<chanora_core::CoreError> for BridgeError {
chanora_core::CoreError::AudioNotStarted => {
BridgeError::InvalidCommand("audio not started".to_string())
}
chanora_core::CoreError::Protocol(chanora_protocol::ProtocolError::DnsFailed {
chanora_core::CoreError::Protocol(chanora_core::ProtocolError::DnsFailed {
host,
reason,
}) => BridgeError::DnsFailed { host, reason },
chanora_core::CoreError::Protocol(
chanora_protocol::ProtocolError::ServerRejected { code, message },
) => BridgeError::ServerRejected { code, message },
chanora_core::CoreError::Protocol(chanora_core::ProtocolError::ServerRejected {
code,
message,
}) => BridgeError::ServerRejected { code, message },
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
chanora_core::CoreError::Storage(s) => BridgeError::Connection(format!("storage: {s}")),
+117
View File
@@ -61,6 +61,14 @@ pub enum DiagnosticsError {
/// to the PoC value so audit grep patterns survive the promotion.
pub const REDACTION_MARKER: &str = "[REDACTED]";
/// SRS-122: In-memory log capacity for release builds.
/// Release builds use a smaller buffer to limit memory footprint and residual data in exports.
#[cfg(not(debug_assertions))]
pub const DEFAULT_LOG_CAPACITY: usize = 256;
/// SRS-122: In-memory log capacity for debug builds.
#[cfg(debug_assertions)]
pub const DEFAULT_LOG_CAPACITY: usize = 4096;
/// Registry of known-secret values that must never appear in logs
/// or exports. Cross-spike contract per SS-AUD-003: the secure
/// storage adapter calls [`Self::register`] every time a secret
@@ -612,6 +620,10 @@ pub struct DiagnosticExport {
/// fragment contains only device-side technical scalars per
/// SDD-090 (no PII, no permission state, no server identity).
pub android_audio: Option<String>,
/// SRS-100: Network connectivity diagnostics summary.
pub network_info: Option<String>,
/// SRS-097: Protocol event recording trace.
pub protocol_events: Vec<String>,
}
impl DiagnosticExport {
@@ -625,6 +637,8 @@ impl DiagnosticExport {
recent_logs: sink.snapshot(),
known_secret_count: sink.redactor().secrets().len(),
android_audio: None,
network_info: None,
protocol_events: Vec::new(),
})
}
@@ -638,6 +652,18 @@ impl DiagnosticExport {
self
}
/// Attach a network connectivity diagnostics fragment (SRS-100).
pub fn with_network_info(mut self, info: Option<String>) -> Self {
self.network_info = info;
self
}
/// Attach protocol event trace (SRS-097).
pub fn with_protocol_events(mut self, events: Vec<String>) -> Self {
self.protocol_events = events;
self
}
/// Render as a plaintext blob suitable for `Share` / `Copy`.
/// The output is multi-line UTF-8, redacted.
pub fn to_text(&self) -> String {
@@ -656,6 +682,17 @@ impl DiagnosticExport {
out.push_str("\n[audio.android]\n");
out.push_str(yaml);
}
if let Some(info) = &self.network_info {
out.push_str("\n[network]\n");
out.push_str(info);
}
if !self.protocol_events.is_empty() {
out.push_str("\n[protocol events]\n");
for ev in &self.protocol_events {
out.push_str(ev);
out.push('\n');
}
}
out.push_str("\n[recent logs]\n");
for line in &self.recent_logs {
out.push_str(line);
@@ -665,6 +702,86 @@ impl DiagnosticExport {
}
}
/// SRS-097/098: Ring-buffer recorder of protocol-level events
/// (connect, disconnect, snapshot changes, channel joins) for
/// diagnostic export and state-sync replay verification.
#[derive(Debug, Clone)]
pub struct ProtocolEventRecorder {
events: Vec<String>,
capacity: usize,
}
impl ProtocolEventRecorder {
/// Create a recorder with the given ring-buffer capacity.
pub fn new(capacity: usize) -> Self {
Self {
events: Vec::with_capacity(capacity),
capacity,
}
}
fn push(&mut self, ts: &str, kind: &str, detail: &str) {
let s = format!("[{ts}] {kind}: {detail}");
if self.events.len() >= self.capacity {
self.events.remove(0);
}
self.events.push(s);
}
/// Record a successful connection.
pub fn record_connected(&mut self, server_name: &str) {
self.push("connect", "connected", server_name);
}
/// Record a graceful or forced disconnect.
pub fn record_disconnected(&mut self, reason: &str) {
self.push("disconnect", "disconnected", reason);
}
/// Record a reconnect attempt.
pub fn record_reconnecting(&mut self, attempt: u64, delay_secs: u64) {
self.push(
"reconnect",
"reconnecting",
&format!("attempt={attempt} delay={delay_secs}s"),
);
}
/// Record a snapshot tree change.
pub fn record_snapshot_changed(&mut self, channels: usize, clients: usize) {
self.push(
"snapshot",
"changed",
&format!("channels={channels} clients={clients}"),
);
}
/// Record a channel join event.
pub fn record_channel_join(&mut self, channel_id: u64, channel_name: &str) {
self.push(
"join",
"channel_joined",
&format!("id={channel_id} name={channel_name}"),
);
}
/// Record a platform lifecycle transition (SRS-138).
pub fn record_lifecycle(&mut self, state: &str) {
self.push("lifecycle", state, "");
}
/// Drain all recorded events and reset the buffer.
pub fn drain(&mut self) -> Vec<String> {
std::mem::take(&mut self.events)
}
}
impl Default for ProtocolEventRecorder {
fn default() -> Self {
Self::new(256)
}
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -24,6 +24,7 @@ tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2
# avoids any version-skew confusion.
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" }
tsproto-types = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" }
ts-bookkeeping = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" }
# Async runtime utilities used by the connection task.
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
+162 -5
View File
@@ -33,7 +33,9 @@ use tsclientlib::{
use tsproto_packets::packets::{InAudioBuf, OutPacket};
use tsproto_types::ClientType;
use crate::dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
use crate::dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerSnapshot,
};
use crate::ProtocolError;
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
@@ -104,8 +106,8 @@ pub struct ConnectConfig {
pub password: Option<String>,
/// Optional pre-existing identity (base64 string accepted by
/// `tsclientlib::Identity::new_from_str`). If `None`, a fresh
/// identity is generated and **not persisted** — production
/// callers must wire this to `chanora_storage::SecretStorageRepository`.
/// identity is generated and **not persisted** — production callers
/// should provide one from secure identity storage.
pub identity: Option<String>,
/// How long to wait for the initial state snapshot before
/// returning `ProtocolError::Timeout`.
@@ -139,6 +141,15 @@ enum Request {
output: Option<bool>,
reply: oneshot::Sender<Result<(), ProtocolError>>,
},
/// Send a text message to a target.
SendTextMessage {
/// Message content.
message: String,
/// Target scope.
target: MessageTarget,
/// Reply channel for outcome.
reply: oneshot::Sender<Result<(), ProtocolError>>,
},
}
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
@@ -170,6 +181,9 @@ pub struct ProtocolClient {
/// auto-reconnect. Wrapped in a Mutex<Option<_>> so it can be
/// taken once by the supervisor and never resurfaced.
lost_rx: std::sync::Mutex<Option<oneshot::Receiver<DisconnectReason>>>,
/// Inbound chat message stream from the connection task. The
/// receiver is taken by the supervisor and forwarded to UI.
chat_rx: std::sync::Mutex<Option<mpsc::Receiver<ChatMessage>>>,
}
/// One inbound voice packet from a remote client.
@@ -228,6 +242,7 @@ impl ProtocolClient {
let (tx, rx) = mpsc::channel::<Request>(8);
let (voice_out_tx, voice_out_rx) = mpsc::channel::<OutPacket>(64);
let (voice_in_tx, voice_in_rx) = mpsc::channel::<InboundVoice>(64);
let (chat_tx, chat_rx) = mpsc::channel::<ChatMessage>(64);
let (ready_tx, ready_rx) = oneshot::channel::<Result<(), ProtocolError>>();
let (lost_tx, lost_rx) = oneshot::channel::<DisconnectReason>();
@@ -236,6 +251,7 @@ impl ProtocolClient {
rx,
voice_out_rx,
voice_in_tx,
chat_tx,
ready_tx,
lost_tx,
));
@@ -246,6 +262,7 @@ impl ProtocolClient {
voice_out_tx,
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
chat_rx: std::sync::Mutex::new(Some(chat_rx)),
}),
Ok(Ok(Err(e))) => Err(e),
Ok(Err(_)) => Err(ProtocolError::Backend(
@@ -360,6 +377,40 @@ impl ProtocolClient {
pub fn take_loss_notifier(&self) -> Option<oneshot::Receiver<DisconnectReason>> {
self.lost_rx.lock().ok().and_then(|mut g| g.take())
}
/// Take the inbound-chat receiver. Returns `None` if it has
/// already been taken; only one consumer is allowed.
pub fn take_chat_rx(&self) -> Option<mpsc::Receiver<ChatMessage>> {
self.chat_rx.lock().ok().and_then(|mut g| g.take())
}
/// Put a previously-taken chat_rx receiver back.
pub fn put_chat_rx(&self, rx: mpsc::Receiver<ChatMessage>) {
if let Ok(mut g) = self.chat_rx.lock() {
if g.is_none() {
*g = Some(rx);
}
}
}
/// Send a text message to the specified target.
pub async fn send_text_message(
&self,
message: String,
target: MessageTarget,
) -> Result<(), ProtocolError> {
let (tx, rx) = oneshot::channel();
self.tx
.send(Request::SendTextMessage {
message,
target,
reply: tx,
})
.await
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
rx.await
.map_err(|_| ProtocolError::Lost("send_text_message reply dropped".to_string()))?
}
}
async fn connection_task(
@@ -367,6 +418,7 @@ async fn connection_task(
mut rx: mpsc::Receiver<Request>,
mut voice_out_rx: mpsc::Receiver<OutPacket>,
voice_in_tx: mpsc::Sender<InboundVoice>,
chat_tx: mpsc::Sender<ChatMessage>,
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
lost_tx: oneshot::Sender<DisconnectReason>,
) {
@@ -545,6 +597,33 @@ async fn connection_task(
}
}
}
StreamItem::BookEvents(events) => {
for ev in events {
if let tsclientlib::events::Event::Message {
target,
invoker,
message,
} = ev
{
let mapped = match target {
tsclientlib::MessageTarget::Server => MessageTarget::Server,
tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
tsclientlib::MessageTarget::Client(id) => {
MessageTarget::Client(id.0 as u64)
}
tsclientlib::MessageTarget::Poke(id) => {
MessageTarget::Poke(id.0 as u64)
}
};
let _ = chat_tx.try_send(ChatMessage {
sender_id: ClientId(invoker.id.0 as u64),
sender_name: sanitize(&invoker.name),
message: sanitize(&message),
target: mapped,
});
}
}
}
StreamItem::MessageResult(handle, result) => {
if let Some((reply, _deadline)) = pending_moves.remove(&handle) {
let mapped = match result {
@@ -644,6 +723,14 @@ async fn connection_task(
let r = set_self_muted(&mut con, input, output);
let _ = reply.send(r);
}
Ok(Request::SendTextMessage {
message,
target,
reply,
}) => {
let r = send_text_message(&mut con, &message, target);
let _ = reply.send(r);
}
Ok(Request::Disconnect(reply)) => {
let _ = con.disconnect(DisconnectOptions::new());
con.events().for_each(|_| future::ready(())).await;
@@ -723,6 +810,74 @@ fn set_self_muted(
Ok(())
}
fn send_text_message(
con: &mut Connection,
message: &str,
target: MessageTarget,
) -> Result<(), ProtocolError> {
use ts_bookkeeping::messages::c2s;
use tsproto_types::TextMessageTargetMode;
match target {
MessageTarget::Server => {
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(
c2s::OutSendTextMessagePart {
target: TextMessageTargetMode::Server,
target_client_id: None,
message: message.into(),
},
))
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(server): {e}")))?;
}
MessageTarget::Channel => {
// Fix: previously channel messages were sent via
// state.server.send_textmessage() which always uses
// TextMessageTargetMode::Server. Now correctly uses
// TextMessageTargetMode::Channel so the message is
// scoped to the current channel, not server-wide.
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(
c2s::OutSendTextMessagePart {
target: TextMessageTargetMode::Channel,
target_client_id: None,
message: message.into(),
},
))
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(channel): {e}")))?;
}
MessageTarget::Client(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = state
.clients
.values()
.find(|c| c.id.0 as u64 == client_id)
.ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?;
client
.send_textmessage(message)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(client): {e}")))?;
}
MessageTarget::Poke(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = state
.clients
.values()
.find(|c| c.id.0 as u64 == client_id)
.ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?;
client
.poke(message)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("poke: {e}")))?;
}
}
info!(target: "chanora_protocol", len = message.len(), ?target, "text message sent");
Ok(())
}
/// Extract the originating `client_id` from an inbound voice packet.
fn packet_sender_id(buf: &InAudioBuf) -> Option<u64> {
use tsproto_packets::packets::AudioData;
@@ -872,6 +1027,7 @@ fn build_snapshot(
name: sanitize(&c.name),
order: c.order.0 as i64,
has_password: c.has_password.unwrap_or(false),
needed_talk_power: c.needed_talk_power,
})
.collect();
@@ -887,6 +1043,8 @@ fn build_snapshot(
.get(&(c.id.0 as u64))
.is_some_and(|last| last.elapsed() <= SPEAKING_ACTIVITY_WINDOW),
is_server_query: is_server_query_client_type(&c.client_type),
talk_power: c.talk_power,
talk_power_granted: c.talk_power_granted,
})
.collect();
@@ -915,8 +1073,7 @@ fn sanitize(s: &str) -> String {
.collect()
}
#[allow(dead_code)]
const _ROOT_MATCHES_UPSTREAM: () = {
const _: () = {
// Compile-time assertion that ChannelId(0) maps to what tsclientlib
// also considers the root.
let _ = TsChannelId(0);
+33
View File
@@ -25,6 +25,35 @@ pub struct ChannelInfo {
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>,
}
/// The target scope of a text message (mirrors TS3 `TextMessageTargetMode`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageTarget {
/// Broadcast to entire server.
Server,
/// Broadcast to current channel.
Channel,
/// Private message to a specific client.
Client(u64),
/// Poke a specific client.
Poke(u64),
}
/// An in-channel text message from a specific client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
/// The client id of the sender.
pub sender_id: ClientId,
/// Nickname of the sender, preserved verbatim.
pub sender_name: String,
/// Message content, preserved verbatim.
pub message: String,
/// Target scope of this message.
pub target: MessageTarget,
}
/// One connected client on the server.
@@ -44,6 +73,10 @@ pub struct ClientInfo {
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,
}
/// Snapshot of the server's published state at a moment in time.
+3 -1
View File
@@ -41,7 +41,9 @@ mod dto;
mod resolver;
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
pub use dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
pub use dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerSnapshot,
};
// Re-export the upstream voice types so chanora_audio can build outbound
// voice packets without taking a direct dependency on tsclientlib /
-1
View File
@@ -11,4 +11,3 @@ publish.workspace = true
[dependencies]
thiserror.workspace = true
tracing.workspace = true
+1 -3
View File
@@ -706,9 +706,7 @@ fn pending_key(pending: &JoinPending) -> JoinOutcomeKey {
}
fn key_matches(pending: Option<JoinPending>, key: JoinOutcomeKey) -> bool {
pending
.map(|pending| pending_key(&pending) == key)
.unwrap_or(false)
pending.is_some_and(|pending| pending_key(&pending) == key)
}
fn stale(state: &mut ChannelJoinState, actions: &mut Vec<ChannelJoinAction>) -> JoinReduceStatus {
+186 -107
View File
@@ -1,15 +1,15 @@
//! # `chanora_storage`
//!
//! Two strictly separated repositories per SAD-067:
//! Two strictly separated storage concerns per SAD-067:
//!
//! * [`LocalDatabaseRepository`] — non-secret state (bookmarks,
//! settings, identity *references*) via SQLite. Crate choice:
//! `rusqlite` bundled (DEC-013.1). **Not yet implemented** —
//! `poc/sqlite-storage-spike` lands in v0.4.
//! * [`SecretStorageRepository`] — secret material (identity private
//! keys, server passwords) via platform secure storage. Linux
//! policy: Secret Service preferred, kernel keyutils fallback
//! (DEC-013.2). Other platforms TBD per SS-TC-001/002/004/005.
//! * [`BookmarkRepository`] — non-secret bookmark state via SQLite
//! with optional encrypted password fields. Crate choice:
//! `rusqlite` bundled (DEC-013.1).
//! * [`IdentityFileStore`] — Beta fallback storage for identity
//! material while the platform secure-storage backends mature.
//! Linux policy remains Secret Service preferred, kernel keyutils
//! fallback (DEC-013.2). Other platforms TBD per
//! SS-TC-001/002/004/005.
//!
//! Secret values **never** appear in the local DB (SS-AUD-001/002);
//! bookmarks store only an `identity_ref` lookup name into the
@@ -48,7 +48,7 @@ use std::sync::Mutex;
use chacha20poly1305::aead::{Aead, KeyInit, OsRng};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::RngCore;
use rusqlite::{params, Connection};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::{info, warn};
@@ -79,16 +79,6 @@ pub enum StorageError {
Crypto(String),
}
/// Marker trait for the non-secret database side. Concrete impl will
/// land alongside the `BookmarkRepository` / `SettingsRepository`
/// traits promoted from the SQLite PoC.
pub trait LocalDatabaseRepository: Send + Sync {}
/// Marker trait for the platform secure-storage side. Concrete impl
/// will land alongside the `Secret` newtype + per-platform adapters
/// promoted from the secure-storage PoC.
pub trait SecretStorageRepository: Send + Sync {}
/// Audio-related per-identity settings persisted alongside the
/// identity file as a small JSON blob (SDD-095 / SDD-096). These
/// are *not* secrets; they sit beside the encrypted identity in
@@ -118,6 +108,22 @@ struct AudioMeta {
ptt_key_label: String,
}
/// Persisted PTT binding metadata.
///
/// `input_class` is a stable privacy-safe category string, `platform_key`
/// is the opaque identifier consumed by the platform backend, and
/// `key_label` is the display-only label shown in the UI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PttBindingMeta {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
pub input_class: String,
/// Opaque platform key identifier.
pub platform_key: String,
/// Display-only key label.
pub key_label: String,
}
fn default_release_tail_ms() -> u32 {
200
}
@@ -214,59 +220,57 @@ impl IdentityFileStore {
/// Honours `CHANORA_DISABLE_KEYRING=1` for tests and headless
/// environments where a real Secret Service call would block on
/// a missing D-Bus session.
#[allow(unused_variables)]
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
fn keyring_load(&self) -> Result<Option<[u8; 32]>, StorageError> {
if keyring_disabled() {
return Ok(None);
}
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
{
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: entry construction failed; falling back to file");
return Ok(None);
}
};
match entry.get_password() {
Ok(b64) => {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?;
if bytes.len() != 32 {
return Err(StorageError::Crypto(format!(
"keyring dek length {} (expected 32)",
bytes.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(Some(key))
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => {
// Bus unreachable, no session, locked keychain
// — best-effort: fall through to file.
warn!(target: "chanora_storage", error = %e, "keyring: lookup failed; falling back to file");
Ok(None)
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: entry construction failed; falling back to file");
return Ok(None);
}
};
match entry.get_password() {
Ok(b64) => {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?;
if bytes.len() != 32 {
return Err(StorageError::Crypto(format!(
"keyring dek length {} (expected 32)",
bytes.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(Some(key))
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => {
// Bus unreachable, no session, locked keychain
// — best-effort: fall through to file.
warn!(target: "chanora_storage", error = %e, "keyring: lookup failed; falling back to file");
Ok(None)
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
{
Ok(None)
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
fn keyring_load(&self) -> Result<Option<[u8; 32]>, StorageError> {
Ok(None)
}
/// Persist the DEK in the platform keyring. Returns true on
@@ -274,44 +278,42 @@ impl IdentityFileStore {
/// should then fall back to the file path).
///
/// Honours `CHANORA_DISABLE_KEYRING=1`.
#[allow(unused_variables)]
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
fn keyring_save(&self, key: &[u8; 32]) -> bool {
if keyring_disabled() {
return false;
}
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
{
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(_) => return false,
};
let b64 = base64::engine::general_purpose::STANDARD.encode(key);
match entry.set_password(&b64) {
Ok(()) => {
info!(target: "chanora_storage", "DEK stored in platform keyring");
true
}
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: save failed; falling back to file");
false
}
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(_) => return false,
};
let b64 = base64::engine::general_purpose::STANDARD.encode(key);
match entry.set_password(&b64) {
Ok(()) => {
info!(target: "chanora_storage", "DEK stored in platform keyring");
true
}
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: save failed; falling back to file");
false
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
{
false
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
fn keyring_save(&self, _key: &[u8; 32]) -> bool {
false
}
fn ensure_dek(&self) -> Result<(), StorageError> {
@@ -562,12 +564,14 @@ impl IdentityFileStore {
self.write_meta(&m)
}
/// Read the persisted PTT binding. Returns
/// `(input_class, platform_key, key_label)` with empty strings
/// meaning "no binding".
pub fn get_ptt_binding(&self) -> (String, String, String) {
/// Read the persisted PTT binding. Empty strings mean "no binding".
pub fn get_ptt_binding(&self) -> PttBindingMeta {
let m = self.read_meta();
(m.ptt_input_class, m.ptt_platform_key, m.ptt_key_label)
PttBindingMeta {
input_class: m.ptt_input_class,
platform_key: m.ptt_platform_key,
key_label: m.ptt_key_label,
}
}
/// Remove any persisted identity. No-op if none exists. Leaves
@@ -874,6 +878,49 @@ impl BookmarkRepository {
Ok(conn.last_insert_rowid())
}
/// Insert or update a bookmark identified by host. If a row
/// with the same host exists, update connection fields while
/// preserving the user-facing display name; otherwise insert.
/// Returns the row id.
pub fn upsert_or_add(&self, b: &Bookmark) -> Result<i64, StorageError> {
let conn = self
.conn
.lock()
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
let blob = match (&self.crypto, &b.password) {
(Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?),
_ => None,
};
let plain: Option<&str> = if self.crypto.is_some() {
None
} else {
b.password.as_deref()
};
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM bookmarks WHERE host = ?1",
params![b.host],
|row| row.get(0),
)
.optional()
.map_err(|e| StorageError::Sqlite(format!("select: {e}")))?;
if let Some(id) = existing {
conn.execute(
"UPDATE bookmarks SET nickname = ?1, password = ?2, password_blob = ?3 WHERE id = ?4",
params![b.nickname, plain, blob, id],
)
.map_err(|e| StorageError::Sqlite(format!("update: {e}")))?;
Ok(id)
} else {
conn.execute(
"INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
params![b.display_name, b.host, b.nickname, plain, blob],
)
.map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?;
Ok(conn.last_insert_rowid())
}
}
/// Replace an existing bookmark identified by `id`. Errors with
/// [`StorageError::NotFound`] if no such row exists. Honours the
/// password-column encryption setting and clears the legacy
@@ -1067,6 +1114,38 @@ mod tests {
assert!(repo.list().unwrap().is_empty());
}
#[test]
fn bookmark_upsert_preserves_existing_display_name() {
force_keyring_off();
let tmp = tempdir();
let repo = BookmarkRepository::new(&tmp).unwrap();
let id = repo
.add(&Bookmark {
id: 0,
display_name: "my custom title".to_string(),
host: "cn.teamspeak.app".to_string(),
nickname: "old nick".to_string(),
password: None,
})
.unwrap();
let upserted = repo
.upsert_or_add(&Bookmark {
id: 0,
display_name: "live server name".to_string(),
host: "cn.teamspeak.app".to_string(),
nickname: "new nick".to_string(),
password: Some("pw".to_string()),
})
.unwrap();
assert_eq!(upserted, id);
let rows = repo.list().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].display_name, "my custom title");
assert_eq!(rows[0].nickname, "new nick");
assert_eq!(rows[0].password.as_deref(), Some("pw"));
}
#[test]
fn bookmark_update_missing_is_notfound() {
force_keyring_off();
@@ -1228,14 +1307,14 @@ mod tests {
fn tempdir() -> PathBuf {
let p = std::env::temp_dir()
.join("chanora_storage_test")
.join(format!("{}", std::process::id()))
.join(format!(
"{}",
.join(std::process::id().to_string())
.join(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
.to_string(),
);
fs::create_dir_all(&p).unwrap();
p
}