//! Cross-platform mobile voice audio backend trait (SDD-111). //! //! `MobileVoiceAudioBackend` abstracts the lifetime of a paired //! low-latency voice input/output stream pair on mobile platforms. //! It is implemented on Android by //! [`crate::android_voice_unit::AndroidVoiceUnit`] (SDD-111). On //! iOS the existing [`crate::ios_voice_unit::IosVoiceUnit`] will be //! back-filled to this trait under SDD-117 — until that lands the //! iOS implementation does **not** implement this trait, and iOS //! audio continues to flow through its dedicated path. //! //! Desktop targets (Windows / macOS / Linux) do not use this trait; //! `cpal` (and SDL on Linux) own desktop capture/playback per the //! existing audio engine design. // TODO(SDD-117): back-fill `IosVoiceUnit` to implement this trait // so the engine can hold a single `Box` // across iOS and Android. 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; /// Sender end shared with audio callbacks. Unbounded is deliberate: /// dropping a route-change event because the engine is briefly behind /// is preferable to blocking the audio thread. pub type BackendEventTx = mpsc::UnboundedSender; /// Audio session id reported by the platform after the input stream /// has been opened. Required by SDD-113 to bind hardware effects /// (`AcousticEchoCanceler`, `NoiseSuppressor`, `AutomaticGainControl`) /// against the active capture session. pub type AudioSessionId = i32; /// 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, /// PTT transmission gate — true when the user holds the PTT key. pub transmit_active: Arc, /// Counter incremented per encoded frame sent. pub frames_sent: Arc, /// 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, /// 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>` 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>>, /// Master output gain (f32 bits stored in AtomicU32 for lock-free /// cross-thread read from the realtime audio callback). pub output_gain: Arc, /// True = output silence regardless of incoming voice frames. pub output_muted: Arc, /// 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>, /// Shared audio-processing config (WebRTC APM flags, VAD backend). pub audio_processing_config: Arc>, /// Shared audio-processing statistics for diagnostics. pub audio_processing_stats: Arc, } /// 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 { if sample_rate == 0 || sample_rate > 192_000 { return Err(BackendError::InvalidConfig { reason: format!("sample_rate {sample_rate} out of range (0, 192000]"), }); } if !(1..=2).contains(&channel_count) { return Err(BackendError::InvalidConfig { reason: format!("channel_count {channel_count} out of range [1, 2]"), }); } Ok(Self { sample_rate, channel_count, request_low_latency, request_exclusive, effects, }) } } /// SDD-111 trait. The engine drives a backend through this surface. /// /// Object-safety: kept object-safe so the engine can hold a /// `Box` once SDD-117 back-fills iOS. /// All methods take `&mut self` because lifecycle transitions /// require exclusive access; the audio callback thread does **not** /// reach engine state through this trait — it marshals events /// through the error channel exposed by [`Self::take_error_rx`]. pub trait MobileVoiceAudioBackend: Send { /// Start audio flowing. Must be called after `open()` (which /// the constructor performs). fn start(&mut self) -> Result<(), BackendError>; /// Stop audio flow. Streams remain open and `start` may be /// called again. fn stop(&mut self) -> Result<(), BackendError>; /// Close streams and release platform resources. After this /// the backend is unusable. /// /// NOTE: SDD-111 item 1 specifies a consuming `self` signature. /// We retain `&mut self` to preserve trait object-safety /// (`Box` is the target shape per /// SDD-117); the engine treats post-`close()` instances as /// drop-on-next-statement. fn close(&mut self) -> Result<(), BackendError>; /// Take the backend event receiver (SDD-111 item 1 / SDD-115). /// Returns `None` after the first call — the engine is the sole /// owner of the receiver. Audio callbacks retain only the sender. fn take_event_rx(&mut self) -> Option; /// Sample rate the platform actually granted (Hz). SDD-111 item 1. fn achieved_sample_rate(&self) -> u32; /// Achieved input preset (SDD-112 item 6, SDD-111 item 1). fn achieved_input_preset(&self) -> AchievedInputPreset; /// Frames-per-burst the platform reported (SDD-112 item 4). fn achieved_frames_per_burst(&self) -> u32; /// Platform audio session id for the input stream, if known. /// Required for SDD-113 hardware-effect binding. fn session_id(&self) -> Option; /// Achieved performance-mode of the input stream (SDD-112). fn achieved_input_performance_mode(&self) -> AchievedPerformanceMode; /// Achieved sharing-mode of the input stream (SDD-112). fn achieved_input_sharing_mode(&self) -> AchievedSharingMode; /// Achieved performance-mode of the output stream (SDD-112). fn achieved_output_performance_mode(&self) -> AchievedPerformanceMode; /// Achieved sharing-mode of the output stream (SDD-112). fn achieved_output_sharing_mode(&self) -> AchievedSharingMode; } // --- Pure helpers (test-enabling, SWE4-UV-048/049/051) ----------- // // These helpers describe the unit-level decisions encoded in the // Android backend's fallback ladders and the SRS-210 latency tier // classifier. They are intentionally pure (no platform handles, // no IO) so they can be unit-tested on the host target without // AAudio / JNI. They mirror the production ladder in // `android_voice_unit::AndroidVoiceUnit::open_input_fallback` and // the SDD-116 matrix basis described in SDD-111/112. // // Production code (the Android backend) currently inlines the // ladder for clarity; these helpers are kept here as the canonical // reference and as the verification surface for SWE.4. If the // ladder ever changes, both this helper and the inline code MUST // move together — see SWE4-UV-048/049. /// Input-preset choice for the Android backend (SDD-112 item 6). /// Mirrors `oboe::InputPreset` without depending on it so the /// helper is host-testable. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InputPresetChoice { /// Voice-communication preset (preferred, primary). VoiceCommunication, /// Voice-performance preset (first fallback rung). VoicePerformance, /// Generic / unprocessed (last rung). Generic, } /// Returns the next input-preset to try given the presets already /// attempted, or `None` when the ladder is exhausted (SDD-112 /// item 6, SWE4-UV-048). /// /// Ladder: VoiceCommunication -> VoicePerformance -> Generic. pub fn next_input_preset_after(attempted: &[InputPresetChoice]) -> Option { const LADDER: [InputPresetChoice; 3] = [ InputPresetChoice::VoiceCommunication, InputPresetChoice::VoicePerformance, InputPresetChoice::Generic, ]; LADDER.iter().copied().find(|p| !attempted.contains(p)) } /// Sharing-mode choice for the Android backend (SDD-112 item 7). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SharingModeChoice { /// `Exclusive` — lowest-latency path; first try. Exclusive, /// `Shared` — fallback when `Exclusive` is denied. Shared, } /// Returns the next sharing-mode to try (SDD-112 item 7, /// SWE4-UV-049). Ladder: Exclusive -> Shared -> exhausted. pub fn next_sharing_mode_after(attempted: &[SharingModeChoice]) -> Option { const LADDER: [SharingModeChoice; 2] = [SharingModeChoice::Exclusive, SharingModeChoice::Shared]; LADDER.iter().copied().find(|m| !attempted.contains(m)) } /// Latency tier per SRS-210 (SWE4-UV-051). Pure mapping from the /// platform-granted `AchievedPerformanceMode` to the SRS-210 /// latency-tier decision the engine uses for diagnostics and /// adaptive jitter-buffer sizing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LatencyTier { /// 150 ms target tier (low-latency granted). LowLatency, /// 250 ms fallback tier (no low-latency). Fallback, } /// SDD-116 / SRS-210 classifier: maps the achieved performance mode /// to a latency tier. Pure function — no platform dependencies. pub fn latency_tier_for(achieved: AchievedPerformanceMode) -> LatencyTier { match achieved { AchievedPerformanceMode::LowLatency => LatencyTier::LowLatency, AchievedPerformanceMode::PowerSaving | AchievedPerformanceMode::None => { LatencyTier::Fallback } } } // --- SDD-112 / SDD-113 / SDD-116 diagnostics snapshot ------------- // // `AndroidAudioDiagnostics` is the cross-platform-shaped snapshot // exported by the diagnostics bundle (`chanora_diagnostics`) and the // FRB bridge so that android-p0-acceptance TC-14 / TC-15 / TC-16 can // collect per-device evidence for the SDD-116 verification matrix. // All fields are device-side technical scalars; per SDD-090 no PII, // permission state, or server identity is admitted into this struct. /// Per-effect engine that satisfied a hardware-effect request /// (SDD-113 item 5). `Hardware` means the JNI `create()` returned /// a live `GlobalRef` and `setEnabled(true)` succeeded; `Software` /// means the engine's cross-platform AEC/NS/AGC path is carrying /// the effect; `None` means the effect was not requested at all. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EffectEngine { /// Platform hardware effect engaged on the active session id. Hardware, /// Software-engine fallback path engaged for the effect. Software, /// Neither path engaged (effect not requested). None, } impl fmt::Display for EffectEngine { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Hardware => f.write_str("hardware"), Self::Software => f.write_str("software"), Self::None => f.write_str("none"), } } } /// Per-effect engagement record (SDD-113 item 7). `engaged` is the /// observable "this effect is doing something" bit; `engine` records /// which path satisfied the request so SRS-212 verification can /// distinguish hardware vs software fulfilment from a single export. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct EffectEngagement { /// Whether the effect is currently active (hardware or software). pub engaged: bool, /// Which engine satisfied the request. pub engine: EffectEngine, } impl Default for EffectEngagement { fn default() -> Self { Self { engaged: false, engine: EffectEngine::None, } } } /// Achieved input-preset reported after stream-open (SDD-112 item 6). /// `Unknown` is used when the platform / wrapper does not expose a /// readback of the achieved preset; SDD-116 item 3 requires the /// attempt sequence to be observable, and the achieved value is /// preserved here whenever the wrapper can surface it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AchievedInputPreset { /// `VoiceCommunication` preset granted (primary). VoiceCommunication, /// `VoicePerformance` preset granted (first fallback rung). VoicePerformance, /// `Generic` preset granted (last rung). Generic, /// Platform did not expose a preset readback. Unknown, } impl fmt::Display for AchievedInputPreset { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::VoiceCommunication => f.write_str("VoiceCommunication"), Self::VoicePerformance => f.write_str("VoicePerformance"), Self::Generic => f.write_str("Generic"), Self::Unknown => f.write_str("Unknown"), } } } /// Diagnostics snapshot of the Android voice-audio session /// (SDD-112 item 4 / SDD-113 item 7 / SDD-116 item 3). /// /// Populated once at `AndroidVoiceUnit::open()` and cleared on /// `close()`. The struct is cross-platform-compilable (the surrounding /// `chanora_diagnostics::AndroidAudioReport` re-exports it) so the /// diagnostics bundle has a stable schema even on non-Android hosts — /// on non-Android the diagnostics report carries `None` for this /// section. /// /// Per SDD-090 every field here is a device-side technical scalar. /// No server hostname, account id, permission state, or PII admitted. /// /// Verification: SWE4-UV-052 follow-through. With these fields now /// exposed, an instrumented test on a real device can assert /// `achieved.usage == VoiceCommunication` and /// `achieved.content_type == Speech` against the recorded export; /// today the values are pinned by SDD-112 item 5 on the requested /// side and the achieved side is `Unknown` until an AAudio readback /// path is wired. #[derive(Debug, Clone)] pub struct AndroidAudioDiagnostics { // Requested side (SDD-112 items 4..7) — fixed by SDD-112. /// Requested performance mode 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>, > = std::sync::OnceLock::new(); fn android_audio_diagnostics_slot() -> &'static std::sync::Mutex> { ANDROID_AUDIO_DIAGNOSTICS.get_or_init(|| std::sync::Mutex::new(None)) } /// Publish a fresh diagnostics snapshot. Called by /// `AndroidVoiceUnit::open()` after both streams open and hardware- /// effect attach completes (SDD-112 item 10 / SDD-113 item 7). pub fn publish_android_audio_diagnostics(d: AndroidAudioDiagnostics) { if let Ok(mut g) = android_audio_diagnostics_slot().lock() { *g = Some(d); } } /// Clear the diagnostics slot. Called by `AndroidVoiceUnit::close()` /// and `Drop` so a stale snapshot does not survive past the voice /// session. pub fn clear_android_audio_diagnostics() { if let Ok(mut g) = android_audio_diagnostics_slot().lock() { *g = None; } } /// Read the current diagnostics snapshot. Returns `None` when no /// voice session is active (and always returns `None` on non-Android /// targets). Cross-platform-callable from the FRB bridge. pub fn current_android_audio_diagnostics() -> Option { android_audio_diagnostics_slot() .lock() .ok() .and_then(|g| g.clone()) } #[cfg(test)] mod tests { use super::*; /// SWE4-UV-047: default config records 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"); } }