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.
This commit is contained in:
Generated
+21
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
//! - `<service android:name=".AndroidVoiceForegroundService"
|
||||
//! android:exported="false"
|
||||
//! android:foregroundServiceType="microphone"/>`
|
||||
//!
|
||||
//! 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<AudioStreamAsync<OboeInput, InputCallback>>,
|
||||
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
|
||||
|
||||
// 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<AudioSessionId>,
|
||||
|
||||
// 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<BackendEventRx>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HardwareEffectHandles {
|
||||
aec: Option<jni::objects::GlobalRef>,
|
||||
ns: Option<jni::objects::GlobalRef>,
|
||||
agc: Option<jni::objects::GlobalRef>,
|
||||
}
|
||||
|
||||
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<Self, BackendError> {
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
|
||||
// --- Open input stream (SDD-112) ---------------------------
|
||||
let mut input_builder = AudioStreamBuilder::default()
|
||||
.set_direction::<OboeInput>()
|
||||
.set_sample_rate(cfg.sample_rate as i32)
|
||||
.set_channel_count::<Mono>()
|
||||
.set_format::<i16>()
|
||||
.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::<OboeOutput>()
|
||||
.set_sample_rate(cfg.sample_rate as i32)
|
||||
.set_channel_count::<Mono>()
|
||||
.set_format::<i16>()
|
||||
.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<AudioStreamAsync<OboeInput, InputCallback>, 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<InputPresetChoice> = Vec::new();
|
||||
while let Some(preset_choice) = next_input_preset_after(&attempted_presets) {
|
||||
attempted_presets.push(preset_choice);
|
||||
let mut attempted_sharings: Vec<SharingModeChoice> = 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::<OboeInput>()
|
||||
.set_sample_rate(cfg.sample_rate as i32)
|
||||
.set_channel_count::<Mono>()
|
||||
.set_format::<i16>()
|
||||
.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<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
||||
let cb = OutputCallback {
|
||||
event_tx: event_tx.clone(),
|
||||
};
|
||||
let builder = AudioStreamBuilder::default()
|
||||
.set_direction::<OboeOutput>()
|
||||
.set_sample_rate(cfg.sample_rate as i32)
|
||||
.set_channel_count::<Mono>()
|
||||
.set_format::<i16>()
|
||||
.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<AudioSessionId> {
|
||||
self.session_id
|
||||
}
|
||||
|
||||
fn take_event_rx(&mut self) -> Option<BackendEventRx> {
|
||||
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<AudioSessionId> {
|
||||
// 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<jni::objects::GlobalRef> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
@@ -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<dyn MobileVoiceAudioBackend>`
|
||||
// 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<BackendEvent>;
|
||||
|
||||
/// 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<BackendEvent>;
|
||||
|
||||
/// 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<Self, BackendError> {
|
||||
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<dyn MobileVoiceAudioBackend>` 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<dyn MobileVoiceAudioBackend>` 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<BackendEventRx>;
|
||||
|
||||
/// 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<AudioSessionId>;
|
||||
|
||||
/// 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<InputPresetChoice> {
|
||||
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<SharingModeChoice> {
|
||||
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<Option<AndroidAudioDiagnostics>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
fn android_audio_diagnostics_slot() -> &'static std::sync::Mutex<Option<AndroidAudioDiagnostics>> {
|
||||
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<AndroidAudioDiagnostics> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user