crates/chanora_audio/src/engine.rs: drop the macOS-specific event-queue producer/consumer path; macOS now uses the iOS-style direct AudioHandler::fill_buffer in the VPIO render callback. The shared AudioHandler is an Arc<Mutex<...>>; the realtime callback uses try_lock so it never blocks on the tokio decode task (see ios_voice_unit.rs render callback). crates/chanora_audio/src/mobile_voice_backend.rs: update VoiceAudioParams cfg gates — handler is now the iOS/macOS/desktop shape (Arc<Mutex<AudioHandler<SessionAudioId>>>), event_producer is Android-only. crates/chanora_audio/src/lib.rs: widen the audio_event_queue module visibility to test so the macOS-specific path can be exercised by the unit test suite. Cargo.toml: bump cpal 0.17.3 -> 0.18.0, jni 0.21 -> 0.22.4, windows 0.54 -> 0.62, criterion 0.5 -> 0.8. Cargo.lock follows.
814 lines
32 KiB
Rust
814 lines
32 KiB
Rust
//! 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.
|
|
|
|
use std::fmt;
|
|
use std::sync::atomic::{AtomicBool, AtomicU32};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use tokio::sync::mpsc;
|
|
use tsclientlib::audio::AudioHandler;
|
|
|
|
use crate::engine::SessionAudioId;
|
|
use crate::AudioEffects;
|
|
use chanora_protocol::OutPacket;
|
|
|
|
/// 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;
|
|
|
|
/// Engine-owned state shared with mobile voice audio callbacks.
|
|
#[cfg_attr(target_os = "ios", derive(Clone))]
|
|
pub(crate) struct VoiceAudioParams {
|
|
/// Opus-encoded voice packets sent on this channel toward the
|
|
/// protocol layer.
|
|
pub voice_out_tx: mpsc::Sender<OutPacket>,
|
|
/// PTT transmission gate — true when the user holds the PTT key.
|
|
pub transmit_active: Arc<AtomicBool>,
|
|
/// Counter incremented per encoded frame sent.
|
|
pub frames_sent: Arc<AtomicU32>,
|
|
/// Pre-encode amplitude scale (1.0 = unity).
|
|
pub mic_gain: f32,
|
|
/// AudioHandler owned by the Android output callback.
|
|
#[cfg(target_os = "android")]
|
|
pub handler: AudioHandler<SessionAudioId>,
|
|
/// Producer used by Android engine tasks to feed the output callback.
|
|
#[cfg(target_os = "android")]
|
|
pub event_producer: crate::audio_event_queue::AudioEventProducer,
|
|
/// AudioHandler that inbound decode+mix feeds into; the output
|
|
/// callback pulls mixed stereo f32 from it. iOS, macOS, and desktop
|
|
/// share this `Arc<Mutex<...>>` shape; the realtime callback uses
|
|
/// `try_lock` so it never blocks on the tokio decode task (see
|
|
/// `ios_voice_unit.rs` render callback).
|
|
#[cfg(not(target_os = "android"))]
|
|
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
|
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
|
|
/// cross-thread read from the realtime audio callback).
|
|
pub output_gain: Arc<AtomicU32>,
|
|
/// True = output silence regardless of incoming voice frames.
|
|
pub output_muted: Arc<AtomicBool>,
|
|
/// Optional TransmitModeSelector for VoiceActivity transmit mode.
|
|
/// The capture callback calls set_voice_activity_open on this when
|
|
/// VAD detects speech. None means VoiceActivity mode is disabled.
|
|
pub voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
|
/// Shared audio-processing config (WebRTC APM flags, VAD backend).
|
|
pub audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
|
/// Shared audio-processing statistics for diagnostics.
|
|
pub audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
|
}
|
|
|
|
/// 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: false,
|
|
request_exclusive: false,
|
|
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 for the current Android request profile.
|
|
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 for the current Android request profile.
|
|
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 the conservative shared
|
|
/// voice-input request profile, 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 remains Exclusive -> Shared -> exhausted.
|
|
/// The default request profile may start at Shared, but the ladder still
|
|
/// exists for explicitly opt-in low-latency / exclusive experiments.
|
|
#[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!(BackendError::OpenFailed("x".into())
|
|
.to_string()
|
|
.contains("open failed"));
|
|
assert!(BackendError::LifecycleFailed("y".into())
|
|
.to_string()
|
|
.contains("lifecycle failed"));
|
|
assert!(BackendError::ErrorDisconnected
|
|
.to_string()
|
|
.contains("disconnected"));
|
|
assert!(BackendError::Platform("z".into())
|
|
.to_string()
|
|
.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!(
|
|
AchievedPerformanceMode::LowLatency.to_string(),
|
|
"LowLatency"
|
|
);
|
|
assert_eq!(
|
|
AchievedPerformanceMode::PowerSaving.to_string(),
|
|
"PowerSaving"
|
|
);
|
|
assert_eq!(AchievedPerformanceMode::None.to_string(), "None");
|
|
assert_eq!(AchievedSharingMode::Exclusive.to_string(), "Exclusive");
|
|
assert_eq!(AchievedSharingMode::Shared.to_string(), "Shared");
|
|
}
|
|
}
|