diff --git a/Cargo.lock b/Cargo.lock
index 8e850f6..898569e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -393,6 +393,7 @@ dependencies = [
"futures-util",
"jni 0.21.1",
"ndk-context",
+ "oboe",
"rand 0.8.6",
"reqwest",
"sdl2",
@@ -2293,6 +2294,26 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "oboe"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
+dependencies = [
+ "num-derive",
+ "num-traits",
+ "oboe-sys",
+]
+
+[[package]]
+name = "oboe-sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
+dependencies = [
+ "cc",
+]
+
[[package]]
name = "omnom"
version = "3.0.0"
diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml
index cad9dd9..fa0c5d9 100644
--- a/crates/chanora_audio/Cargo.toml
+++ b/crates/chanora_audio/Cargo.toml
@@ -61,6 +61,12 @@ reqwest = { version = "0.13", default-features = false, features = ["charset", "
# by the bridge crate's android_init shim.
jni = { version = "0.21", default-features = false }
ndk-context = "0.1"
+# Oboe-rs (Google Oboe wrapper) for low-latency voice capture + playback.
+# Primary backend for SDD-111..SDD-115. The pre-compiled static library
+# shipped with `oboe-sys` 0.6 covers armv7 / aarch64 / x86 / x86_64.
+# Default features keep the precompiled library + pregenerated bindings
+# so we avoid the clang-sys / libclang requirement on the build host.
+oboe = "0.6"
[target.'cfg(target_os = "windows")'.dependencies]
# Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices
diff --git a/crates/chanora_audio/src/android_voice_unit.rs b/crates/chanora_audio/src/android_voice_unit.rs
new file mode 100644
index 0000000..63b2ac2
--- /dev/null
+++ b/crates/chanora_audio/src/android_voice_unit.rs
@@ -0,0 +1,923 @@
+//! Android voice audio backend (SDD-111..SDD-115).
+//!
+//! Implements the `MobileVoiceAudioBackend` trait against
+//! `oboe-rs 0.6.x`, which wraps Google's Oboe C++ library on top of
+//! AAudio (Android 8.1+) and OpenSL ES (legacy). The backend owns
+//! the lifetime of a paired voice input / output stream pair and
+//! drives the SDD-113 hardware-effect attach via JNI.
+//!
+//! ## Manifest contract (SDD-114)
+//!
+//! This module assumes the Android manifest already declares
+//! (landed by Wave 2B-1):
+//!
+//! - `INTERNET`, `RECORD_AUDIO`, `FOREGROUND_SERVICE`,
+//! `FOREGROUND_SERVICE_MICROPHONE`, `POST_NOTIFICATIONS`,
+//! `MODIFY_AUDIO_SETTINGS`, `BLUETOOTH_CONNECT`
+//! - ``
+//!
+//! Regressions against the manifest are SDD-114 violations and are
+//! caught by the CI assertion described in SDD-114 item 4. This
+//! file does NOT mutate the manifest.
+//!
+//! ## Callback safety
+//!
+//! Oboe audio callbacks run on real-time threads. Under `panic=abort`
+//! a panic in an audio callback aborts the process. Every callback
+//! path in this module:
+//!
+//! 1. Catches unwinds at the FFI boundary (`std::panic::catch_unwind`).
+//! 2. Never blocks (no mutex/RwLock acquisition; only `try_send` on
+//! bounded channels).
+//! 3. Marshals events (error/disconnect, focus change, route change)
+//! to a regular tokio task via an `mpsc::UnboundedSender` so all
+//! engine-state mutation happens off the audio thread (SDD-115).
+
+#![cfg(target_os = "android")]
+#![allow(dead_code)]
+
+use std::panic::{catch_unwind, AssertUnwindSafe};
+
+use tracing::{info, warn};
+
+use crate::mobile_voice_backend::{
+ clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after,
+ next_sharing_mode_after, publish_android_audio_diagnostics, AchievedInputPreset,
+ AchievedPerformanceMode, AchievedSharingMode, AndroidAudioDiagnostics,
+ AndroidVoiceStreamConfig, AudioSessionId, BackendError, BackendEvent, BackendEventRx,
+ BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
+ SharingModeChoice,
+};
+
+use tokio::sync::mpsc;
+
+use oboe::{
+ AudioInputCallback, AudioInputStreamSafe, AudioOutputCallback, AudioOutputStreamSafe,
+ AudioStream, AudioStreamAsync, AudioStreamBase, AudioStreamBuilder, AudioStreamSafe,
+ DataCallbackResult, Input as OboeInput, InputPreset, Mono, Output as OboeOutput,
+ PerformanceMode, SessionId, SharingMode, Usage,
+};
+
+// `BackendEvent` / `BackendEventRx` / `BackendEventTx` moved to
+// `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) ----
+//
+// 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).
+
+struct InputCallback {
+ event_tx: BackendEventTx,
+}
+
+impl AudioInputCallback for InputCallback {
+ type FrameType = (i16, Mono);
+
+ fn on_audio_ready(
+ &mut self,
+ _stream: &mut dyn AudioInputStreamSafe,
+ _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.
+ }));
+ 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"
+ );
+ }
+ }
+}
+
+struct OutputCallback {
+ event_tx: BackendEventTx,
+}
+
+impl AudioOutputCallback for OutputCallback {
+ type FrameType = (i16, Mono);
+
+ fn on_audio_ready(
+ &mut self,
+ _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;
+ }
+ }));
+ DataCallbackResult::Continue
+ }
+
+ fn on_error_after_close(
+ &mut self,
+ _stream: &mut dyn AudioOutputStreamSafe,
+ error: oboe::Error,
+ ) {
+ 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"
+ );
+ }
+ }
+}
+
+// --- The backend itself ------------------------------------------
+
+/// Android voice-audio backend (SDD-111). Owns one input + one
+/// output Oboe stream and the JNI handles for SDD-113 hardware
+/// effects.
+pub struct AndroidVoiceUnit {
+ input: Option>,
+ output: Option>,
+
+ // Recorded achieved values (SDD-112).
+ input_perf: AchievedPerformanceMode,
+ input_share: AchievedSharingMode,
+ output_perf: AchievedPerformanceMode,
+ output_share: AchievedSharingMode,
+ input_sample_rate: i32,
+ output_sample_rate: i32,
+ input_frames_per_burst: i32,
+ output_frames_per_burst: i32,
+
+ session_id: Option,
+
+ // SDD-113 hardware effect handles. Each is a JNI `GlobalRef`
+ // we keep alive for the lifetime of the input stream.
+ hw_effects: HardwareEffectHandles,
+
+ event_tx: BackendEventTx,
+ event_rx: Option,
+}
+
+#[derive(Default)]
+struct HardwareEffectHandles {
+ aec: Option,
+ ns: Option,
+ agc: Option,
+}
+
+impl AndroidVoiceUnit {
+ /// Open the input + output streams (SDD-111 + SDD-112) and,
+ /// once a session id is available, attach SDD-113 hardware
+ /// 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 {
+ let (event_tx, event_rx) = mpsc::unbounded_channel();
+
+ // --- Open input stream (SDD-112) ---------------------------
+ let mut input_builder = AudioStreamBuilder::default()
+ .set_direction::()
+ .set_sample_rate(cfg.sample_rate as i32)
+ .set_channel_count::()
+ .set_format::()
+ .set_performance_mode(if cfg.request_low_latency {
+ PerformanceMode::LowLatency
+ } else {
+ PerformanceMode::None
+ })
+ .set_sharing_mode(if cfg.request_exclusive {
+ SharingMode::Exclusive
+ } else {
+ SharingMode::Shared
+ })
+ // SDD-113 item 1 amends SDD-112: input stream MUST be
+ // opened with session id allocation so platform effects
+ // can attach.
+ .set_session_id(SessionId::Allocate)
+ .set_input_preset(InputPreset::VoiceCommunication)
+ .set_usage(Usage::VoiceCommunication);
+
+ let input_cb = InputCallback {
+ event_tx: event_tx.clone(),
+ };
+ let input_builder = input_builder.set_callback(input_cb);
+
+ let mut input_stream = match input_builder.open_stream() {
+ Ok(s) => s,
+ Err(e) => {
+ // Input-preset fallback ladder (SDD-112 item 6) X
+ // Sharing-mode ladder (SDD-112 item 7), explored
+ // independently via the pure helpers so all
+ // (preset × sharing) rungs are reachable.
+ warn!(
+ target: "chanora_audio",
+ error = ?e,
+ "android: primary input stream open failed; entering fallback ladder"
+ );
+ Self::open_input_fallback(cfg, &event_tx)?
+ }
+ };
+
+ let input_perf = perf_from_oboe(input_stream.get_performance_mode());
+ let input_share = share_from_oboe(input_stream.get_sharing_mode());
+ let input_sample_rate = input_stream.get_sample_rate();
+ let input_frames_per_burst = input_stream.get_frames_per_burst();
+ let session_id = match input_stream.get_session_id() {
+ SessionId::None => None,
+ // `SessionId::Allocate` is the request value; once a
+ // stream is open, oboe returns the concrete allocated
+ // id via `SessionId::Id(i32)` in some versions or via
+ // a getter — defensively match.
+ other => session_id_value(other),
+ };
+
+ // --- Open output stream (SDD-112) --------------------------
+ let output_builder = AudioStreamBuilder::default()
+ .set_direction::()
+ .set_sample_rate(cfg.sample_rate as i32)
+ .set_channel_count::()
+ .set_format::()
+ .set_performance_mode(if cfg.request_low_latency {
+ PerformanceMode::LowLatency
+ } else {
+ PerformanceMode::None
+ })
+ .set_sharing_mode(if cfg.request_exclusive {
+ SharingMode::Exclusive
+ } else {
+ SharingMode::Shared
+ })
+ .set_usage(Usage::VoiceCommunication)
+ .set_content_type(oboe::ContentType::Speech);
+
+ let output_cb = OutputCallback {
+ event_tx: event_tx.clone(),
+ };
+ let output_builder = output_builder.set_callback(output_cb);
+
+ let mut output_stream = match output_builder.open_stream() {
+ Ok(s) => s,
+ Err(e) => {
+ warn!(
+ target: "chanora_audio",
+ error = ?e,
+ "android: primary output stream open failed; retrying with Shared sharing mode"
+ );
+ Self::open_output_fallback(cfg, &event_tx)?
+ }
+ };
+
+ let output_perf = perf_from_oboe(output_stream.get_performance_mode());
+ let output_share = share_from_oboe(output_stream.get_sharing_mode());
+ let output_sample_rate = output_stream.get_sample_rate();
+ let output_frames_per_burst = output_stream.get_frames_per_burst();
+
+ // SDD-112 / SRS-210: structured "stream opened" event with
+ // achieved values. No PII; only platform-reported scalars.
+ info!(
+ target: "chanora_audio",
+ event = "audio.android.stream_opened",
+ input_perf = %input_perf,
+ input_share = %input_share,
+ input_sample_rate,
+ input_frames_per_burst,
+ output_perf = %output_perf,
+ output_share = %output_share,
+ output_sample_rate,
+ output_frames_per_burst,
+ session_id = ?session_id,
+ "android: voice streams opened"
+ );
+
+ // --- SDD-113 hardware effects -----------------------------
+ let hw_effects = if let Some(sid) = session_id {
+ attach_hardware_effects(sid, &cfg.effects)
+ } else {
+ warn!(
+ target: "chanora_audio",
+ "android: no session id from input stream; hardware effects not bound — engine software AEC/NS/AGC will engage"
+ );
+ HardwareEffectHandles::default()
+ };
+
+ // --- SDD-112 item 10 / SDD-113 item 7 / SDD-116 item 3 ---
+ // Publish the diagnostics snapshot. Per-effect engagement is
+ // derived from (a) the JNI handle (`Hardware`) or (b) the
+ // requested-effects mask (`Software` fallback) or (c) neither
+ // (`None`). The achieved input preset readback is `Unknown`
+ // until the oboe-rs wrapper exposes a getter (SWE4-UV-052
+ // follow-through). No PII per SDD-090.
+ let aec = effect_engagement(cfg.effects.aec, hw_effects.aec.is_some());
+ let ns = effect_engagement(cfg.effects.noise_suppression, hw_effects.ns.is_some());
+ let agc = effect_engagement(cfg.effects.agc, hw_effects.agc.is_some());
+ let diagnostics = AndroidAudioDiagnostics {
+ // SDD-112 items 4..7: requested-side pinned values.
+ requested_performance_mode: if cfg.request_low_latency {
+ "LowLatency"
+ } else {
+ "None"
+ },
+ requested_usage: "VoiceCommunication",
+ requested_content_type: "Speech",
+ requested_input_preset: "VoiceCommunication",
+ requested_sharing_mode: if cfg.request_exclusive {
+ "Exclusive"
+ } else {
+ "Shared"
+ },
+ requested_sample_rate_hz: cfg.sample_rate,
+ // SDD-112 item 4 / 7 achieved side.
+ achieved_performance_mode: input_perf,
+ achieved_sharing_mode: input_share,
+ achieved_input_preset: AchievedInputPreset::Unknown,
+ achieved_sample_rate_hz: input_sample_rate.max(0) as u32,
+ achieved_frames_per_burst: input_frames_per_burst.max(0) as u32,
+ // SDD-113 item 7 / SRS-212 per-effect engagement.
+ aec,
+ ns,
+ agc,
+ // SDD-116 / SRS-210 latency-tier classification.
+ latency_tier: latency_tier_for(input_perf),
+ };
+ publish_android_audio_diagnostics(diagnostics);
+
+ Ok(Self {
+ input: Some(input_stream),
+ output: Some(output_stream),
+ input_perf,
+ input_share,
+ output_perf,
+ output_share,
+ input_sample_rate,
+ output_sample_rate,
+ input_frames_per_burst,
+ output_frames_per_burst,
+ session_id,
+ hw_effects,
+ event_tx,
+ event_rx: Some(event_rx),
+ })
+ }
+
+ fn open_input_fallback(
+ cfg: &AndroidVoiceStreamConfig,
+ event_tx: &BackendEventTx,
+ ) -> Result, BackendError> {
+ // SDD-112 items 6 & 7: explore (preset × sharing) independently
+ // via the pure helpers in `mobile_voice_backend`. Primary
+ // attempt (VoiceCommunication × Exclusive) was tried by the
+ // caller; here we walk the remaining rungs of both ladders.
+ let presets = [
+ InputPresetChoice::VoiceCommunication,
+ InputPresetChoice::VoicePerformance,
+ InputPresetChoice::Generic,
+ ];
+ let sharings = [SharingModeChoice::Exclusive, SharingModeChoice::Shared];
+ // Track attempted presets / sharings so the helpers' order
+ // statements are honoured in tests and operations.
+ let mut attempted_presets: Vec = Vec::new();
+ while let Some(preset_choice) = next_input_preset_after(&attempted_presets) {
+ attempted_presets.push(preset_choice);
+ let mut attempted_sharings: Vec = Vec::new();
+ while let Some(sharing_choice) = next_sharing_mode_after(&attempted_sharings) {
+ attempted_sharings.push(sharing_choice);
+ // Skip the rung the primary attempt already used.
+ if matches!(preset_choice, InputPresetChoice::VoiceCommunication)
+ && matches!(sharing_choice, SharingModeChoice::Exclusive)
+ {
+ continue;
+ }
+ let preset = match preset_choice {
+ InputPresetChoice::VoiceCommunication => InputPreset::VoiceCommunication,
+ InputPresetChoice::VoicePerformance => InputPreset::VoicePerformance,
+ InputPresetChoice::Generic => InputPreset::Generic,
+ };
+ let sharing = match sharing_choice {
+ SharingModeChoice::Exclusive => SharingMode::Exclusive,
+ SharingModeChoice::Shared => SharingMode::Shared,
+ };
+ let cb = InputCallback {
+ event_tx: event_tx.clone(),
+ };
+ let builder = AudioStreamBuilder::default()
+ .set_direction::()
+ .set_sample_rate(cfg.sample_rate as i32)
+ .set_channel_count::()
+ .set_format::()
+ .set_performance_mode(PerformanceMode::LowLatency)
+ .set_sharing_mode(sharing)
+ .set_session_id(SessionId::Allocate)
+ .set_input_preset(preset)
+ .set_usage(Usage::VoiceCommunication)
+ .set_callback(cb);
+
+ match builder.open_stream() {
+ Ok(s) => return Ok(s),
+ Err(e) => warn!(
+ target: "chanora_audio",
+ error = ?e,
+ ?preset_choice,
+ ?sharing_choice,
+ "android: input fallback rung failed"
+ ),
+ }
+ }
+ }
+ let _ = (presets, sharings); // documented coverage source
+ Err(BackendError::OpenFailed(
+ "all input preset / sharing fallbacks exhausted".to_string(),
+ ))
+ }
+
+ fn open_output_fallback(
+ cfg: &AndroidVoiceStreamConfig,
+ event_tx: &BackendEventTx,
+ ) -> Result, BackendError> {
+ let cb = OutputCallback {
+ event_tx: event_tx.clone(),
+ };
+ let builder = AudioStreamBuilder::default()
+ .set_direction::()
+ .set_sample_rate(cfg.sample_rate as i32)
+ .set_channel_count::()
+ .set_format::()
+ .set_performance_mode(PerformanceMode::LowLatency)
+ .set_sharing_mode(SharingMode::Shared)
+ .set_usage(Usage::VoiceCommunication)
+ .set_content_type(oboe::ContentType::Speech)
+ .set_callback(cb);
+ builder
+ .open_stream()
+ .map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
+ }
+
+ /// Clone of the event sender, for JNI focus / SCO listeners
+ /// registered on the engine's behalf.
+ pub fn event_sender(&self) -> BackendEventTx {
+ self.event_tx.clone()
+ }
+}
+
+impl MobileVoiceAudioBackend for AndroidVoiceUnit {
+ fn start(&mut self) -> Result<(), BackendError> {
+ if let Some(s) = self.input.as_mut() {
+ s.start()
+ .map_err(|e| BackendError::LifecycleFailed(format!("input start: {e:?}")))?;
+ }
+ if let Some(s) = self.output.as_mut() {
+ s.start()
+ .map_err(|e| BackendError::LifecycleFailed(format!("output start: {e:?}")))?;
+ }
+ Ok(())
+ }
+
+ fn stop(&mut self) -> Result<(), BackendError> {
+ if let Some(s) = self.input.as_mut() {
+ // best-effort stop — log on failure but continue
+ // tearing down the rest of the pair.
+ if let Err(e) = s.stop() {
+ warn!(target: "chanora_audio", error = ?e, "android: input stop failed");
+ }
+ }
+ if let Some(s) = self.output.as_mut() {
+ if let Err(e) = s.stop() {
+ warn!(target: "chanora_audio", error = ?e, "android: output stop failed");
+ }
+ }
+ Ok(())
+ }
+
+ fn close(&mut self) -> Result<(), BackendError> {
+ // SDD-115 reverse order: release hardware effects FIRST,
+ // then close streams.
+ release_hardware_effects(&mut self.hw_effects);
+ self.stop().ok();
+ // Dropping the Option drops the underlying AudioStreamAsync
+ // which Oboe-safe-closes the stream.
+ self.input = None;
+ self.output = None;
+ // SDD-116: clear the diagnostics slot so a stale snapshot
+ // does not survive past the voice session.
+ clear_android_audio_diagnostics();
+ Ok(())
+ }
+
+ fn session_id(&self) -> Option {
+ self.session_id
+ }
+
+ fn take_event_rx(&mut self) -> Option {
+ self.event_rx.take()
+ }
+
+ fn achieved_sample_rate(&self) -> u32 {
+ self.input_sample_rate.max(0) as u32
+ }
+
+ fn achieved_input_preset(&self) -> AchievedInputPreset {
+ // The oboe-rs wrapper does not expose a preset-readback as of
+ // 0.6.x; record `Unknown` until a readback path lands
+ // (SWE4-UV-052 follow-through).
+ AchievedInputPreset::Unknown
+ }
+
+ fn achieved_frames_per_burst(&self) -> u32 {
+ self.input_frames_per_burst.max(0) as u32
+ }
+
+ fn achieved_input_performance_mode(&self) -> AchievedPerformanceMode {
+ self.input_perf
+ }
+ fn achieved_input_sharing_mode(&self) -> AchievedSharingMode {
+ self.input_share
+ }
+ fn achieved_output_performance_mode(&self) -> AchievedPerformanceMode {
+ self.output_perf
+ }
+ fn achieved_output_sharing_mode(&self) -> AchievedSharingMode {
+ self.output_share
+ }
+}
+
+impl Drop for AndroidVoiceUnit {
+ fn drop(&mut self) {
+ // Belt-and-braces: if `close()` was not called explicitly,
+ // tear hardware effects down here so the JNI globals are
+ // released before the stream's session id evaporates.
+ // Wrap in catch_unwind so a panic during Drop cannot unwind
+ // into the JVM (SDD-115 callback safety).
+ let _ = catch_unwind(AssertUnwindSafe(|| {
+ release_hardware_effects(&mut self.hw_effects);
+ // SDD-116: clear the diagnostics slot on Drop too.
+ clear_android_audio_diagnostics();
+ }));
+ }
+}
+
+// --- helpers -----------------------------------------------------
+
+/// Derive per-effect engagement (SDD-113 item 7) from the request
+/// mask and the JNI binding outcome. Hardware: JNI ref retained.
+/// Software: requested but hardware binding failed → engine
+/// software AEC/NS/AGC carries it. None: not requested.
+fn effect_engagement(requested: bool, hardware_bound: bool) -> EffectEngagement {
+ match (requested, hardware_bound) {
+ (true, true) => EffectEngagement {
+ engaged: true,
+ engine: EffectEngine::Hardware,
+ },
+ (true, false) => EffectEngagement {
+ engaged: true,
+ engine: EffectEngine::Software,
+ },
+ (false, _) => EffectEngagement {
+ engaged: false,
+ engine: EffectEngine::None,
+ },
+ }
+}
+
+fn perf_from_oboe(p: PerformanceMode) -> AchievedPerformanceMode {
+ match p {
+ PerformanceMode::LowLatency => AchievedPerformanceMode::LowLatency,
+ PerformanceMode::PowerSaving => AchievedPerformanceMode::PowerSaving,
+ PerformanceMode::None => AchievedPerformanceMode::None,
+ }
+}
+
+fn share_from_oboe(s: SharingMode) -> AchievedSharingMode {
+ match s {
+ SharingMode::Exclusive => AchievedSharingMode::Exclusive,
+ SharingMode::Shared => AchievedSharingMode::Shared,
+ }
+}
+
+fn session_id_value(sid: SessionId) -> Option {
+ // SessionId is a Rust enum from oboe-rs. `Allocate` is the
+ // request token; once allocated the platform returns a positive
+ // integer carried in the enum's tuple variant. Match explicitly
+ // — `mem::transmute` would be UB on a non-`#[repr(i32)]` enum,
+ // even if it happens to lay out correctly today.
+ match sid {
+ SessionId::None => None,
+ // `SessionId::Allocate` is a request marker; treat it as
+ // "id not yet known" rather than fabricating one.
+ SessionId::Allocate => None,
+ }
+}
+
+// --- SDD-113 hardware-effect binding -----------------------------
+//
+// We attach `AcousticEchoCanceler`, `NoiseSuppressor`,
+// `AutomaticGainControl` against the input stream's session id via
+// JNI. Per-effect failure falls back to the engine's software path;
+// failure is **never** propagated to the user (SDD-113 item 5).
+
+fn attach_hardware_effects(
+ session_id: AudioSessionId,
+ effects: &crate::AudioEffects,
+) -> HardwareEffectHandles {
+ // 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)));
+ match result {
+ Ok(h) => h,
+ Err(_) => {
+ warn!(
+ target: "chanora_audio",
+ "android: attach_hardware_effects panicked; caught at FFI boundary (software fallback engages)"
+ );
+ HardwareEffectHandles::default()
+ }
+ }
+}
+
+fn attach_hardware_effects_inner(
+ session_id: AudioSessionId,
+ effects: &crate::AudioEffects,
+) -> HardwareEffectHandles {
+ let ctx = ndk_context::android_context();
+ if ctx.vm().is_null() {
+ warn!(
+ target: "chanora_audio",
+ "android: ndk_context vm null; cannot bind hardware effects (software fallback engages)"
+ );
+ return HardwareEffectHandles::default();
+ }
+ let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
+ Ok(v) => v,
+ Err(e) => {
+ warn!(target: "chanora_audio", error = %e, "android: JavaVM::from_raw failed; effects not bound");
+ return HardwareEffectHandles::default();
+ }
+ };
+ let mut env = match jvm.attach_current_thread() {
+ Ok(e) => e,
+ Err(e) => {
+ warn!(target: "chanora_audio", error = %e, "android: attach_current_thread failed; effects not bound");
+ return HardwareEffectHandles::default();
+ }
+ };
+
+ let mut handles = HardwareEffectHandles::default();
+ if effects.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");
+ }
+ if effects.agc {
+ handles.agc = create_effect(&mut env, "android/media/audiofx/AutomaticGainControl", session_id, "AGC");
+ }
+ handles
+}
+
+/// SDD-113 item 3: probe the static `isAvailable()` on each effect
+/// class before calling `create(int)`. Returns `false` on any JNI
+/// failure so the caller engages the software fallback.
+fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, label: &str) -> bool {
+ match env.call_static_method(class, "isAvailable", "()Z", &[]) {
+ Ok(v) => match v.z() {
+ Ok(b) => b,
+ Err(e) => {
+ let _ = env.exception_clear();
+ warn!(target: "chanora_audio", error = %e, effect = label, "android: isAvailable() return cast failed");
+ false
+ }
+ },
+ Err(e) => {
+ let _ = env.exception_clear();
+ warn!(target: "chanora_audio", error = %e, effect = label, "android: isAvailable() threw");
+ false
+ }
+ }
+}
+
+fn create_effect(
+ env: &mut jni::JNIEnv,
+ fqcn: &str,
+ session_id: AudioSessionId,
+ label: &str,
+) -> Option {
+ use jni::objects::JValue;
+ // Class.create(int) -> ClassInstance|null
+ let class = match env.find_class(fqcn) {
+ Ok(c) => c,
+ Err(e) => {
+ warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
+ return None;
+ }
+ };
+ // SDD-113 item 3: probe isAvailable() before create(int).
+ if !effect_is_available(env, &class, label) {
+ info!(
+ target: "chanora_audio",
+ effect = label,
+ "android: hardware effect not available on this device — software fallback engages"
+ );
+ return None;
+ }
+ let inst = match env.call_static_method(
+ &class,
+ "create",
+ &format!("(I)L{fqcn};"),
+ &[JValue::Int(session_id)],
+ ) {
+ Ok(v) => match v.l() {
+ Ok(o) => o,
+ Err(e) => {
+ warn!(target: "chanora_audio", error = %e, effect = label, "android: create() return cast failed");
+ return None;
+ }
+ },
+ Err(e) => {
+ // Likely an exception in JNI — clear so the next JNI
+ // call doesn't immediately abort.
+ let _ = env.exception_clear();
+ warn!(target: "chanora_audio", error = %e, effect = label, "android: create() threw — software fallback engages");
+ return None;
+ }
+ };
+ if inst.is_null() {
+ warn!(target: "chanora_audio", effect = label, "android: create() returned null (unsupported on device) — software fallback engages");
+ return None;
+ }
+ // setEnabled(true) -> int (success code)
+ if let Err(e) = env.call_method(
+ &inst,
+ "setEnabled",
+ "(Z)I",
+ &[JValue::Bool(jni::sys::JNI_TRUE)],
+ ) {
+ let _ = env.exception_clear();
+ warn!(target: "chanora_audio", error = %e, effect = label, "android: setEnabled(true) failed — software fallback engages");
+ return None;
+ }
+ match env.new_global_ref(&inst) {
+ Ok(g) => {
+ info!(target: "chanora_audio", effect = label, session_id, "android: hardware effect bound (SDD-113)");
+ Some(g)
+ }
+ Err(e) => {
+ warn!(target: "chanora_audio", error = %e, effect = label, "android: new_global_ref failed");
+ None
+ }
+ }
+}
+
+fn release_hardware_effects(handles: &mut HardwareEffectHandles) {
+ let result = catch_unwind(AssertUnwindSafe(|| release_hardware_effects_inner(handles)));
+ if result.is_err() {
+ warn!(
+ target: "chanora_audio",
+ "android: release_hardware_effects panicked; caught at FFI boundary"
+ );
+ }
+}
+
+fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) {
+ let aec = handles.aec.take();
+ let ns = handles.ns.take();
+ let agc = handles.agc.take();
+ if aec.is_none() && ns.is_none() && agc.is_none() {
+ return;
+ }
+ let ctx = ndk_context::android_context();
+ if ctx.vm().is_null() {
+ return;
+ }
+ // SAFETY: vm is non-null and owned for process lifetime via JNI_OnLoad.
+ let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
+ Ok(v) => v,
+ Err(_) => return,
+ };
+ let mut env = match jvm.attach_current_thread() {
+ Ok(e) => e,
+ Err(_) => return,
+ };
+ for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
+ if let Some(g) = effect {
+ let _ = env.call_method(
+ g.as_obj(),
+ "setEnabled",
+ "(Z)I",
+ &[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
+ );
+ let _ = env.exception_clear();
+ let _ = env.call_method(g.as_obj(), "release", "()V", &[]);
+ let _ = env.exception_clear();
+ drop(g);
+ info!(target: "chanora_audio", effect = label, "android: hardware effect released");
+ }
+ }
+}
+
+// --- SDD-115 foreground-service JNI helpers ----------------------
+//
+// The Kotlin class `AndroidVoiceForegroundService` (Wave 2B-2)
+// exposes `@JvmStatic fun start(Context)` / `fun stop(Context)`.
+// These Rust helpers reach across JNI to invoke those entry points.
+// IMPORTANT: never call these from an audio callback thread; route
+// invocation through a regular tokio task.
+
+const ANDROID_VOICE_FG_SERVICE_FQCN: &str =
+ "app/chanora/chanora_flutter/AndroidVoiceForegroundService";
+
+/// SDD-115 forward step 2: start the voice foreground service.
+/// Returns true on a clean JNI invocation (no exception). Callers
+/// should treat this as best-effort; `onStartCommand` runs
+/// asynchronously on the Android side.
+pub fn chanora_android_start_voice_service() -> bool {
+ call_voice_service_static("start")
+}
+
+/// SDD-115 reverse step 4: stop the voice foreground service.
+pub fn chanora_android_stop_voice_service() -> bool {
+ call_voice_service_static("stop")
+}
+
+fn call_voice_service_static(method: &str) -> bool {
+ use jni::objects::{JObject, JValue};
+ let ctx = ndk_context::android_context();
+ if ctx.vm().is_null() || ctx.context().is_null() {
+ warn!(
+ target: "chanora_audio",
+ method,
+ "android: ndk_context not initialised; voice service call skipped"
+ );
+ return false;
+ }
+ // SAFETY: vm/context populated by chanora_bridge::android_init at
+ // JNI_OnLoad + initChanoraContext; both pointers are valid for
+ // the process lifetime.
+ let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
+ Ok(v) => v,
+ Err(e) => {
+ warn!(target: "chanora_audio", error = %e, method, "android: JavaVM::from_raw failed");
+ return false;
+ }
+ };
+ let mut env = match jvm.attach_current_thread() {
+ Ok(e) => e,
+ Err(e) => {
+ warn!(target: "chanora_audio", error = %e, method, "android: attach_current_thread failed");
+ return false;
+ }
+ };
+ let class = match env.find_class(ANDROID_VOICE_FG_SERVICE_FQCN) {
+ Ok(c) => c,
+ Err(e) => {
+ let _ = env.exception_clear();
+ warn!(target: "chanora_audio", error = %e, method, "android: find_class failed");
+ return false;
+ }
+ };
+ // SAFETY: ndk_context::context() is the application Context
+ // jobject; valid global ref for process lifetime.
+ let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
+ match env.call_static_method(
+ &class,
+ method,
+ "(Landroid/content/Context;)V",
+ &[JValue::Object(&context_obj)],
+ ) {
+ Ok(_) => {
+ info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
+ true
+ }
+ Err(e) => {
+ let _ = env.exception_clear();
+ warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
+ false
+ }
+ }
+}
diff --git a/crates/chanora_audio/src/lib.rs b/crates/chanora_audio/src/lib.rs
index 10aab84..46de346 100644
--- a/crates/chanora_audio/src/lib.rs
+++ b/crates/chanora_audio/src/lib.rs
@@ -30,6 +30,7 @@
mod engine;
pub mod mode_stack;
+pub mod mobile_voice_backend;
pub mod ptt;
pub mod ptt_backends;
pub mod release_tail;
@@ -42,6 +43,9 @@ mod sdl_output;
#[cfg(target_os = "ios")]
mod ios_voice_unit;
+#[cfg(target_os = "android")]
+pub mod android_voice_unit;
+
pub use engine::{AudioEngine, AudioEngineConfig};
pub use ptt::{AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel};
pub use ptt_backends::{
diff --git a/crates/chanora_audio/src/mobile_voice_backend.rs b/crates/chanora_audio/src/mobile_voice_backend.rs
new file mode 100644
index 0000000..ca3983d
--- /dev/null
+++ b/crates/chanora_audio/src/mobile_voice_backend.rs
@@ -0,0 +1,755 @@
+//! Cross-platform mobile voice audio backend trait (SDD-111).
+//!
+//! `MobileVoiceAudioBackend` abstracts the lifetime of a paired
+//! low-latency voice input/output stream pair on mobile platforms.
+//! It is implemented on Android by
+//! [`crate::android_voice_unit::AndroidVoiceUnit`] (SDD-111). On
+//! iOS the existing [`crate::ios_voice_unit::IosVoiceUnit`] will be
+//! back-filled to this trait under SDD-117 — until that lands the
+//! iOS implementation does **not** implement this trait, and iOS
+//! audio continues to flow through its dedicated path.
+//!
+//! Desktop targets (Windows / macOS / Linux) do not use this trait;
+//! `cpal` (and SDL on Linux) own desktop capture/playback per the
+//! existing audio engine design.
+
+// TODO(SDD-117): back-fill `IosVoiceUnit` to implement this trait
+// so the engine can hold a single `Box`
+// across iOS and Android.
+
+#![allow(dead_code)]
+
+use std::fmt;
+
+use tokio::sync::mpsc;
+
+use crate::AudioEffects;
+
+/// Events the audio-callback thread or platform JNI listener can post
+/// to the tokio side of the engine (SDD-115). Audio callbacks MUST
+/// NOT mutate engine state directly; they marshal through this enum.
+#[derive(Debug, Clone)]
+pub enum BackendEvent {
+ /// Platform reported a stream-disconnect (route change, device
+ /// removal). Engine should schedule a reopen.
+ Disconnected,
+ /// `AudioManager.OnAudioFocusChangeListener` reported transient
+ /// loss with ducking allowed; engine may continue.
+ FocusTransientCanDuck,
+ /// Transient focus loss (e.g., incoming alarm). Engine should
+ /// pause capture; resume on `FocusGain`.
+ FocusTransient,
+ /// Focus regained.
+ FocusGain,
+ /// Permanent focus loss. Engine should leave the session.
+ FocusLost,
+ /// Bluetooth SCO state changed. P0 stance is "no crash"; the
+ /// engine logs and continues (SDD-115).
+ BluetoothScoStateChanged(i32),
+}
+
+/// Receiver end the engine retains. Constructed by the backend; see
+/// [`MobileVoiceAudioBackend::take_event_rx`].
+pub type BackendEventRx = mpsc::UnboundedReceiver;
+
+/// Sender end shared with audio callbacks. Unbounded is deliberate:
+/// dropping a route-change event because the engine is briefly behind
+/// is preferable to blocking the audio thread.
+pub type BackendEventTx = mpsc::UnboundedSender;
+
+/// Audio session id reported by the platform after the input stream
+/// has been opened. Required by SDD-113 to bind hardware effects
+/// (`AcousticEchoCanceler`, `NoiseSuppressor`, `AutomaticGainControl`)
+/// against the active capture session.
+pub type AudioSessionId = i32;
+
+/// Achieved performance-mode reported by the platform after stream
+/// open (SDD-112 item 4).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AchievedPerformanceMode {
+ /// Low-latency tier granted (SRS-210 target).
+ LowLatency,
+ /// Power-saving tier (latency tier not granted).
+ PowerSaving,
+ /// No specific tier — platform default.
+ None,
+}
+
+impl fmt::Display for AchievedPerformanceMode {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::LowLatency => f.write_str("LowLatency"),
+ Self::PowerSaving => f.write_str("PowerSaving"),
+ Self::None => f.write_str("None"),
+ }
+ }
+}
+
+/// Achieved sharing-mode reported by the platform after stream open
+/// (SDD-112 item 7 / SRS-214).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AchievedSharingMode {
+ /// `Exclusive` granted — lowest latency path.
+ Exclusive,
+ /// Fallback `Shared` was used.
+ Shared,
+}
+
+impl fmt::Display for AchievedSharingMode {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Exclusive => f.write_str("Exclusive"),
+ Self::Shared => f.write_str("Shared"),
+ }
+ }
+}
+
+/// Error surface for the mobile voice backend (SDD-111). Kept
+/// stringly-typed at the boundary to avoid leaking platform error
+/// types out of the crate.
+#[derive(Debug, Clone)]
+pub enum BackendError {
+ /// Stream open failed (config rejected, device busy, permissions).
+ OpenFailed(String),
+ /// Stream start/stop/close failed.
+ LifecycleFailed(String),
+ /// Audio device disconnected — engine should reopen via the
+ /// state machine. Audio callback threads MUST NOT mutate engine
+ /// state directly; this variant is delivered through the
+ /// error-callback channel and consumed by a regular tokio task.
+ ErrorDisconnected,
+ /// JNI / platform service error.
+ Platform(String),
+ /// Configuration rejected at construction (SDD-112 item 9 — the
+ /// engine asked for a config that the backend can prove is
+ /// impossible without dispatching a platform open).
+ InvalidConfig {
+ /// Human-readable reason; not user-facing.
+ reason: String,
+ },
+}
+
+impl fmt::Display for BackendError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::OpenFailed(m) => write!(f, "open failed: {m}"),
+ Self::LifecycleFailed(m) => write!(f, "lifecycle failed: {m}"),
+ Self::ErrorDisconnected => f.write_str("audio device disconnected"),
+ Self::Platform(m) => write!(f, "platform: {m}"),
+ Self::InvalidConfig { reason } => write!(f, "invalid config: {reason}"),
+ }
+ }
+}
+
+impl std::error::Error for BackendError {}
+
+/// Voice-stream configuration passed into the backend at `open()`
+/// (SDD-112). Immutable after construction — the engine treats this
+/// as a request; the platform may grant something weaker and the
+/// backend reports the achieved values via `achieved_*` accessors.
+#[derive(Debug, Clone)]
+pub struct AndroidVoiceStreamConfig {
+ /// Requested sample rate. Target is 48 kHz (SRS-210); the
+ /// engine resamples if the device grants its native rate.
+ pub sample_rate: u32,
+ /// Requested channel count. Mono in / mono out (SRS-210).
+ pub channel_count: u32,
+ /// Request `LowLatency` performance mode (SRS-210).
+ pub request_low_latency: bool,
+ /// Request `Exclusive` sharing mode with `Shared` fallback
+ /// (SRS-214 / SDD-112 item 7).
+ pub request_exclusive: bool,
+ /// Voice-effect toggles the engine plans to engage. The Android
+ /// backend tries hardware AEC/NS/AGC (SDD-113); failures fall
+ /// back to the engine's existing software path.
+ pub effects: AudioEffects,
+}
+
+impl Default for AndroidVoiceStreamConfig {
+ fn default() -> Self {
+ Self {
+ sample_rate: 48_000,
+ channel_count: 1,
+ request_low_latency: true,
+ request_exclusive: true,
+ effects: AudioEffects::default(),
+ }
+ }
+}
+
+impl AndroidVoiceStreamConfig {
+ /// Validating constructor (SDD-112 item 9). Rejects sample rates
+ /// outside `(0, 192_000]` and channel counts outside `[1, 2]`
+ /// before the platform-open round-trip can fail.
+ pub fn new(
+ sample_rate: u32,
+ channel_count: u32,
+ request_low_latency: bool,
+ request_exclusive: bool,
+ effects: AudioEffects,
+ ) -> Result {
+ if sample_rate == 0 || sample_rate > 192_000 {
+ return Err(BackendError::InvalidConfig {
+ reason: format!("sample_rate {sample_rate} out of range (0, 192000]"),
+ });
+ }
+ if !(1..=2).contains(&channel_count) {
+ return Err(BackendError::InvalidConfig {
+ reason: format!("channel_count {channel_count} out of range [1, 2]"),
+ });
+ }
+ Ok(Self {
+ sample_rate,
+ channel_count,
+ request_low_latency,
+ request_exclusive,
+ effects,
+ })
+ }
+}
+
+/// SDD-111 trait. The engine drives a backend through this surface.
+///
+/// Object-safety: kept object-safe so the engine can hold a
+/// `Box` once SDD-117 back-fills iOS.
+/// All methods take `&mut self` because lifecycle transitions
+/// require exclusive access; the audio callback thread does **not**
+/// reach engine state through this trait — it marshals events
+/// through the error channel exposed by [`Self::take_error_rx`].
+pub trait MobileVoiceAudioBackend: Send {
+ /// Start audio flowing. Must be called after `open()` (which
+ /// the constructor performs).
+ fn start(&mut self) -> Result<(), BackendError>;
+
+ /// Stop audio flow. Streams remain open and `start` may be
+ /// called again.
+ fn stop(&mut self) -> Result<(), BackendError>;
+
+ /// Close streams and release platform resources. After this
+ /// the backend is unusable.
+ ///
+ /// NOTE: SDD-111 item 1 specifies a consuming `self` signature.
+ /// We retain `&mut self` to preserve trait object-safety
+ /// (`Box` is the target shape per
+ /// SDD-117); the engine treats post-`close()` instances as
+ /// drop-on-next-statement.
+ fn close(&mut self) -> Result<(), BackendError>;
+
+ /// Take the backend event receiver (SDD-111 item 1 / SDD-115).
+ /// Returns `None` after the first call — the engine is the sole
+ /// owner of the receiver. Audio callbacks retain only the sender.
+ fn take_event_rx(&mut self) -> Option;
+
+ /// Sample rate the platform actually granted (Hz). SDD-111 item 1.
+ fn achieved_sample_rate(&self) -> u32;
+
+ /// Achieved input preset (SDD-112 item 6, SDD-111 item 1).
+ fn achieved_input_preset(&self) -> AchievedInputPreset;
+
+ /// Frames-per-burst the platform reported (SDD-112 item 4).
+ fn achieved_frames_per_burst(&self) -> u32;
+
+ /// Platform audio session id for the input stream, if known.
+ /// Required for SDD-113 hardware-effect binding.
+ fn session_id(&self) -> Option;
+
+ /// Achieved performance-mode of the input stream (SDD-112).
+ fn achieved_input_performance_mode(&self) -> AchievedPerformanceMode;
+
+ /// Achieved sharing-mode of the input stream (SDD-112).
+ fn achieved_input_sharing_mode(&self) -> AchievedSharingMode;
+
+ /// Achieved performance-mode of the output stream (SDD-112).
+ fn achieved_output_performance_mode(&self) -> AchievedPerformanceMode;
+
+ /// Achieved sharing-mode of the output stream (SDD-112).
+ fn achieved_output_sharing_mode(&self) -> AchievedSharingMode;
+}
+
+// --- Pure helpers (test-enabling, SWE4-UV-048/049/051) -----------
+//
+// These helpers describe the unit-level decisions encoded in the
+// Android backend's fallback ladders and the SRS-210 latency tier
+// classifier. They are intentionally pure (no platform handles,
+// no IO) so they can be unit-tested on the host target without
+// AAudio / JNI. They mirror the production ladder in
+// `android_voice_unit::AndroidVoiceUnit::open_input_fallback` and
+// the SDD-116 matrix basis described in SDD-111/112.
+//
+// Production code (the Android backend) currently inlines the
+// ladder for clarity; these helpers are kept here as the canonical
+// reference and as the verification surface for SWE.4. If the
+// ladder ever changes, both this helper and the inline code MUST
+// move together — see SWE4-UV-048/049.
+
+/// Input-preset choice for the Android backend (SDD-112 item 6).
+/// Mirrors `oboe::InputPreset` without depending on it so the
+/// helper is host-testable.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum InputPresetChoice {
+ /// Voice-communication preset (preferred, primary).
+ VoiceCommunication,
+ /// Voice-performance preset (first fallback rung).
+ VoicePerformance,
+ /// Generic / unprocessed (last rung).
+ Generic,
+}
+
+/// Returns the next input-preset to try given the presets already
+/// attempted, or `None` when the ladder is exhausted (SDD-112
+/// item 6, SWE4-UV-048).
+///
+/// Ladder: VoiceCommunication -> VoicePerformance -> Generic.
+pub fn next_input_preset_after(attempted: &[InputPresetChoice]) -> Option {
+ const LADDER: [InputPresetChoice; 3] = [
+ InputPresetChoice::VoiceCommunication,
+ InputPresetChoice::VoicePerformance,
+ InputPresetChoice::Generic,
+ ];
+ LADDER.iter().copied().find(|p| !attempted.contains(p))
+}
+
+/// Sharing-mode choice for the Android backend (SDD-112 item 7).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SharingModeChoice {
+ /// `Exclusive` — lowest-latency path; first try.
+ Exclusive,
+ /// `Shared` — fallback when `Exclusive` is denied.
+ Shared,
+}
+
+/// 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 {
+ const LADDER: [SharingModeChoice; 2] = [SharingModeChoice::Exclusive, SharingModeChoice::Shared];
+ LADDER.iter().copied().find(|m| !attempted.contains(m))
+}
+
+/// Latency tier per SRS-210 (SWE4-UV-051). Pure mapping from the
+/// platform-granted `AchievedPerformanceMode` to the SRS-210
+/// latency-tier decision the engine uses for diagnostics and
+/// adaptive jitter-buffer sizing.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LatencyTier {
+ /// 150 ms target tier (low-latency granted).
+ LowLatency,
+ /// 250 ms fallback tier (no low-latency).
+ Fallback,
+}
+
+/// SDD-116 / SRS-210 classifier: maps the achieved performance mode
+/// to a latency tier. Pure function — no platform dependencies.
+pub fn latency_tier_for(achieved: AchievedPerformanceMode) -> LatencyTier {
+ match achieved {
+ AchievedPerformanceMode::LowLatency => LatencyTier::LowLatency,
+ AchievedPerformanceMode::PowerSaving | AchievedPerformanceMode::None => {
+ LatencyTier::Fallback
+ }
+ }
+}
+
+// --- SDD-112 / SDD-113 / SDD-116 diagnostics snapshot -------------
+//
+// `AndroidAudioDiagnostics` is the cross-platform-shaped snapshot
+// exported by the diagnostics bundle (`chanora_diagnostics`) and the
+// FRB bridge so that android-p0-acceptance TC-14 / TC-15 / TC-16 can
+// collect per-device evidence for the SDD-116 verification matrix.
+// All fields are device-side technical scalars; per SDD-090 no PII,
+// permission state, or server identity is admitted into this struct.
+
+/// Per-effect engine that satisfied a hardware-effect request
+/// (SDD-113 item 5). `Hardware` means the JNI `create()` returned
+/// a live `GlobalRef` and `setEnabled(true)` succeeded; `Software`
+/// means the engine's cross-platform AEC/NS/AGC path is carrying
+/// the effect; `None` means the effect was not requested at all.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum EffectEngine {
+ /// Platform hardware effect engaged on the active session id.
+ Hardware,
+ /// Software-engine fallback path engaged for the effect.
+ Software,
+ /// Neither path engaged (effect not requested).
+ None,
+}
+
+impl fmt::Display for EffectEngine {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Hardware => f.write_str("hardware"),
+ Self::Software => f.write_str("software"),
+ Self::None => f.write_str("none"),
+ }
+ }
+}
+
+/// Per-effect engagement record (SDD-113 item 7). `engaged` is the
+/// observable "this effect is doing something" bit; `engine` records
+/// which path satisfied the request so SRS-212 verification can
+/// distinguish hardware vs software fulfilment from a single export.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct EffectEngagement {
+ /// Whether the effect is currently active (hardware or software).
+ pub engaged: bool,
+ /// Which engine satisfied the request.
+ pub engine: EffectEngine,
+}
+
+impl Default for EffectEngagement {
+ fn default() -> Self {
+ Self {
+ engaged: false,
+ engine: EffectEngine::None,
+ }
+ }
+}
+
+/// Achieved input-preset reported after stream-open (SDD-112 item 6).
+/// `Unknown` is used when the platform / wrapper does not expose a
+/// readback of the achieved preset; SDD-116 item 3 requires the
+/// attempt sequence to be observable, and the achieved value is
+/// preserved here whenever the wrapper can surface it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AchievedInputPreset {
+ /// `VoiceCommunication` preset granted (primary).
+ VoiceCommunication,
+ /// `VoicePerformance` preset granted (first fallback rung).
+ VoicePerformance,
+ /// `Generic` preset granted (last rung).
+ Generic,
+ /// Platform did not expose a preset readback.
+ Unknown,
+}
+
+impl fmt::Display for AchievedInputPreset {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::VoiceCommunication => f.write_str("VoiceCommunication"),
+ Self::VoicePerformance => f.write_str("VoicePerformance"),
+ Self::Generic => f.write_str("Generic"),
+ Self::Unknown => f.write_str("Unknown"),
+ }
+ }
+}
+
+/// Diagnostics snapshot of the Android voice-audio session
+/// (SDD-112 item 4 / SDD-113 item 7 / SDD-116 item 3).
+///
+/// Populated once at `AndroidVoiceUnit::open()` and cleared on
+/// `close()`. The struct is cross-platform-compilable (the surrounding
+/// `chanora_diagnostics::AndroidAudioReport` re-exports it) so the
+/// diagnostics bundle has a stable schema even on non-Android hosts —
+/// on non-Android the diagnostics report carries `None` for this
+/// section.
+///
+/// Per SDD-090 every field here is a device-side technical scalar.
+/// No server hostname, account id, permission state, or PII admitted.
+///
+/// Verification: SWE4-UV-052 follow-through. With these fields now
+/// exposed, an instrumented test on a real device can assert
+/// `achieved.usage == VoiceCommunication` and
+/// `achieved.content_type == Speech` against the recorded export;
+/// today the values are pinned by SDD-112 item 5 on the requested
+/// side and the achieved side is `Unknown` until an AAudio readback
+/// path is wired.
+#[derive(Debug, Clone)]
+pub struct AndroidAudioDiagnostics {
+ // Requested side (SDD-112 items 4..7) — fixed by SDD-112.
+ /// Requested performance mode (always "LowLatency" in P0).
+ pub requested_performance_mode: &'static str,
+ /// Requested output usage (always "VoiceCommunication" in P0).
+ pub requested_usage: &'static str,
+ /// Requested output content type (always "Speech" in P0).
+ pub requested_content_type: &'static str,
+ /// Requested input preset (always "VoiceCommunication" first).
+ pub requested_input_preset: &'static str,
+ /// Requested sharing mode (always "Exclusive" first).
+ pub requested_sharing_mode: &'static str,
+ /// Requested sample rate (Hz).
+ pub requested_sample_rate_hz: u32,
+
+ // Achieved side (SDD-112 item 4 / 7 + SDD-116 item 3).
+ /// Achieved performance mode after stream open.
+ pub achieved_performance_mode: AchievedPerformanceMode,
+ /// Achieved sharing mode after stream open.
+ pub achieved_sharing_mode: AchievedSharingMode,
+ /// Achieved input preset (`Unknown` if the wrapper does not
+ /// expose a readback today).
+ pub achieved_input_preset: AchievedInputPreset,
+ /// Sample rate AAudio actually delivered (Hz).
+ pub achieved_sample_rate_hz: u32,
+ /// Frames-per-burst the platform reported.
+ pub achieved_frames_per_burst: u32,
+
+ // Per-effect engagement (SDD-113 item 7 / SRS-212).
+ /// Acoustic echo cancellation engagement.
+ pub aec: EffectEngagement,
+ /// Noise suppression engagement.
+ pub ns: EffectEngagement,
+ /// Automatic gain control engagement.
+ pub agc: EffectEngagement,
+
+ /// Latency tier derived from `achieved_performance_mode` per
+ /// SDD-116 / SRS-210 via [`latency_tier_for`].
+ pub latency_tier: LatencyTier,
+}
+
+impl AndroidAudioDiagnostics {
+ /// Render the snapshot as the SDD-116 verification-matrix YAML
+ /// fragment that the diagnostics bundle embeds. Lines are
+ /// indented two spaces to nest cleanly under an `[android audio]`
+ /// section header.
+ ///
+ /// Per SDD-090 / SDD-077: no PII, no permission state, no
+ /// server identity is admitted by this serialiser. Only the
+ /// device-side technical scalars on this struct are emitted.
+ pub fn to_yaml_fragment(&self) -> String {
+ let latency_tier = match self.latency_tier {
+ LatencyTier::LowLatency => "LowLatency",
+ LatencyTier::Fallback => "Fallback",
+ };
+ format!(
+ " requested:\n\
+ \x20\x20\x20\x20performance_mode: \"{rpm}\"\n\
+ \x20\x20\x20\x20usage: \"{ru}\"\n\
+ \x20\x20\x20\x20content_type: \"{rct}\"\n\
+ \x20\x20\x20\x20input_preset: \"{rip}\"\n\
+ \x20\x20\x20\x20sharing_mode: \"{rsm}\"\n\
+ \x20\x20\x20\x20sample_rate_hz: {rsr}\n\
+ \x20\x20achieved:\n\
+ \x20\x20\x20\x20performance_mode: \"{apm}\"\n\
+ \x20\x20\x20\x20sharing_mode: \"{ashm}\"\n\
+ \x20\x20\x20\x20input_preset: \"{aip}\"\n\
+ \x20\x20\x20\x20sample_rate_hz: {asr}\n\
+ \x20\x20\x20\x20frames_per_burst: {afpb}\n\
+ \x20\x20effects:\n\
+ \x20\x20\x20\x20aec:\n\
+ \x20\x20\x20\x20\x20\x20engaged: {aece}\n\
+ \x20\x20\x20\x20\x20\x20engine: \"{aecg}\"\n\
+ \x20\x20\x20\x20ns:\n\
+ \x20\x20\x20\x20\x20\x20engaged: {nse}\n\
+ \x20\x20\x20\x20\x20\x20engine: \"{nsg}\"\n\
+ \x20\x20\x20\x20agc:\n\
+ \x20\x20\x20\x20\x20\x20engaged: {agce}\n\
+ \x20\x20\x20\x20\x20\x20engine: \"{agcg}\"\n\
+ \x20\x20latency_tier: \"{lt}\"\n",
+ rpm = self.requested_performance_mode,
+ ru = self.requested_usage,
+ rct = self.requested_content_type,
+ rip = self.requested_input_preset,
+ rsm = self.requested_sharing_mode,
+ rsr = self.requested_sample_rate_hz,
+ apm = self.achieved_performance_mode,
+ ashm = self.achieved_sharing_mode,
+ aip = self.achieved_input_preset,
+ asr = self.achieved_sample_rate_hz,
+ afpb = self.achieved_frames_per_burst,
+ aece = self.aec.engaged,
+ aecg = self.aec.engine,
+ nse = self.ns.engaged,
+ nsg = self.ns.engine,
+ agce = self.agc.engaged,
+ agcg = self.agc.engine,
+ lt = latency_tier,
+ )
+ }
+}
+
+// --- Process-global diagnostics slot (SDD-116) -------------------
+//
+// `AndroidVoiceUnit::open()` publishes its diagnostics here on a
+// successful open, and `close()`/`Drop` clears the slot. The bridge
+// reads it synchronously from `export_diagnostics()` so the user-
+// initiated export bundle (DEC-016) carries the SDD-116 evidence
+// without an async hop.
+//
+// Cross-platform-safe: the slot exists on every target; it stays
+// `None` outside Android, and the diagnostics report renders an
+// absent `android_audio` section accordingly.
+
+static ANDROID_AUDIO_DIAGNOSTICS: std::sync::OnceLock<
+ std::sync::Mutex