feat(voice): harden Android audio and channel joins
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user