From 76c6d1d40c5da3f7db5e1b0218ec337e87801678 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Mon, 18 May 2026 09:50:53 +0800 Subject: [PATCH] feat(audio,android): add MobileVoiceAudioBackend + AndroidVoiceUnit (oboe-rs) Add the cross-platform MobileVoiceAudioBackend trait, plus the Android implementation AndroidVoiceUnit backed by oboe-rs 0.6.x. AndroidVoiceUnit owns AAudio stream setup with VoiceCommunication usage/preset, performance-mode LowLatency request, sharing-mode Exclusive best-effort, hardware AEC/NS/AGC engagement via JNI, and the diagnostics snapshot publish path used by SDD-116 evidence collection. Cargo.toml: adds oboe = "0.6" under the Android target. Trace: SDD-111, SDD-112, SDD-113, SRS-210, SRS-211, SRS-212, SRS-213, SRS-214. --- Cargo.lock | 21 + crates/chanora_audio/Cargo.toml | 6 + .../chanora_audio/src/android_voice_unit.rs | 923 ++++++++++++++++++ crates/chanora_audio/src/lib.rs | 4 + .../chanora_audio/src/mobile_voice_backend.rs | 755 ++++++++++++++ 5 files changed, 1709 insertions(+) create mode 100644 crates/chanora_audio/src/android_voice_unit.rs create mode 100644 crates/chanora_audio/src/mobile_voice_backend.rs 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>, +> = std::sync::OnceLock::new(); + +fn android_audio_diagnostics_slot() -> &'static std::sync::Mutex> { + ANDROID_AUDIO_DIAGNOSTICS.get_or_init(|| std::sync::Mutex::new(None)) +} + +/// Publish a fresh diagnostics snapshot. Called by +/// `AndroidVoiceUnit::open()` after both streams open and hardware- +/// effect attach completes (SDD-112 item 10 / SDD-113 item 7). +pub fn publish_android_audio_diagnostics(d: AndroidAudioDiagnostics) { + if let Ok(mut g) = android_audio_diagnostics_slot().lock() { + *g = Some(d); + } +} + +/// Clear the diagnostics slot. Called by `AndroidVoiceUnit::close()` +/// and `Drop` so a stale snapshot does not survive past the voice +/// session. +pub fn clear_android_audio_diagnostics() { + if let Ok(mut g) = android_audio_diagnostics_slot().lock() { + *g = None; + } +} + +/// Read the current diagnostics snapshot. Returns `None` when no +/// voice session is active (and always returns `None` on non-Android +/// targets). Cross-platform-callable from the FRB bridge. +pub fn current_android_audio_diagnostics() -> Option { + android_audio_diagnostics_slot() + .lock() + .ok() + .and_then(|g| g.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// SWE4-UV-047: default config records requested low-latency, + /// exclusive sharing, mono 48 kHz, and default effects. + #[test] + fn swe4_uv_047_default_config_records_requested_values() { + let cfg = AndroidVoiceStreamConfig::default(); + assert_eq!(cfg.sample_rate, 48_000); + assert_eq!(cfg.channel_count, 1); + assert!(cfg.request_low_latency); + assert!(cfg.request_exclusive); + // AudioEffects defaults are all-on per DEC-007..010. + assert!(cfg.effects.aec); + assert!(cfg.effects.noise_suppression); + assert!(cfg.effects.agc); + } + + /// SWE4-UV-047: requested fields are independently settable so + /// a non-default request (e.g., power-saving, shared) is faithfully + /// recorded by the builder/struct. + #[test] + fn swe4_uv_047_config_allows_non_default_requests() { + let cfg = AndroidVoiceStreamConfig { + sample_rate: 16_000, + channel_count: 1, + request_low_latency: false, + request_exclusive: false, + ..AndroidVoiceStreamConfig::default() + }; + assert_eq!(cfg.sample_rate, 16_000); + assert!(!cfg.request_low_latency); + assert!(!cfg.request_exclusive); + } + + /// SWE4-UV-048: ladder starts at VoiceCommunication, falls to + /// VoicePerformance, then Generic, then exhausts. + #[test] + fn swe4_uv_048_input_preset_fallback_ladder_order() { + assert_eq!( + next_input_preset_after(&[]), + Some(InputPresetChoice::VoiceCommunication) + ); + assert_eq!( + next_input_preset_after(&[InputPresetChoice::VoiceCommunication]), + Some(InputPresetChoice::VoicePerformance) + ); + assert_eq!( + next_input_preset_after(&[ + InputPresetChoice::VoiceCommunication, + InputPresetChoice::VoicePerformance + ]), + Some(InputPresetChoice::Generic) + ); + assert_eq!( + next_input_preset_after(&[ + InputPresetChoice::VoiceCommunication, + InputPresetChoice::VoicePerformance, + InputPresetChoice::Generic + ]), + None, + "ladder exhausts after all three presets" + ); + } + + /// SWE4-UV-049: sharing-mode ladder is Exclusive -> Shared -> exhausted. + #[test] + fn swe4_uv_049_sharing_mode_fallback_ladder_order() { + assert_eq!( + next_sharing_mode_after(&[]), + Some(SharingModeChoice::Exclusive) + ); + assert_eq!( + next_sharing_mode_after(&[SharingModeChoice::Exclusive]), + Some(SharingModeChoice::Shared) + ); + assert_eq!( + next_sharing_mode_after(&[SharingModeChoice::Exclusive, SharingModeChoice::Shared]), + None + ); + } + + /// SWE4-UV-051: LowLatency achieved maps to the 150 ms tier. + #[test] + fn swe4_uv_051_lowlatency_maps_to_lowlatency_tier() { + assert_eq!( + latency_tier_for(AchievedPerformanceMode::LowLatency), + LatencyTier::LowLatency + ); + } + + /// SWE4-UV-051: PowerSaving / None achieved map to the fallback tier. + #[test] + fn swe4_uv_051_non_lowlatency_maps_to_fallback_tier() { + assert_eq!( + latency_tier_for(AchievedPerformanceMode::PowerSaving), + LatencyTier::Fallback + ); + assert_eq!( + latency_tier_for(AchievedPerformanceMode::None), + LatencyTier::Fallback + ); + } + + /// SWE4-UV-052: output stream is conceptually pinned to + /// VoiceCommunication usage + Speech content; the struct does + /// not expose any "music"/"media" alternative — only + /// request_low_latency + request_exclusive are variable. This + /// test guards against accidental introduction of a usage + /// override field on the config struct. + #[test] + fn swe4_uv_052_config_does_not_expose_alt_usage_or_content_type() { + // Compile-time field check: AndroidVoiceStreamConfig has no + // `usage` / `content_type` fields. If a future change adds + // one, this destructuring breaks and the author must update + // SWE4-UV-052 deliberately. + let AndroidVoiceStreamConfig { + sample_rate: _, + channel_count: _, + request_low_latency: _, + request_exclusive: _, + effects: _, + } = AndroidVoiceStreamConfig::default(); + } + + /// SWE4-UV-047: BackendError variants render without panicking + /// and carry their context (verifies the error surface used by + /// open()/start()/stop()/close()). + #[test] + fn swe4_uv_047_backend_error_display() { + assert!(format!("{}", BackendError::OpenFailed("x".into())).contains("open failed")); + assert!( + format!("{}", BackendError::LifecycleFailed("y".into())).contains("lifecycle failed") + ); + assert!(format!("{}", BackendError::ErrorDisconnected).contains("disconnected")); + assert!(format!("{}", BackendError::Platform("z".into())).contains("platform")); + } + + /// SWE4-UV-047: achieved enums Display stably (used in + /// structured "stream_opened" diagnostics; SRS-210 traces grep + /// 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::None), "None"); + assert_eq!(format!("{}", AchievedSharingMode::Exclusive), "Exclusive"); + assert_eq!(format!("{}", AchievedSharingMode::Shared), "Shared"); + } +}