diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index 4489442..d732347 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -144,6 +144,24 @@ pub struct AudioEngine { _output_stream: Mutex>, #[cfg(target_os = "ios")] _ios_voice_unit: Mutex>, + /// SDD-111..SDD-115: Android voice backend held parallel to the + /// cpal streams. Owns the SDD-113 hardware-effect handles and + /// the foreground-service lifecycle; tearing it down on engine + /// drop unbinds effects and stops the service in SDD-115 + /// reverse order. The cpal capture/playback pair continues to + /// carry voice frames for the transitional period — replacing + /// the data path with the Oboe streams is a follow-up. + #[cfg(target_os = "android")] + _android_voice_unit: Mutex>, + /// SDD-108 §1/§2: refcount-composable audio-mode controller. + /// Snapshots `AudioManager.getMode()` on the 0 → 1 transition and + /// restores it on the 1 → 0 transition. Held in a `Mutex` so the + /// snapshot/restore critical section is serialized across + /// composed callers (SDD-108 §2). Engine-scoped because the + /// mode lifecycle is bound to the voice-session lifecycle + /// (SDD-108 §3). + #[cfg(target_os = "android")] + audio_mode_stack: Mutex, // Hand the inbound-voice forwarder task a shutdown signal. shutdown_tx: Option>, /// True if the capture stream actually opened. If false (typical @@ -295,33 +313,127 @@ impl AudioEngine { ), } - // A.5 mobile-only preset acknowledgement. On Linux desktop - // the flag is ignored; on Android we log it so a future cpal - // / Oboe wiring can be observed in the diagnostic export. + // SDD-115 lifecycle sequencing on engine start. Forward order: + // 1) bridge -> engine receives voice_join (here we are + // already inside `start`, the engine-side trigger). + // 2) start the Android voice foreground service so that + // the platform records the microphone capture under + // `foregroundServiceType="microphone"` (SDD-107 + SRS-215). + // 3) open the AAudio voice streams (SDD-111 + SDD-112). + // 4) engage `MODE_IN_COMMUNICATION` (SDD-108). + // 5) bind hardware effects (SDD-113) — performed inside + // `AndroidVoiceUnit::open()` once the input stream has a + // session id. + // The reverse order on engine drop is enforced by the field + // drop order (`_android_voice_unit` is dropped before the + // engine returns; `close()` is invoked from its Drop impl). #[cfg(target_os = "android")] - { + let mut audio_mode_stack = crate::mode_stack::ModeStack::new(); + #[cfg(target_os = "android")] + let _android_voice_unit = { if cfg.mobile_voice_preset { - match android_engage_voice_communication() { - Ok(()) => info!( + // Step 2: foreground service. + if crate::android_voice_unit::chanora_android_start_voice_service() { + info!( target: "chanora_audio", - "android: AudioManager mode set to MODE_IN_COMMUNICATION" - ), + "android: voice foreground service start dispatched (SDD-115)" + ); + } else { + warn!( + target: "chanora_audio", + "android: foreground service start failed; capture may be denied in background (SDD-115)" + ); + } + // Step 4 (mode engage) BEFORE Step 5 (effect bind); + // hardware-effect routing only engages reliably under + // MODE_IN_COMMUNICATION (SDD-113 item 6 / SDD-115). + // + // SDD-108 §1/§2: route through `ModeStack` so the + // 0 → 1 transition snapshots the prior platform mode + // (via `android_get_audio_mode`) and only that + // transition writes `MODE_IN_COMMUNICATION` via + // `android_set_audio_mode`. P0 only ever observes + // refcount {0, 1} per SRS-189 but the composition + // model is in place for P1. + match android_get_audio_mode() { + Ok(prior_now) => { + let outcome = audio_mode_stack.acquire(prior_now); + if let crate::mode_stack::ModeAcquire::FirstAcquire { prior } = outcome { + match android_set_audio_mode(ANDROID_MODE_IN_COMMUNICATION) { + Ok(()) => info!( + target: "chanora_audio", + prior_mode = prior, + "android: AudioManager mode set to MODE_IN_COMMUNICATION (SDD-108)" + ), + Err(e) => { + // SDD-108 §5: setMode failed AFTER + // the 0 → 1 ModeStack transition. + // Roll the stack back so refcount + // returns to 0 and the snapshot is + // cleared; otherwise a future + // release would issue an + // unmatched setMode(prior) against + // a system that never had its mode + // changed by us. + warn!( + target: "chanora_audio", + error = %e, + prior_mode = prior, + "android: setMode failed; rolling back ModeStack acquire (SDD-108 §5)" + ); + let _ = audio_mode_stack.release(); + } + } + } + } Err(e) => warn!( target: "chanora_audio", error = %e, - "android: failed to set MODE_IN_COMMUNICATION; falling back to default routing" + "android: AudioManager.getMode failed; skipping mode engage (SDD-108)" ), } + // Step 3 + 5: open streams (SDD-111/112) and bind + // hardware effects (SDD-113). Failure here is logged + // and the engine continues with software AEC/NS/AGC + // via the existing engine path; the cpal data path + // remains the in-flight carrier. + // + // The prior silent-no-op log line at this site + // ("engagement depends on device AEC/NS support + // under MODE_IN_COMMUNICATION") is removed: the + // AndroidVoiceUnit either succeeds in engaging + // hardware effects (SDD-113) or logs the per-effect + // fallback, so engagement is now observable rather + // than rationalised. + let cfg_av = crate::mobile_voice_backend::AndroidVoiceStreamConfig { + effects: cfg.effects, + ..Default::default() + }; + match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av) { + Ok(mut unit) => { + use crate::mobile_voice_backend::MobileVoiceAudioBackend; + if let Err(e) = unit.start() { + warn!( + target: "chanora_audio", + error = %e, + "android: AndroidVoiceUnit::start failed — cpal path remains active (SDD-115)" + ); + } + Some(unit) + } + Err(e) => { + warn!( + target: "chanora_audio", + error = %e, + "android: AndroidVoiceUnit::open failed — software AEC/NS/AGC fallback engages (SDD-111/SDD-113)" + ); + None + } + } + } else { + None } - if cfg.effects.aec || cfg.effects.noise_suppression { - info!( - target: "chanora_audio", - aec = cfg.effects.aec, - ns = cfg.effects.noise_suppression, - "android: effects requested; engagement depends on device AEC/NS support under MODE_IN_COMMUNICATION" - ); - } - } + }; #[cfg(target_os = "ios")] { if cfg.mobile_voice_preset { @@ -532,6 +644,10 @@ impl AudioEngine { output_muted, _input_stream: Mutex::new(input_stream), _output_stream: Mutex::new(Some(output_stream)), + #[cfg(target_os = "android")] + _android_voice_unit: Mutex::new(_android_voice_unit), + #[cfg(target_os = "android")] + audio_mode_stack: Mutex::new(audio_mode_stack), shutdown_tx: Some(shutdown_tx), capture_active, ptt_watchdog, @@ -676,6 +792,72 @@ impl AudioEngine { { let _ = self._ios_voice_unit.lock().unwrap().take(); } + // SDD-115 reverse-order teardown on Android: + // 1) close the voice unit (releases SDD-113 hardware + // effects then stops + closes the Oboe streams); + // 2) restore the prior audio mode (SDD-108 §1 on 1 → 0 + // transition); + // 3) stop the foreground service. + #[cfg(target_os = "android")] + { + if let Some(mut unit) = self._android_voice_unit.lock().unwrap().take() { + use crate::mobile_voice_backend::MobileVoiceAudioBackend; + if let Err(e) = unit.close() { + warn!( + target: "chanora_audio", + error = %e, + "android: AndroidVoiceUnit::close failed (SDD-115)" + ); + } + } + // SDD-108 §1/§2: release the audio-mode stack. Only the + // 1 → 0 transition writes the platform; mid-stack + // releases stay engaged. Underflow (release without a + // matching acquire) is clamped without panic per + // SDD-108 §1. + // SDD-108 §5: tolerate a poisoned mutex on the teardown + // path — if a panicking thread held the lock, we still + // need to drive the release to completion (otherwise the + // platform stays in MODE_IN_COMMUNICATION). + let release = self + .audio_mode_stack + .lock() + .unwrap_or_else(|e| e.into_inner()) + .release(); + match release { + crate::mode_stack::ModeRelease::LastRelease { prior } => { + match android_set_audio_mode(prior) { + Ok(()) => info!( + target: "chanora_audio", + restored_mode = prior, + "android: AudioManager mode restored (SDD-108)" + ), + Err(e) => warn!( + target: "chanora_audio", + error = %e, + restored_mode = prior, + "android: failed to restore prior AudioManager mode (SDD-108)" + ), + } + } + crate::mode_stack::ModeRelease::StillHeld => { + info!( + target: "chanora_audio", + "android: audio mode still held by composed session (SDD-108)" + ); + } + crate::mode_stack::ModeRelease::AlreadyReleased => { + // No engage ever happened (e.g. mobile_voice_preset + // was false, or getMode failed). Silent no-op. + } + } + if crate::android_voice_unit::chanora_android_stop_voice_service() { + info!( + target: "chanora_audio", + "android: voice foreground service stop dispatched (SDD-115)" + ); + } + } info!(target: "chanora_audio", "audio engine stopped"); } @@ -796,6 +978,18 @@ impl AudioEngine { self.frames_received.load(Ordering::Relaxed) } + /// Latest Android voice-audio diagnostics snapshot (SDD-112 item + /// 10 / SDD-113 item 7 / SDD-116 item 3). On non-Android targets + /// this always returns `None`. On Android it returns `Some(...)` + /// once `AndroidVoiceUnit::open()` has published a snapshot; the + /// slot is cleared on `close()` / `Drop`. Per SDD-090 the + /// snapshot contains only device-side technical scalars — no PII. + pub fn android_diagnostics( + &self, + ) -> Option { + crate::mobile_voice_backend::current_android_audio_diagnostics() + } + /// Set master output mute. When true the output stream emits /// silence regardless of incoming voice frames. pub fn set_output_muted(&self, muted: bool) { @@ -1387,52 +1581,173 @@ impl FromF32 for u16 { // ---------- Android voice-communication routing ---------- // -// Engages `AudioManager.MODE_IN_COMMUNICATION` on the Android-side -// AudioManager. This is the routing-level lever that tells the OS -// "this is a voice call, please use the earpiece / engage hardware -// AEC / NS / AGC where the device supports it". cpal opens its -// input stream at the AAudio default preset; on most Android -// devices this honours the global mode and chooses the right -// pipeline. Fully wiring `setInputPreset(VOICE_COMMUNICATION)` would -// need either a cpal fork or a parallel Oboe input — out of scope -// for External Beta. +// SDD-108 §1: `AudioManager.setMode(MODE_IN_COMMUNICATION)` engagement +// is the routing-level lever that tells Android "this is a voice +// call, please use the earpiece / engage hardware AEC / NS / AGC +// where the device supports it". The helpers here are the +// platform-write surface invoked by the engine's `ModeStack` +// (`crate::mode_stack`) — the stack owns refcount + snapshot +// semantics (SDD-108 §2), these helpers only perform the platform +// write / read. +// +// Placement rationale (SDD-108 §4): the JNI helpers for +// `AudioManager.getMode` / `AudioManager.setMode` live in +// `chanora_audio::engine` (not `chanora_bridge::android_init`) per +// SDD-108 §4, which assigns the AudioManager JNI surface to the Rust +// audio engine. This keeps the AudioManager interaction co-located +// with the engine state (`ModeStack`) that owns it, so the +// snapshot/restore lifecycle and the JNI calls evolve together. +// +// `AudioManager.MODE_IN_COMMUNICATION == 3` per the Android SDK. #[cfg(target_os = "android")] -fn android_engage_voice_communication() -> Result<(), String> { - use jni::objects::{JObject, JString, JValue}; - let ctx = ndk_context::android_context(); - let vm_ptr = ctx.vm(); - if vm_ptr.is_null() { - return Err("ndk_context vm is null".to_string()); - } - // SAFETY: ndk_context::android_context guarantees `vm` points at - // a live JavaVM* set by our bridge_init JNI hook. The unsafe - // block contains only the cast required by `JavaVM::from_raw`. - let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) } - .map_err(|e| format!("jvm from_raw: {e}"))?; - let mut env = jvm - .attach_current_thread() - .map_err(|e| format!("attach: {e}"))?; +pub const ANDROID_MODE_IN_COMMUNICATION: i32 = 3; - let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) }; - - let service_name: JString = env - .new_string("audio") - .map_err(|e| format!("new_string: {e}"))?; - let audio_manager = env - .call_method( - &context_obj, - "getSystemService", - "(Ljava/lang/String;)Ljava/lang/Object;", - &[JValue::Object(&service_name.into())], - ) - .map_err(|e| format!("getSystemService: {e}"))? - .l() - .map_err(|e| format!("getSystemService obj: {e}"))?; - if audio_manager.is_null() { - return Err("AudioManager service is null".to_string()); +/// SDD-108 §5: typed error for the Android `AudioManager` JNI surface. +/// +/// Replaces the previous ad-hoc `Result<_, String>` so callers can +/// pattern-match on the failure category (attach vs. method call vs. +/// other) and log/route accordingly. `Display` renders a stable +/// human-readable form that is safe to feed into the existing +/// `tracing::warn!(error = %e, ...)` sites. +#[cfg(target_os = "android")] +#[derive(Debug, Clone)] +pub enum AudioModeError { + /// `JavaVM::from_raw` or `attach_current_thread` failed: the + /// engine could not reach the JVM at all. + JniAttachFailed(String), + /// A specific JNI method call failed (e.g. `getMode`, `setMode`, + /// `getSystemService`). `method` is a static string for grep-ability. + MethodCallFailed { + method: &'static str, + detail: String, + }, + /// Catch-all for non-JNI-method failures (null context, panic in + /// the JNI body, etc.). + Other(String), +} + +#[cfg(target_os = "android")] +impl std::fmt::Display for AudioModeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::JniAttachFailed(d) => write!(f, "JNI attach failed: {d}"), + Self::MethodCallFailed { method, detail } => { + write!(f, "JNI method `{method}` failed: {detail}") + } + Self::Other(d) => write!(f, "{d}"), + } } - // AudioManager.MODE_IN_COMMUNICATION == 3. - env.call_method(&audio_manager, "setMode", "(I)V", &[JValue::Int(3)]) - .map_err(|e| format!("setMode: {e}"))?; - Ok(()) +} + +#[cfg(target_os = "android")] +impl std::error::Error for AudioModeError {} + +/// JNI helper shared by `android_get_audio_mode` and +/// `android_set_audio_mode`: attach to the current thread and return +/// the `AudioManager` jobject. Centralised so SDD-108's two platform +/// entry points share one bootstrapping path. +#[cfg(target_os = "android")] +fn android_audio_manager_call(op: F) -> Result +where + F: FnOnce(&mut jni::JNIEnv, &jni::objects::JObject) -> Result + + std::panic::UnwindSafe, +{ + use jni::objects::{JObject, JString, JValue}; + + let result = std::panic::catch_unwind(|| -> Result { + let ctx = ndk_context::android_context(); + let vm_ptr = ctx.vm(); + if vm_ptr.is_null() { + return Err(AudioModeError::JniAttachFailed( + "ndk_context vm is null".to_string(), + )); + } + // SAFETY: ndk_context::android_context guarantees `vm` points + // at a live JavaVM* set by our bridge_init JNI hook. The + // unsafe block contains only the cast required by + // `JavaVM::from_raw`. + let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) } + .map_err(|e| AudioModeError::JniAttachFailed(format!("jvm from_raw: {e}")))?; + let mut env = jvm + .attach_current_thread() + .map_err(|e| AudioModeError::JniAttachFailed(format!("attach: {e}")))?; + + let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) }; + + let service_name: JString = env.new_string("audio").map_err(|e| { + AudioModeError::MethodCallFailed { + method: "new_string", + detail: e.to_string(), + } + })?; + let audio_manager = env + .call_method( + &context_obj, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::Object(&service_name.into())], + ) + .map_err(|e| AudioModeError::MethodCallFailed { + method: "getSystemService", + detail: e.to_string(), + })? + .l() + .map_err(|e| AudioModeError::MethodCallFailed { + method: "getSystemService", + detail: format!("obj cast: {e}"), + })?; + if audio_manager.is_null() { + return Err(AudioModeError::Other( + "AudioManager service is null".to_string(), + )); + } + op(&mut env, &audio_manager) + }); + match result { + Ok(inner) => inner, + Err(_) => Err(AudioModeError::Other( + "panic in JNI audio_manager call".to_string(), + )), + } +} + +/// SDD-108 §1 platform-read: `AudioManager.getMode()`. +/// +/// Returns the integer mode constant currently active on the system. +/// Called by the engine on first acquire (0 → 1 transition) so that +/// `ModeStack` can snapshot the prior mode for restoration on last +/// release. +#[cfg(target_os = "android")] +pub fn android_get_audio_mode() -> Result { + android_audio_manager_call(|env, audio_manager| { + env.call_method(audio_manager, "getMode", "()I", &[]) + .map_err(|e| AudioModeError::MethodCallFailed { + method: "getMode", + detail: e.to_string(), + })? + .i() + .map_err(|e| AudioModeError::MethodCallFailed { + method: "getMode", + detail: format!("int cast: {e}"), + }) + }) +} + +/// SDD-108 §1 platform-write: `AudioManager.setMode(mode)`. +/// +/// `mode` is the Android `AudioManager.MODE_*` integer constant. +/// Use [`ANDROID_MODE_IN_COMMUNICATION`] for engagement; pass back +/// the snapshotted prior mode (from +/// [`crate::mode_stack::ModeRelease::LastRelease`]) for restoration. +#[cfg(target_os = "android")] +pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> { + use jni::objects::JValue; + android_audio_manager_call(move |env, audio_manager| { + env.call_method(audio_manager, "setMode", "(I)V", &[JValue::Int(mode)]) + .map_err(|e| AudioModeError::MethodCallFailed { + method: "setMode", + detail: e.to_string(), + })?; + Ok(()) + }) } diff --git a/crates/chanora_audio/src/ptt.rs b/crates/chanora_audio/src/ptt.rs index a6b7a82..dbed1b7 100644 --- a/crates/chanora_audio/src/ptt.rs +++ b/crates/chanora_audio/src/ptt.rs @@ -337,6 +337,9 @@ async fn run_watchdog( mod tests { use super::*; + /// SWE4-UV-035: `PttCapabilityLevel::as_str()` mapping is + /// unambiguous and stable (used by diagnostic sanitization and + /// the capability badge). #[test] fn level_as_str_is_stable() { assert_eq!(PttCapabilityLevel::L0Focused.as_str(), "L0Focused"); @@ -350,6 +353,9 @@ mod tests { ); } + /// SWE4-UV-035 / SWE4-UV-046: level → is_global classification; + /// `L0Focused` (the pinned Android level per SDD-110) must + /// report not-global so no global hotkey binding is attempted. #[test] fn level_is_global_classification() { assert!(!PttCapabilityLevel::L0Focused.is_global()); @@ -359,6 +365,8 @@ mod tests { assert!(PttCapabilityLevel::L4DeviceAware.is_global()); } + /// SWE4-UV-038 / DEC-027: `PttBackendDescriptor::focused()` only + /// carries sanitization-safe fields (no key codes / key syms). #[test] fn focused_descriptor_carries_only_safe_fields() { let d = PttBackendDescriptor::focused(); @@ -371,6 +379,7 @@ mod tests { let _: Option<&str> = d.bound_input_class; } + /// SWE4-UV-037: `AudioTransmitGate::set/load` round-trips. #[test] fn gate_set_and_load_roundtrip() { let g = AudioTransmitGate::new(false); @@ -381,6 +390,8 @@ mod tests { assert!(!g.load()); } + /// SWE4-UV-037: `AudioTransmitGate::subscribe()` watch channel + /// delivers every transition observed by the gate. #[tokio::test(flavor = "current_thread", start_paused = true)] async fn gate_subscribe_observes_transitions() { let g = AudioTransmitGate::new(false); @@ -393,6 +404,8 @@ mod tests { assert!(!*rx.borrow_and_update()); } + /// SWE4-UV-039: `MissedKeyUpWatchdog` clamps `AudioTransmitGate` + /// to false after the configured timeout (missed key-up safety). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn watchdog_clears_transmit_after_timeout() { let g = AudioTransmitGate::new(false);