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
+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();
}