feat(voice): harden Android audio and channel joins

This commit is contained in:
Edison Jwa
2026-05-19 01:58:07 +09:00
parent 29a553d4e1
commit 8c253f1d4d
23 changed files with 2948 additions and 363 deletions
+9 -3
View File
@@ -12,8 +12,8 @@
use audiopus::coder::{Decoder, Encoder};
use audiopus::packet::Packet;
use audiopus::{Application, Channels, MutSignals, SampleRate};
use std::convert::TryFrom;
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use std::convert::TryFrom;
mod common;
use common::{synthetic_opus_bytes, synthetic_opus_frame};
@@ -43,11 +43,17 @@ fn bench_opus_decode_latency(c: &mut Criterion) {
b.iter(|| {
let input = Packet::try_from(black_box(&bytes[..])).expect("packet");
let output = MutSignals::try_from(&mut pcm_out[..]).expect("signals");
let n = dec.decode_float(Some(input), output, false).expect("decode");
let n = dec
.decode_float(Some(input), output, false)
.expect("decode");
black_box(n);
});
});
}
criterion_group!(opus_codec, bench_opus_encode_latency, bench_opus_decode_latency);
criterion_group!(
opus_codec,
bench_opus_encode_latency,
bench_opus_decode_latency
);
criterion_main!(opus_codec);
@@ -80,9 +80,7 @@ fn bench_capture_alloc_count(c: &mut Criterion) {
// here and the emitter reads it directly.
if let Ok(dir) = std::env::var("CARGO_TARGET_DIR")
.map(std::path::PathBuf::from)
.or_else(|_| {
std::env::current_dir().map(|d| d.join("target"))
})
.or_else(|_| std::env::current_dir().map(|d| d.join("target")))
{
let path = dir.join("criterion").join("capture_alloc_count.sidecar");
if let Some(parent) = path.parent() {
+13 -17
View File
@@ -166,21 +166,18 @@ fn main() {
"capture_callback_wall_clock" => read_estimate_ns(name),
"opus_encode_latency" => read_estimate_ns(name),
"opus_decode_latency" => read_estimate_ns(name),
"resampler_44100_to_48000" => read_estimate_group_ns(
"resampler_throughput",
"44100_to_48000",
)
.map(|ns| ns_to_throughput(ns, 44_100.0)),
"resampler_16000_to_48000" => read_estimate_group_ns(
"resampler_throughput",
"16000_to_48000",
)
.map(|ns| ns_to_throughput(ns, 16_000.0)),
"resampler_48000_passthrough" => read_estimate_group_ns(
"resampler_throughput",
"48000_passthrough",
)
.map(|ns| ns_to_throughput(ns, 48_000.0)),
"resampler_44100_to_48000" => {
read_estimate_group_ns("resampler_throughput", "44100_to_48000")
.map(|ns| ns_to_throughput(ns, 44_100.0))
}
"resampler_16000_to_48000" => {
read_estimate_group_ns("resampler_throughput", "16000_to_48000")
.map(|ns| ns_to_throughput(ns, 16_000.0))
}
"resampler_48000_passthrough" => {
read_estimate_group_ns("resampler_throughput", "48000_passthrough")
.map(|ns| ns_to_throughput(ns, 48_000.0))
}
_ => None,
};
let v = value.unwrap_or(f64::NAN);
@@ -201,7 +198,6 @@ fn main() {
});
let out_path = Path::new("current.json");
fs::write(out_path, serde_json::to_string_pretty(&doc).unwrap())
.expect("write current.json");
fs::write(out_path, serde_json::to_string_pretty(&doc).unwrap()).expect("write current.json");
eprintln!("emit_baseline: wrote {}", out_path.display());
}
+279 -46
View File
@@ -36,11 +36,17 @@
//! engine-state mutation happens off the audio thread (SDD-115).
#![cfg(target_os = "android")]
#![allow(dead_code)]
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use tracing::{info, warn};
use audiopus::coder::Encoder as OpusEncoder;
use audiopus::{
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
SampleRate as OpusSampleRate,
};
use tracing::{debug, info, warn};
use crate::mobile_voice_backend::{
clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after,
@@ -50,6 +56,10 @@ use crate::mobile_voice_backend::{
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
SharingModeChoice,
};
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
use tsclientlib::audio::AudioHandler;
use crate::{engine::SessionAudioId, AudioError};
use tokio::sync::mpsc;
@@ -64,21 +74,127 @@ use oboe::{
// `mobile_voice_backend` so the trait can expose `take_event_rx`
// (SDD-111 item 1) cross-platform.
// --- Empty I/O callbacks for the lifecycle skeleton (SDD-111) ----
/// 20 ms at 48 kHz mono — one Opus frame's worth of samples.
/// Matches the iOS and desktop constants; duplicated here so this
/// module is fully self-contained and cfg-gate-clean.
const FRAME_SAMPLES: usize = 960;
/// Maximum size of an encoded Opus frame in bytes (RFC 6716 §3.2.1).
const MAX_OPUS_FRAME: usize = 1275;
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
//
// Audio data is plumbed through the existing engine paths
// (cpal-shaped channels feeding the `AudioHandler` mix). The
// callbacks here exist to (a) satisfy `oboe-rs`'s requirement that
// each async stream have a callback, and (b) provide the seam where
// the engine can later inject its capture / playback ring buffers.
// They are deliberately panic-free: any error path logs through the
// `tracing` macro and returns `DataCallbackResult::Continue`. A
// disconnect / error is delivered out-of-band through the error
// callback that `AudioStreamBuilder::set_error_callback` would
// install (the safe wrapper exposes this via the callback's
// `on_error_*` hooks).
// Mirrors the iOS `IosCaptureState` and the cpal-side `CaptureState`.
// Oboe delivers 48 kHz mono i16 PCM; we apply mic gain, accumulate to
// FRAME_SAMPLES, encode to Opus 32 kbps (complexity 10, inband FEC, 5 % PLC),
// and try-send the resulting packet on `voice_out_tx`.
struct AndroidCaptureState {
encoder: OpusEncoder,
/// Accumulator for 48 kHz mono PCM. 2x capacity to absorb
/// cpal-style buffer-size jitter without reallocating.
pcm_accum: Vec<i16>,
opus_out: [u8; MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
}
impl AndroidCaptureState {
fn new(
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
) -> Result<Self, AudioError> {
let mut encoder =
OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
.map_err(|e| AudioError::Opus(format!("encoder new (android): {e}")))?;
if let Err(e) = encoder.set_bitrate(audiopus::Bitrate::BitsPerSecond(32_000)) {
warn!(target: "chanora_audio", error = %e, "opus(android): set_bitrate(32000) failed");
}
if let Err(e) = encoder.set_complexity(10) {
warn!(target: "chanora_audio", error = %e, "opus(android): set_complexity(10) failed");
}
if let Err(e) = encoder.set_inband_fec(true) {
warn!(target: "chanora_audio", error = %e, "opus(android): set_inband_fec(true) failed");
}
if let Err(e) = encoder.set_packet_loss_perc(5) {
warn!(target: "chanora_audio", error = %e, "opus(android): set_packet_loss_perc(5) failed");
}
info!(
target: "chanora_audio",
bitrate_bps = 32_000,
complexity = 10,
inband_fec = true,
packet_loss_perc = 5,
"android Oboe opus encoder tuned for VoIP"
);
Ok(Self {
encoder,
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
opus_out: [0u8; MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
frames_sent,
mic_gain,
})
}
/// Consume i16 mono frames from Oboe, accumulate to FRAME_SAMPLES,
/// encode + send when PTT is held. Oboe delivers at the device's
/// native sample rate (always 48 kHz for modern Android per SRS-210),
/// so no resampling is needed.
fn ingest(&mut self, samples: &[i16]) {
if !self.transmit_active.load(Ordering::Relaxed) {
self.pcm_accum.clear();
return;
}
// Mic-gain application.
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
self.pcm_accum.extend_from_slice(samples);
} else {
let gain = self.mic_gain;
self.pcm_accum.extend(samples.iter().map(|&s| {
let scaled = (s as f32) * gain;
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
}));
}
// Drain complete 20 ms frames.
while self.pcm_accum.len() >= FRAME_SAMPLES {
let mut frame = [0i16; FRAME_SAMPLES];
frame.copy_from_slice(&self.pcm_accum[..FRAME_SAMPLES]);
self.pcm_accum.drain(..FRAME_SAMPLES);
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
Ok(len) => {
let packet = OutAudio::new(&AudioData::C2S {
id: 0,
codec: CodecType::OpusVoice,
data: &self.opus_out[..len],
});
match self.voice_out_tx.try_send(packet) {
Ok(()) => {
self.frames_sent.fetch_add(1, Ordering::Relaxed);
}
Err(mpsc::error::TrySendError::Full(_)) => {
warn!(target: "chanora_audio", "android Oboe: voice_out queue full; dropping frame");
}
Err(mpsc::error::TrySendError::Closed(_)) => {
debug!(target: "chanora_audio", "android Oboe: voice_out closed; capture pipeline stopping");
}
}
}
Err(e) => {
warn!(target: "chanora_audio", error = %e, "android Oboe opus encode failed");
}
}
}
}
}
struct InputCallback {
state: Arc<Mutex<AndroidCaptureState>>,
event_tx: BackendEventTx,
}
@@ -88,36 +204,37 @@ impl AudioInputCallback for InputCallback {
fn on_audio_ready(
&mut self,
_stream: &mut dyn AudioInputStreamSafe,
_frames: &[i16],
frames: &[i16],
) -> DataCallbackResult {
// Catch panics so a logic bug in the future can't abort the
// process under `panic=abort`. The audio thread MUST NOT
// panic.
let _ = catch_unwind(AssertUnwindSafe(|| {
// Engine wires real capture through the AudioHandler
// path; this seam is intentionally a no-op for now.
if let Ok(mut s) = self.state.lock() {
s.ingest(frames);
}
}));
DataCallbackResult::Continue
}
fn on_error_after_close(&mut self, _stream: &mut dyn AudioInputStreamSafe, error: oboe::Error) {
// Oboe reports `ErrorDisconnected` here on route loss.
// We never call back into the engine from this method;
// instead we marshal a `Disconnected` event.
if matches!(error, oboe::Error::Disconnected) {
let _ = self.event_tx.send(BackendEvent::Disconnected);
} else {
warn!(
target: "chanora_audio",
error = ?error,
"android: input stream error_after_close"
);
warn!(target: "chanora_audio", error = ?error, "android: input stream error_after_close");
}
}
}
// --- Output callback wiring (SDD-111 / SDD-120) ----
//
// Mirrors the iOS VPIO render callback. Pulls mixed 48 kHz stereo f32
// from `AudioHandler::fill_buffer`, applies output gain + mute, and
// writes mono i16 to the Oboe output buffer.
struct OutputCallback {
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
event_tx: BackendEventTx,
scratch: Arc<Mutex<Vec<f32>>>,
}
impl AudioOutputCallback for OutputCallback {
@@ -128,13 +245,45 @@ impl AudioOutputCallback for OutputCallback {
_stream: &mut dyn AudioOutputStreamSafe,
frames: &mut [i16],
) -> DataCallbackResult {
// Default to silence. The engine wires real playback through
// the existing AudioHandler path; this callback is a seam
// where a future commit replaces silence with a ring-buffer
// pull. Zeroing is panic-free and lock-free.
let _ = catch_unwind(AssertUnwindSafe(|| {
for s in frames.iter_mut() {
*s = 0;
let needed = frames.len() * 2; // stereo
let scratch = &mut self.scratch.lock().unwrap();
if scratch.len() < needed {
scratch.resize(needed, 0.0);
} else {
for s in &mut scratch[..needed] {
*s = 0.0;
}
}
// Non-blocking pull from AudioHandler (same pattern as iOS VPIO).
match self.handler.try_lock() {
Ok(mut h) => {
let _ = h.fill_buffer(&mut scratch[..needed]);
}
Err(std::sync::TryLockError::WouldBlock) => {
// scratch already zeroed above.
}
Err(std::sync::TryLockError::Poisoned(e)) => {
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {}", e);
}
}
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
let muted = self.output_muted.load(Ordering::Relaxed);
let mut peak: i16 = 0;
for (i, dst) in frames.iter_mut().enumerate() {
if muted {
*dst = 0;
continue;
}
let l = scratch[i * 2];
let r = scratch[i * 2 + 1];
let mono = (l + r) * 0.5 * gain;
let clamped = mono.clamp(-1.0, 1.0);
let sample = (clamped * i16::MAX as f32) as i16;
*dst = sample;
if sample.unsigned_abs() > peak.unsigned_abs() {
peak = sample;
}
}
}));
DataCallbackResult::Continue
@@ -148,15 +297,34 @@ impl AudioOutputCallback for OutputCallback {
if matches!(error, oboe::Error::Disconnected) {
let _ = self.event_tx.send(BackendEvent::Disconnected);
} else {
warn!(
target: "chanora_audio",
error = ?error,
"android: output stream error_after_close"
);
warn!(target: "chanora_audio", error = ?error, "android: output stream error_after_close");
}
}
}
/// 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解码+混合 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>,
}
// --- The backend itself ------------------------------------------
/// Android voice-audio backend (SDD-111). Owns one input + one
@@ -199,9 +367,35 @@ impl AndroidVoiceUnit {
/// effects. The engine is expected to have already issued
/// `setMode(MODE_IN_COMMUNICATION)` (SDD-108) per the SDD-115
/// sequencing rules.
pub fn open(cfg: &AndroidVoiceStreamConfig) -> Result<Self, BackendError> {
///
/// `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(
cfg: &AndroidVoiceStreamConfig,
params: VoiceAudioParams,
) -> Result<Self, BackendError> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
// SDD-120: build the capture state that the Oboe input callback
// will own via Arc<Mutex>. Same Opus VoIP tuning as iOS and
// desktop (32 kbps, complexity 10, inband FEC, 5 % PLC).
let capture_state = Arc::new(Mutex::new(
AndroidCaptureState::new(
params.voice_out_tx,
params.transmit_active,
params.frames_sent,
params.mic_gain,
)
.map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?,
));
// Scratch buffer for the output callback (realtime-safe
// pre-allocation). 8192 floats covers the largest practical
// burst size at 48 kHz with headroom.
let scratch = Arc::new(Mutex::new(Vec::with_capacity(8192)));
// --- Open input stream (SDD-112) ---------------------------
let mut input_builder = AudioStreamBuilder::default()
.set_direction::<OboeInput>()
@@ -226,6 +420,7 @@ impl AndroidVoiceUnit {
.set_usage(Usage::VoiceCommunication);
let input_cb = InputCallback {
state: capture_state.clone(),
event_tx: event_tx.clone(),
};
let input_builder = input_builder.set_callback(input_cb);
@@ -242,7 +437,7 @@ impl AndroidVoiceUnit {
error = ?e,
"android: primary input stream open failed; entering fallback ladder"
);
Self::open_input_fallback(cfg, &event_tx)?
Self::open_input_fallback(cfg, &event_tx, capture_state.clone())?
}
};
@@ -279,7 +474,11 @@ impl AndroidVoiceUnit {
.set_content_type(oboe::ContentType::Speech);
let output_cb = OutputCallback {
handler: params.handler.clone(),
output_gain: params.output_gain.clone(),
output_muted: params.output_muted.clone(),
event_tx: event_tx.clone(),
scratch: scratch.clone(),
};
let output_builder = output_builder.set_callback(output_cb);
@@ -291,7 +490,14 @@ impl AndroidVoiceUnit {
error = ?e,
"android: primary output stream open failed; retrying with Shared sharing mode"
);
Self::open_output_fallback(cfg, &event_tx)?
Self::open_output_fallback(
cfg,
&event_tx,
params.handler.clone(),
params.output_gain.clone(),
params.output_muted.clone(),
scratch.clone(),
)?
}
};
@@ -390,6 +596,7 @@ impl AndroidVoiceUnit {
fn open_input_fallback(
cfg: &AndroidVoiceStreamConfig,
event_tx: &BackendEventTx,
capture_state: Arc<Mutex<AndroidCaptureState>>,
) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
// SDD-112 items 6 & 7: explore (preset × sharing) independently
// via the pure helpers in `mobile_voice_backend`. Primary
@@ -425,6 +632,7 @@ impl AndroidVoiceUnit {
SharingModeChoice::Shared => SharingMode::Shared,
};
let cb = InputCallback {
state: capture_state.clone(),
event_tx: event_tx.clone(),
};
let builder = AudioStreamBuilder::default()
@@ -460,9 +668,17 @@ impl AndroidVoiceUnit {
fn open_output_fallback(
cfg: &AndroidVoiceStreamConfig,
event_tx: &BackendEventTx,
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
scratch: Arc<Mutex<Vec<f32>>>,
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
let cb = OutputCallback {
handler,
output_gain,
output_muted,
event_tx: event_tx.clone(),
scratch,
};
let builder = AudioStreamBuilder::default()
.set_direction::<OboeOutput>()
@@ -648,7 +864,9 @@ fn attach_hardware_effects(
// SDD-115 callback safety: even on the (assumed) non-realtime
// open/close paths, wrap the JNI body in `catch_unwind` so a
// panic during teardown cannot unwind into the JVM.
let result = catch_unwind(AssertUnwindSafe(|| attach_hardware_effects_inner(session_id, effects)));
let result = catch_unwind(AssertUnwindSafe(|| {
attach_hardware_effects_inner(session_id, effects)
}));
match result {
Ok(h) => h,
Err(_) => {
@@ -690,13 +908,28 @@ fn attach_hardware_effects_inner(
let mut handles = HardwareEffectHandles::default();
if effects.aec {
handles.aec = create_effect(&mut env, "android/media/audiofx/AcousticEchoCanceler", session_id, "AEC");
handles.aec = create_effect(
&mut env,
"android/media/audiofx/AcousticEchoCanceler",
session_id,
"AEC",
);
}
if effects.noise_suppression {
handles.ns = create_effect(&mut env, "android/media/audiofx/NoiseSuppressor", session_id, "NS");
handles.ns = create_effect(
&mut env,
"android/media/audiofx/NoiseSuppressor",
session_id,
"NS",
);
}
if effects.agc {
handles.agc = create_effect(&mut env, "android/media/audiofx/AutomaticGainControl", session_id, "AGC");
handles.agc = create_effect(
&mut env,
"android/media/audiofx/AutomaticGainControl",
session_id,
"AGC",
);
}
handles
}
+203 -190
View File
@@ -8,9 +8,9 @@
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
#[cfg(not(target_os = "ios"))]
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
#[cfg(not(target_os = "ios"))]
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
use cpal::{SampleFormat, SizedSample};
use tokio::sync::mpsc;
use tracing::{debug, info};
@@ -18,12 +18,15 @@ use tracing::{debug, info};
// playback paths (`build_input_stream`, `build_output_stream`,
// `try_open_capture` log lines). Cfg-gate the imports too so iOS
// builds don't carry an unused-imports warning.
#[cfg(not(target_os = "ios"))]
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
use tracing::{error, warn};
#[cfg(not(target_os = "ios"))]
#[cfg(target_os = "android")]
use tracing::warn;
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
use audiopus::coder::Encoder as OpusEncoder;
#[cfg(not(target_os = "ios"))]
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
use audiopus::{
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
SampleRate as OpusSampleRate,
@@ -38,7 +41,7 @@ use tsclientlib::audio::AudioHandler;
// iOS too, and `OutPacket` flows out of the capture pipeline once
// commit 3 lands. Cfg-gate the cpal-only ones to keep iOS warnings
// clean.
#[cfg(not(target_os = "ios"))]
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
use chanora_protocol::{AudioData, CodecType, OutAudio};
use chanora_protocol::{InboundVoice, OutPacket};
@@ -136,11 +139,15 @@ pub struct AudioEngine {
// VoiceProcessingIO AudioUnit hosts mic + speaker) — cpal is
// unused on iOS for the reasons documented in
// `ios_voice_unit.rs`.
#[cfg(not(target_os = "ios"))]
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
_input_stream: Mutex<Option<cpal::Stream>>,
#[cfg(target_os = "linux")]
_output_stream: Mutex<Option<crate::sdl_output::SdlOutput>>,
#[cfg(all(not(target_os = "linux"), not(target_os = "ios")))]
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "android")
))]
_output_stream: Mutex<Option<cpal::Stream>>,
#[cfg(target_os = "ios")]
_ios_voice_unit: Mutex<Option<crate::ios_voice_unit::IosVoiceUnit>>,
@@ -236,7 +243,11 @@ impl AudioEngine {
{
return Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate);
}
#[cfg(not(target_os = "ios"))]
#[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 = "android")))]
{
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
@@ -247,7 +258,7 @@ impl AudioEngine {
/// at the top of `start_with_gate` without dragging a 200-line
/// cfg-gated block. Body is the pre-iOS-port code, unchanged
/// except for the new function name + signature.
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
fn start_with_gate_cpal(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
@@ -313,153 +324,6 @@ impl AudioEngine {
),
}
// SDD-115 lifecycle sequencing on engine start. Forward order:
// 1) bridge -> engine receives voice_join (here we are
// already inside `start`, the engine-side trigger).
// 2) start the Android voice foreground service so that
// the platform records the microphone capture under
// `foregroundServiceType="microphone"` (SDD-107 + SRS-215).
// 3) open the AAudio voice streams (SDD-111 + SDD-112).
// 4) engage `MODE_IN_COMMUNICATION` (SDD-108).
// 5) bind hardware effects (SDD-113) — performed inside
// `AndroidVoiceUnit::open()` once the input stream has a
// session id.
// The reverse order on engine drop is enforced by the field
// drop order (`_android_voice_unit` is dropped before the
// engine returns; `close()` is invoked from its Drop impl).
#[cfg(target_os = "android")]
let mut audio_mode_stack = crate::mode_stack::ModeStack::new();
#[cfg(target_os = "android")]
let _android_voice_unit = {
if cfg.mobile_voice_preset {
// Step 2: foreground service.
if crate::android_voice_unit::chanora_android_start_voice_service() {
info!(
target: "chanora_audio",
"android: voice foreground service start dispatched (SDD-115)"
);
} else {
warn!(
target: "chanora_audio",
"android: foreground service start failed; capture may be denied in background (SDD-115)"
);
}
// Step 4 (mode engage) BEFORE Step 5 (effect bind);
// hardware-effect routing only engages reliably under
// MODE_IN_COMMUNICATION (SDD-113 item 6 / SDD-115).
//
// SDD-108 §1/§2: route through `ModeStack` so the
// 0 → 1 transition snapshots the prior platform mode
// (via `android_get_audio_mode`) and only that
// transition writes `MODE_IN_COMMUNICATION` via
// `android_set_audio_mode`. P0 only ever observes
// refcount {0, 1} per SRS-189 but the composition
// model is in place for P1.
match android_get_audio_mode() {
Ok(prior_now) => {
let outcome = audio_mode_stack.acquire(prior_now);
if let crate::mode_stack::ModeAcquire::FirstAcquire { prior } = outcome {
match android_set_audio_mode(ANDROID_MODE_IN_COMMUNICATION) {
Ok(()) => info!(
target: "chanora_audio",
prior_mode = prior,
"android: AudioManager mode set to MODE_IN_COMMUNICATION (SDD-108)"
),
Err(e) => {
// SDD-108 §5: setMode failed AFTER
// the 0 → 1 ModeStack transition.
// Roll the stack back so refcount
// returns to 0 and the snapshot is
// cleared; otherwise a future
// release would issue an
// unmatched setMode(prior) against
// a system that never had its mode
// changed by us.
warn!(
target: "chanora_audio",
error = %e,
prior_mode = prior,
"android: setMode failed; rolling back ModeStack acquire (SDD-108 §5)"
);
let _ = audio_mode_stack.release();
}
}
}
}
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"android: AudioManager.getMode failed; skipping mode engage (SDD-108)"
),
}
// Step 3 + 5: open streams (SDD-111/112) and bind
// hardware effects (SDD-113). Failure here is logged
// and the engine continues with software AEC/NS/AGC
// via the existing engine path; the cpal data path
// remains the in-flight carrier.
//
// The prior silent-no-op log line at this site
// ("engagement depends on device AEC/NS support
// under MODE_IN_COMMUNICATION") is removed: the
// AndroidVoiceUnit either succeeds in engaging
// hardware effects (SDD-113) or logs the per-effect
// fallback, so engagement is now observable rather
// than rationalised.
let cfg_av = crate::mobile_voice_backend::AndroidVoiceStreamConfig {
effects: cfg.effects,
..Default::default()
};
match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av) {
Ok(mut unit) => {
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
if let Err(e) = unit.start() {
warn!(
target: "chanora_audio",
error = %e,
"android: AndroidVoiceUnit::start failed — cpal path remains active (SDD-115)"
);
}
Some(unit)
}
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
"android: AndroidVoiceUnit::open failed — software AEC/NS/AGC fallback engages (SDD-111/SDD-113)"
);
None
}
}
} else {
None
}
};
#[cfg(target_os = "ios")]
{
if cfg.mobile_voice_preset {
// iOS AVAudioSession configuration is performed
// Swift-side in `apps/chanora_flutter/ios/Runner/
// AppDelegate.swift::application(_:didFinishLaunching\
// WithOptions:)` BEFORE Flutter starts its audio
// pipeline. The category/mode set there
// (`.playAndRecord` + `.voiceChat`,
// `defaultToSpeaker | allowBluetooth |
// allowBluetoothA2DP`) is the recommended iOS shape
// for voice clients and engages on-device AEC / NS
// routing where supported. cpal's CoreAudio
// backend then opens its streams against that
// session and inherits the routing. Logging here
// just records that the engine-start path
// acknowledges the request; the actual session
// mutation lives in Swift because it must happen
// before Dart loads.
info!(
target: "chanora_audio",
"ios: voice-chat session mode requested — AVAudioSession configured in AppDelegate"
);
}
}
let transmit_flag_for_capture = transmit_gate.flag_arc();
let frames_sent = Arc::new(AtomicU32::new(0));
let frames_received = Arc::new(AtomicU32::new(0));
@@ -644,9 +508,158 @@ impl AudioEngine {
output_muted,
_input_stream: Mutex::new(input_stream),
_output_stream: Mutex::new(Some(output_stream)),
#[cfg(target_os = "android")]
_android_voice_unit: Mutex::new(_android_voice_unit),
#[cfg(target_os = "android")]
shutdown_tx: Some(shutdown_tx),
capture_active,
ptt_watchdog,
})
}
#[cfg(target_os = "android")]
fn start_with_gate_android(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
mut voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
use crate::mobile_voice_backend::{BackendEvent, MobileVoiceAudioBackend};
info!(target: "chanora_audio", "starting audio engine: Android Oboe backend");
let transmit_flag_for_capture = transmit_gate.flag_arc();
let frames_sent = Arc::new(AtomicU32::new(0));
let frames_received = Arc::new(AtomicU32::new(0));
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
let output_muted = Arc::new(AtomicBool::new(false));
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
if !cfg.mobile_voice_preset {
return Err(AudioError::Backend(
"android: mobile_voice_preset=false is unsupported for Oboe-only backend"
.to_string(),
));
}
let mut audio_mode_stack = crate::mode_stack::ModeStack::new();
if crate::android_voice_unit::chanora_android_start_voice_service() {
info!(
target: "chanora_audio",
"android: voice foreground service start dispatched (SDD-115)"
);
} else {
warn!(
target: "chanora_audio",
"android: foreground service start failed; capture may be denied in background (SDD-115)"
);
}
match android_get_audio_mode() {
Ok(prior_now) => {
let outcome = audio_mode_stack.acquire(prior_now);
if let crate::mode_stack::ModeAcquire::FirstAcquire { prior } = outcome {
match android_set_audio_mode(ANDROID_MODE_IN_COMMUNICATION) {
Ok(()) => info!(
target: "chanora_audio",
prior_mode = prior,
"android: AudioManager mode set to MODE_IN_COMMUNICATION (SDD-108)"
),
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
prior_mode = prior,
"android: setMode failed; rolling back ModeStack acquire (SDD-108 §5)"
);
let _ = audio_mode_stack.release();
}
}
}
}
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"android: AudioManager.getMode failed; skipping mode engage (SDD-108)"
),
}
let cfg_av = crate::mobile_voice_backend::AndroidVoiceStreamConfig {
effects: cfg.effects,
..Default::default()
};
let params = crate::android_voice_unit::VoiceAudioParams {
voice_out_tx,
transmit_active: transmit_flag_for_capture,
frames_sent: frames_sent.clone(),
mic_gain: cfg.mic_gain,
handler: audio_handler.clone(),
output_gain: output_gain.clone(),
output_muted: output_muted.clone(),
};
let mut android_voice_unit =
crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params).map_err(|e| {
AudioError::Backend(format!("android: failed to open Oboe voice unit: {e}"))
})?;
if let Err(e) = android_voice_unit.start() {
return Err(AudioError::Backend(format!(
"android: failed to start Oboe voice unit: {e}"
)));
}
if let Some(mut event_rx) = android_voice_unit.take_event_rx() {
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if let BackendEvent::Disconnected = event {
warn!(
target: "chanora_audio",
"android: backend disconnected event received; stream reconnect requires session restart"
);
}
}
});
}
let capture_active = true;
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone();
let frames_received_for_task = frames_received.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = &mut shutdown_rx => {
debug!(target: "chanora_audio", "inbound forwarder shutting down");
break;
}
item = voice_in_rx.recv() => {
match item {
Some(v) => {
let id = SessionAudioId(v.from_client);
let mut h = handler_for_task.lock().unwrap();
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
None => break,
}
}
}
}
});
let ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog> = None;
Ok(Self {
transmit_gate,
frames_sent,
frames_received,
output_gain,
output_muted,
_android_voice_unit: Mutex::new(Some(android_voice_unit)),
audio_mode_stack: Mutex::new(audio_mode_stack),
shutdown_tx: Some(shutdown_tx),
capture_active,
@@ -783,7 +796,7 @@ impl AudioEngine {
// audio. iOS collapses input + output into one
// `IosVoiceUnit` (see `ios_voice_unit.rs`); every other
// platform has separate cpal input + cpal/SDL output.
#[cfg(not(target_os = "ios"))]
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
{
let _ = self._input_stream.lock().unwrap().take();
let _ = self._output_stream.lock().unwrap().take();
@@ -1023,7 +1036,7 @@ impl Drop for AudioEngine {
// ---------- Capture pipeline ----------
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
fn try_open_capture(
in_dev: &cpal::Device,
voice_out_tx: mpsc::Sender<OutPacket>,
@@ -1131,7 +1144,7 @@ fn try_open_capture(
Ok(stream)
}
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
struct CaptureState {
encoder: OpusEncoder,
in_sample_rate: u32,
@@ -1169,7 +1182,7 @@ struct CaptureState {
frame_scratch: Vec<f32>,
}
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
impl CaptureState {
fn new(
encoder: OpusEncoder,
@@ -1269,7 +1282,10 @@ impl CaptureState {
*s = -1.0;
}
}
match self.encoder.encode_float(&frame[..], &mut self.opus_out[..]) {
match self
.encoder
.encode_float(&frame[..], &mut self.opus_out[..])
{
Ok(len) => {
let packet = OutAudio::new(&AudioData::C2S {
id: 0,
@@ -1350,30 +1366,30 @@ impl CaptureState {
}
/// Per-sample format conversion to f32 in the range [-1.0, 1.0].
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
trait ToF32 {
fn to_f32_sample(self) -> f32;
}
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
impl ToF32 for f32 {
fn to_f32_sample(self) -> f32 {
self
}
}
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
impl ToF32 for i16 {
fn to_f32_sample(self) -> f32 {
f32::from(self) / f32::from(i16::MAX)
}
}
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
impl ToF32 for u16 {
fn to_f32_sample(self) -> f32 {
(f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX)
}
}
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
fn build_input_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
@@ -1401,7 +1417,7 @@ where
// ---------- Playback pipeline ----------
#[cfg(not(target_os = "linux"))]
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
fn build_output_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
@@ -1590,7 +1606,7 @@ where
/// Resampler state carried across output cpal callbacks. See
/// `build_output_stream` for the rationale.
#[cfg(not(target_os = "linux"))]
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
struct PlaybackResampleState {
pos: f64,
last_l: f32,
@@ -1598,26 +1614,26 @@ struct PlaybackResampleState {
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
trait FromF32 {
fn from_f32_sample(v: f32) -> Self;
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
impl FromF32 for f32 {
fn from_f32_sample(v: f32) -> Self {
v
}
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
impl FromF32 for i16 {
fn from_f32_sample(v: f32) -> Self {
(v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16
}
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
impl FromF32 for u16 {
fn from_f32_sample(v: f32) -> Self {
let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;
@@ -1720,12 +1736,12 @@ where
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
let service_name: JString = env.new_string("audio").map_err(|e| {
AudioModeError::MethodCallFailed {
method: "new_string",
detail: e.to_string(),
}
})?;
let service_name: JString =
env.new_string("audio")
.map_err(|e| AudioModeError::MethodCallFailed {
method: "new_string",
detail: e.to_string(),
})?;
let audio_manager = env
.call_method(
&context_obj,
@@ -1809,11 +1825,11 @@ pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
// is not a supported external API. Only compiled on non-iOS targets
// because `CaptureState` itself is gated on `cfg(not(target_os = "ios"))`.
// ---------------------------------------------------------------------------
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
#[doc(hidden)]
pub mod bench_seam {
use super::{
AtomicBool, AtomicU32, Arc, CaptureState, OpusApp, OpusChannels, OpusEncoder,
Arc, AtomicBool, AtomicU32, CaptureState, OpusApp, OpusChannels, OpusEncoder,
OpusSampleRate, OutPacket,
};
use tokio::sync::mpsc;
@@ -1840,12 +1856,9 @@ pub mod bench_seam {
/// resampler). `in_channels` selects the channel layout
/// (typically 1 or 2).
pub fn new(in_sample_rate: u32, in_channels: usize) -> Self {
let encoder = OpusEncoder::new(
OpusSampleRate::Hz48000,
OpusChannels::Mono,
OpusApp::Voip,
)
.expect("opus encoder init");
let encoder =
OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
.expect("opus encoder init");
let (tx, rx) = mpsc::channel::<OutPacket>(64);
let transmit_active = Arc::new(AtomicBool::new(true));
let frames_sent = Arc::new(AtomicU32::new(0));
+6 -1
View File
@@ -52,7 +52,7 @@ pub use engine::{AudioEngine, AudioEngineConfig};
// bench harness under `crates/chanora_audio/benches/` can construct a
// CaptureState and drive `ingest` without re-implementing the engine.
// Not part of the supported public API.
#[cfg(not(target_os = "ios"))]
#[cfg(not(any(target_os = "ios", target_os = "android")))]
#[doc(hidden)]
pub use engine::bench_seam;
pub use ptt::{AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel};
@@ -84,6 +84,11 @@ pub enum AudioError {
/// A backend-specific failure surfaced without a typed mapping.
#[error("audio backend: {0}")]
Backend(String),
/// Android platform not ready — ndk_context not initialised before
/// audio engine start. This occurs when `initChanoraContext` has not
/// been called before `voice_join` triggers the audio engine.
#[error("android platform not ready: ndk_context not initialised")]
PlatformNotReady,
}
/// Audio-effect toggles. Defaults match DEC-007 (AEC),
@@ -321,7 +321,8 @@ pub enum SharingModeChoice {
/// Returns the next sharing-mode to try (SDD-112 item 7,
/// SWE4-UV-049). Ladder: Exclusive -> Shared -> exhausted.
pub fn next_sharing_mode_after(attempted: &[SharingModeChoice]) -> Option<SharingModeChoice> {
const LADDER: [SharingModeChoice; 2] = [SharingModeChoice::Exclusive, SharingModeChoice::Shared];
const LADDER: [SharingModeChoice; 2] =
[SharingModeChoice::Exclusive, SharingModeChoice::Shared];
LADDER.iter().copied().find(|m| !attempted.contains(m))
}
@@ -746,8 +747,14 @@ mod tests {
/// on these tokens).
#[test]
fn swe4_uv_047_achieved_enum_display_is_stable() {
assert_eq!(format!("{}", AchievedPerformanceMode::LowLatency), "LowLatency");
assert_eq!(format!("{}", AchievedPerformanceMode::PowerSaving), "PowerSaving");
assert_eq!(
format!("{}", AchievedPerformanceMode::LowLatency),
"LowLatency"
);
assert_eq!(
format!("{}", AchievedPerformanceMode::PowerSaving),
"PowerSaving"
);
assert_eq!(format!("{}", AchievedPerformanceMode::None), "None");
assert_eq!(format!("{}", AchievedSharingMode::Exclusive), "Exclusive");
assert_eq!(format!("{}", AchievedSharingMode::Shared), "Shared");
+1 -1
View File
@@ -8,8 +8,8 @@
//! cancelled. Drives [`AudioTransmitGate`] directly.
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::watch;
use tokio::task::JoinHandle;
@@ -152,7 +152,8 @@ impl TransmitModeSelector {
/// permission revocation immediately silences the microphone
/// even mid-PTT.
pub fn set_permission_state(&self, state: PermissionGate) {
self.permission_state.store(state.as_u8(), Ordering::Relaxed);
self.permission_state
.store(state.as_u8(), Ordering::Relaxed);
self.recompute();
}
+108
View File
@@ -983,6 +983,18 @@ pub enum BridgeEvent {
mute: bool,
/// Current release-tail in milliseconds (0..=500).
release_tail_ms: u32,
/// Last confirmed authoritative channel id.
current_channel_id: Option<u64>,
/// Non-authoritative pending join target channel id.
pending_target_channel_id: Option<u64>,
/// Whether a new join intent is currently allowed.
can_join: bool,
/// Whether leave intent is currently allowed.
can_leave: bool,
/// Join projection sync state.
join_sync_state: BridgeVoiceJoinSyncState,
/// Last stable join error code, if any.
join_error_code: Option<BridgeVoiceJoinErrorCode>,
},
/// iOS audio interruption state (SDD-101).
InterruptionState {
@@ -1013,6 +1025,44 @@ pub enum BridgeEvent {
},
}
/// Bridge mirror of core join projection sync state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeVoiceJoinSyncState {
/// Reducer is ready to accept channel actions.
Ready,
/// Reducer is waiting on initial snapshot reconciliation.
SynchronizingInitialSnapshot,
/// Reducer is waiting on reconnect snapshot reconciliation.
SynchronizingReconnect,
}
/// Bridge mirror of stable join error/status codes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeVoiceJoinErrorCode {
/// Duplicate same-target join intent was coalesced.
DuplicateSameTargetCoalesced,
/// A different target was requested while one is already pending.
JoinAlreadyPendingDifferentTarget,
/// Join denied by server policy/permission.
JoinDenied,
/// Join failed due to protocol-level error.
JoinProtocolFailure,
/// Join failed due to transport/network error.
JoinNetworkFailure,
/// Join timed out awaiting confirmation.
JoinTimeout,
/// Pending join was superseded by user leave.
JoinSupersededByLeave,
/// Stale join outcome was ignored.
JoinStaleOutcomeIgnored,
/// Authoritative membership reconciled to different channel.
JoinReconciledDifferentChannel,
/// Join command was rejected before send acceptance.
JoinCommandRejectedBeforeSend,
/// Join intent rejected while reducer synchronizing.
JoinCannotStartWhileSynchronizing,
}
/// Schema-controlled mirror of the Kotlin
/// `AndroidPermissionRequester.PermissionState` sealed class
/// (SDD-106 §5). Crosses the bridge as an enum so the Dart side can
@@ -1094,11 +1144,23 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
transmit_mode,
mute,
release_tail_ms,
current_channel_id,
pending_target_channel_id,
can_join,
can_leave,
join_sync_state,
join_error_code,
} => BridgeEvent::VoiceState {
in_channel,
transmit_mode: transmit_mode_from_u8(transmit_mode),
mute,
release_tail_ms,
current_channel_id,
pending_target_channel_id,
can_join,
can_leave,
join_sync_state: map_join_sync_state(join_sync_state),
join_error_code: join_error_code.map(map_join_error_code),
},
chanora_core::SessionEvent::InterruptionState {
began,
@@ -1111,6 +1173,52 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
}
}
fn map_join_sync_state(state: chanora_core::VoiceJoinSyncState) -> BridgeVoiceJoinSyncState {
match state {
chanora_core::VoiceJoinSyncState::Ready => BridgeVoiceJoinSyncState::Ready,
chanora_core::VoiceJoinSyncState::SynchronizingInitialSnapshot => {
BridgeVoiceJoinSyncState::SynchronizingInitialSnapshot
}
chanora_core::VoiceJoinSyncState::SynchronizingReconnect => {
BridgeVoiceJoinSyncState::SynchronizingReconnect
}
}
}
fn map_join_error_code(code: chanora_core::VoiceJoinErrorCode) -> BridgeVoiceJoinErrorCode {
match code {
chanora_core::VoiceJoinErrorCode::DuplicateSameTargetCoalesced => {
BridgeVoiceJoinErrorCode::DuplicateSameTargetCoalesced
}
chanora_core::VoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget => {
BridgeVoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget
}
chanora_core::VoiceJoinErrorCode::JoinDenied => BridgeVoiceJoinErrorCode::JoinDenied,
chanora_core::VoiceJoinErrorCode::JoinProtocolFailure => {
BridgeVoiceJoinErrorCode::JoinProtocolFailure
}
chanora_core::VoiceJoinErrorCode::JoinNetworkFailure => {
BridgeVoiceJoinErrorCode::JoinNetworkFailure
}
chanora_core::VoiceJoinErrorCode::JoinTimeout => BridgeVoiceJoinErrorCode::JoinTimeout,
chanora_core::VoiceJoinErrorCode::JoinSupersededByLeave => {
BridgeVoiceJoinErrorCode::JoinSupersededByLeave
}
chanora_core::VoiceJoinErrorCode::JoinStaleOutcomeIgnored => {
BridgeVoiceJoinErrorCode::JoinStaleOutcomeIgnored
}
chanora_core::VoiceJoinErrorCode::JoinReconciledDifferentChannel => {
BridgeVoiceJoinErrorCode::JoinReconciledDifferentChannel
}
chanora_core::VoiceJoinErrorCode::JoinCommandRejectedBeforeSend => {
BridgeVoiceJoinErrorCode::JoinCommandRejectedBeforeSend
}
chanora_core::VoiceJoinErrorCode::JoinCannotStartWhileSynchronizing => {
BridgeVoiceJoinErrorCode::JoinCannotStartWhileSynchronizing
}
}
}
/// Subscribe to lifecycle events. Each call yields a fresh
/// subscription; multiple subscribers are supported. On slow
/// consumers, events are dropped rather than blocking the supervisor
+213
View File
@@ -1403,11 +1403,25 @@ impl SseDecode for crate::api::BridgeEvent {
<crate::api::BridgeTransmitMode>::sse_decode(deserializer);
let mut var_mute = <bool>::sse_decode(deserializer);
let mut var_releaseTailMs = <u32>::sse_decode(deserializer);
let mut var_currentChannelId = <Option<u64>>::sse_decode(deserializer);
let mut var_pendingTargetChannelId = <Option<u64>>::sse_decode(deserializer);
let mut var_canJoin = <bool>::sse_decode(deserializer);
let mut var_canLeave = <bool>::sse_decode(deserializer);
let mut var_joinSyncState =
<crate::api::BridgeVoiceJoinSyncState>::sse_decode(deserializer);
let mut var_joinErrorCode =
<Option<crate::api::BridgeVoiceJoinErrorCode>>::sse_decode(deserializer);
return crate::api::BridgeEvent::VoiceState {
in_channel: var_inChannel,
transmit_mode: var_transmitMode,
mute: var_mute,
release_tail_ms: var_releaseTailMs,
current_channel_id: var_currentChannelId,
pending_target_channel_id: var_pendingTargetChannelId,
can_join: var_canJoin,
can_leave: var_canLeave,
join_sync_state: var_joinSyncState,
join_error_code: var_joinErrorCode,
};
}
9 => {
@@ -1494,6 +1508,40 @@ impl SseDecode for crate::api::BridgeTransmitMode {
}
}
impl SseDecode for 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 {
let mut inner = <i32>::sse_decode(deserializer);
return match inner {
0 => crate::api::BridgeVoiceJoinErrorCode::DuplicateSameTargetCoalesced,
1 => crate::api::BridgeVoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget,
2 => crate::api::BridgeVoiceJoinErrorCode::JoinDenied,
3 => crate::api::BridgeVoiceJoinErrorCode::JoinProtocolFailure,
4 => crate::api::BridgeVoiceJoinErrorCode::JoinNetworkFailure,
5 => crate::api::BridgeVoiceJoinErrorCode::JoinTimeout,
6 => crate::api::BridgeVoiceJoinErrorCode::JoinSupersededByLeave,
7 => crate::api::BridgeVoiceJoinErrorCode::JoinStaleOutcomeIgnored,
8 => crate::api::BridgeVoiceJoinErrorCode::JoinReconciledDifferentChannel,
9 => crate::api::BridgeVoiceJoinErrorCode::JoinCommandRejectedBeforeSend,
10 => crate::api::BridgeVoiceJoinErrorCode::JoinCannotStartWhileSynchronizing,
_ => unreachable!("Invalid variant for BridgeVoiceJoinErrorCode: {}", inner),
};
}
}
impl SseDecode for crate::api::BridgeVoiceJoinSyncState {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <i32>::sse_decode(deserializer);
return match inner {
0 => crate::api::BridgeVoiceJoinSyncState::Ready,
1 => crate::api::BridgeVoiceJoinSyncState::SynchronizingInitialSnapshot,
2 => crate::api::BridgeVoiceJoinSyncState::SynchronizingReconnect,
_ => unreachable!("Invalid variant for BridgeVoiceJoinSyncState: {}", inner),
};
}
}
impl SseDecode for f32 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1563,6 +1611,30 @@ impl SseDecode for Vec<u8> {
}
}
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 {
if (<bool>::sse_decode(deserializer)) {
return Some(<crate::api::BridgeVoiceJoinErrorCode>::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 {
if (<bool>::sse_decode(deserializer)) {
return Some(<u64>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for crate::api::PermissionStateKind {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1841,12 +1913,24 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
transmit_mode,
mute,
release_tail_ms,
current_channel_id,
pending_target_channel_id,
can_join,
can_leave,
join_sync_state,
join_error_code,
} => [
8.into_dart(),
in_channel.into_into_dart().into_dart(),
transmit_mode.into_into_dart().into_dart(),
mute.into_into_dart().into_dart(),
release_tail_ms.into_into_dart().into_dart(),
current_channel_id.into_into_dart().into_dart(),
pending_target_channel_id.into_into_dart().into_dart(),
can_join.into_into_dart().into_dart(),
can_leave.into_into_dart().into_dart(),
join_sync_state.into_into_dart().into_dart(),
join_error_code.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::InterruptionState {
@@ -1964,6 +2048,58 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeTransmitMode>
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeVoiceJoinErrorCode {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
Self::DuplicateSameTargetCoalesced => 0.into_dart(),
Self::JoinAlreadyPendingDifferentTarget => 1.into_dart(),
Self::JoinDenied => 2.into_dart(),
Self::JoinProtocolFailure => 3.into_dart(),
Self::JoinNetworkFailure => 4.into_dart(),
Self::JoinTimeout => 5.into_dart(),
Self::JoinSupersededByLeave => 6.into_dart(),
Self::JoinStaleOutcomeIgnored => 7.into_dart(),
Self::JoinReconciledDifferentChannel => 8.into_dart(),
Self::JoinCommandRejectedBeforeSend => 9.into_dart(),
Self::JoinCannotStartWhileSynchronizing => 10.into_dart(),
_ => unreachable!(),
}
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgeVoiceJoinErrorCode
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeVoiceJoinErrorCode>
for crate::api::BridgeVoiceJoinErrorCode
{
fn into_into_dart(self) -> crate::api::BridgeVoiceJoinErrorCode {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeVoiceJoinSyncState {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
Self::Ready => 0.into_dart(),
Self::SynchronizingInitialSnapshot => 1.into_dart(),
Self::SynchronizingReconnect => 2.into_dart(),
_ => unreachable!(),
}
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgeVoiceJoinSyncState
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeVoiceJoinSyncState>
for crate::api::BridgeVoiceJoinSyncState
{
fn into_into_dart(self) -> crate::api::BridgeVoiceJoinSyncState {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::PermissionStateKind {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
@@ -2146,12 +2282,27 @@ impl SseEncode for crate::api::BridgeEvent {
transmit_mode,
mute,
release_tail_ms,
current_channel_id,
pending_target_channel_id,
can_join,
can_leave,
join_sync_state,
join_error_code,
} => {
<i32>::sse_encode(8, serializer);
<bool>::sse_encode(in_channel, serializer);
<crate::api::BridgeTransmitMode>::sse_encode(transmit_mode, serializer);
<bool>::sse_encode(mute, serializer);
<u32>::sse_encode(release_tail_ms, serializer);
<Option<u64>>::sse_encode(current_channel_id, serializer);
<Option<u64>>::sse_encode(pending_target_channel_id, serializer);
<bool>::sse_encode(can_join, serializer);
<bool>::sse_encode(can_leave, serializer);
<crate::api::BridgeVoiceJoinSyncState>::sse_encode(join_sync_state, serializer);
<Option<crate::api::BridgeVoiceJoinErrorCode>>::sse_encode(
join_error_code,
serializer,
);
}
crate::api::BridgeEvent::InterruptionState {
began,
@@ -2237,6 +2388,48 @@ impl SseEncode for crate::api::BridgeTransmitMode {
}
}
impl SseEncode for 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) {
<i32>::sse_encode(
match self {
crate::api::BridgeVoiceJoinErrorCode::DuplicateSameTargetCoalesced => 0,
crate::api::BridgeVoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget => 1,
crate::api::BridgeVoiceJoinErrorCode::JoinDenied => 2,
crate::api::BridgeVoiceJoinErrorCode::JoinProtocolFailure => 3,
crate::api::BridgeVoiceJoinErrorCode::JoinNetworkFailure => 4,
crate::api::BridgeVoiceJoinErrorCode::JoinTimeout => 5,
crate::api::BridgeVoiceJoinErrorCode::JoinSupersededByLeave => 6,
crate::api::BridgeVoiceJoinErrorCode::JoinStaleOutcomeIgnored => 7,
crate::api::BridgeVoiceJoinErrorCode::JoinReconciledDifferentChannel => 8,
crate::api::BridgeVoiceJoinErrorCode::JoinCommandRejectedBeforeSend => 9,
crate::api::BridgeVoiceJoinErrorCode::JoinCannotStartWhileSynchronizing => 10,
_ => {
unimplemented!("");
}
},
serializer,
);
}
}
impl SseEncode for crate::api::BridgeVoiceJoinSyncState {
// 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(
match self {
crate::api::BridgeVoiceJoinSyncState::Ready => 0,
crate::api::BridgeVoiceJoinSyncState::SynchronizingInitialSnapshot => 1,
crate::api::BridgeVoiceJoinSyncState::SynchronizingReconnect => 2,
_ => {
unimplemented!("");
}
},
serializer,
);
}
}
impl SseEncode for f32 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -2298,6 +2491,26 @@ impl SseEncode for Vec<u8> {
}
}
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) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<crate::api::BridgeVoiceJoinErrorCode>::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) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<u64>::sse_encode(value, serializer);
}
}
}
impl SseEncode for crate::api::PermissionStateKind {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
File diff suppressed because it is too large Load Diff
+2
View File
@@ -13,6 +13,8 @@
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod channel_join;
use thiserror::Error;
/// Errors raised while reducing protocol events into state or