feat: integrate chat voice and diagnostics client
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(¶ms, 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;
|
||||
|
||||
@@ -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(¶ms, 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}")),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -11,4 +11,3 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user