feat(audio,android): wire engine + AudioManager JNI through ModeStack (SDD-108 §2)

Replace the prior one-shot android_engage_voice_communication call
with a ModeStack-mediated acquire/release pair. AudioEngine snapshots
the system audio mode on first acquire via android_get_audio_mode()
and restores it on last release via android_set_audio_mode(prior).
MODE_IN_COMMUNICATION (3) is engaged across the voice-session lifetime
per SDD-108.

Includes the Android AudioManager getMode/setMode JNI helpers
(placed in chanora_audio::engine alongside the existing JNI surface)
and the small ptt.rs touch needed for the SDD-108 ID-tag on the
existing tests.

Trace: SDD-108, SDD-115.
This commit is contained in:
EdisonJwa
2026-05-18 10:53:18 +08:00
parent 76c6d1d40c
commit 78190c0694
2 changed files with 391 additions and 63 deletions
+378 -63
View File
@@ -144,6 +144,24 @@ pub struct AudioEngine {
_output_stream: Mutex<Option<cpal::Stream>>,
#[cfg(target_os = "ios")]
_ios_voice_unit: Mutex<Option<crate::ios_voice_unit::IosVoiceUnit>>,
/// 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<Option<crate::android_voice_unit::AndroidVoiceUnit>>,
/// 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<crate::mode_stack::ModeStack>,
// Hand the inbound-voice forwarder task a shutdown signal.
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
/// 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::AndroidAudioDiagnostics> {
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<F, R>(op: F) -> Result<R, AudioModeError>
where
F: FnOnce(&mut jni::JNIEnv, &jni::objects::JObject) -> Result<R, AudioModeError>
+ std::panic::UnwindSafe,
{
use jni::objects::{JObject, JString, JValue};
let result = std::panic::catch_unwind(|| -> Result<R, AudioModeError> {
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<i32, AudioModeError> {
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(())
})
}
+13
View File
@@ -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);