Files
chanora/crates/chanora_audio/src/engine.rs
T

2663 lines
109 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Audio engine — owns platform-native capture/playback, the Opus
//! encoder, and the tsclientlib `AudioHandler` for decode+mix.
//!
//! The engine is started after a protocol connection is established
//! and stopped before disconnect. It does not retry on device
//! change.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
#[cfg(target_os = "linux")]
use cpal::traits::{DeviceTrait, HostTrait};
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use cpal::{SampleFormat, SizedSample};
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use std::collections::hash_map::DefaultHasher;
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use std::hash::{Hash, Hasher};
use tokio::sync::mpsc;
use tracing::{debug, info};
// `error!` and `warn!` are used only inside the cpal capture /
// playback paths (`build_input_stream`, `build_output_stream`,
// `try_open_capture` log lines). Cfg-gate the imports too so iOS
// builds don't carry an unused-imports warning.
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use tracing::{error, warn};
#[cfg(any(target_os = "ios", target_os = "android"))]
use tracing::warn;
use tsclientlib::audio::AudioHandler;
use chanora_protocol::{InboundVoice, OutPacket};
use crate::AudioError;
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use audiopus::coder::Encoder as OpusEncoder;
/// Stable Chanora-side identifier for AudioHandler bookkeeping.
/// We only ever have one connection at a time (DEC-006), so this is
/// trivially unique.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct SessionAudioId(pub u64);
/// Audio framing: 48 kHz mono, 20 ms = 960 samples per frame.
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
const SAMPLE_RATE: u32 = 48_000;
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
const FRAME_SAMPLES: usize = 48_000 / 50; // 960
/// List of available audio devices from the platform.
#[derive(Debug, Clone)]
pub struct AudioDeviceList {
/// Available input (capture) devices.
pub input_devices: Vec<AudioDeviceInfo>,
/// Available output (playback) devices.
pub output_devices: Vec<AudioDeviceInfo>,
}
/// Info about a single audio device.
#[derive(Debug, Clone)]
pub struct AudioDeviceInfo {
/// Stable platform-reported device identifier.
pub id: String,
/// Human-readable device name from the OS.
pub name: String,
/// Additional device details useful for disambiguation.
pub details: String,
/// True if the OS reports this as the default device.
pub is_default: bool,
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
fn desktop_device_id(device: &cpal::Device) -> Option<String> {
device.id().ok().map(|id| id.to_string())
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
fn short_device_id(id: &str) -> String {
let mut hasher = DefaultHasher::new();
id.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
fn describe_device(device: &cpal::Device) -> Option<AudioDeviceInfo> {
let id = desktop_device_id(device)?;
let description = device.description().ok();
let name = description
.as_ref()
.map(|d| d.name().trim().to_owned())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| format!("Device {}", &short_device_id(&id)[..8]));
let mut details = Vec::new();
if let Some(description) = description.as_ref() {
if let Some(manufacturer) = description.manufacturer() {
details.push(manufacturer.to_owned());
}
if let Some(driver) = description.driver() {
details.push(driver.to_owned());
}
let device_type = description.device_type();
if device_type != cpal::device_description::DeviceType::Unknown {
details.push(format!("{device_type}"));
}
let interface_type = description.interface_type();
if interface_type != cpal::device_description::InterfaceType::Unknown {
details.push(format!("{interface_type}"));
}
}
details.push(format!("id={}", short_device_id(&id)));
Some(AudioDeviceInfo {
id,
name,
details: details.join(" · "),
is_default: false,
})
}
/// Enumerate available audio input and output devices.
/// On mobile platforms (iOS, Android) returns an empty list because
/// device selection is managed by the OS audio session.
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
pub fn list_audio_devices() -> AudioDeviceList {
use cpal::traits::HostTrait;
let mut list = AudioDeviceList {
input_devices: Vec::new(),
output_devices: Vec::new(),
};
let host = cpal::default_host();
#[cfg(target_os = "linux")]
{
let default_input_name = host.default_input_device().and_then(|device| {
device
.description()
.ok()
.map(|description| description.name().trim().to_owned())
.filter(|name| !name.is_empty())
});
let default_output_name = host.default_output_device().and_then(|device| {
device
.description()
.ok()
.map(|description| description.name().trim().to_owned())
.filter(|name| !name.is_empty())
});
list.input_devices =
crate::linux_pipewire_input::list_input_devices(default_input_name.as_deref());
list.output_devices =
crate::linux_pipewire_input::list_output_devices(default_output_name.as_deref());
}
#[cfg(not(target_os = "linux"))]
{
let default_in = host
.default_input_device()
.and_then(|device| desktop_device_id(&device));
if let Ok(devices) = host.input_devices() {
for d in devices {
if let Some(mut device) = describe_device(&d) {
device.is_default = default_in
.as_ref()
.is_some_and(|default_id| default_id == &device.id);
list.input_devices.push(device);
}
}
}
let default_out = host
.default_output_device()
.and_then(|device| desktop_device_id(&device));
if let Ok(devices) = host.output_devices() {
for d in devices {
if let Some(mut device) = describe_device(&d) {
device.is_default = default_out
.as_ref()
.is_some_and(|default_id| default_id == &device.id);
list.output_devices.push(device);
}
}
}
}
list
}
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
/// Enumerate available audio input and output devices.
pub fn list_audio_devices() -> AudioDeviceList {
AudioDeviceList {
input_devices: Vec::new(),
output_devices: Vec::new(),
}
}
/// Engine configuration.
#[derive(Clone)]
pub struct AudioEngineConfig {
/// Input gain applied before encoding (1.0 = pass-through).
pub mic_gain: f32,
/// Initial PTT state. When false the encoder is bypassed and no
/// outbound packets are produced.
pub ptt_initial: bool,
/// Audio-effect toggles. The struct is honoured by *naming* but
/// the filters themselves are still no-op in Beta — see the
/// crate-level docs and DEC-007/008/009/010.
pub effects: crate::AudioEffects,
/// A.5 mobile: prefer the OS-provided "voice communication"
/// audio source on mobile platforms (Android
/// `MediaRecorder.AudioSource.VOICE_COMMUNICATION`, iOS
/// `AVAudioSession.Mode.voiceChat`). On Linux desktop this is
/// ignored — the DSP chain stays a no-op and we use the
/// default ALSA/PipeWire source.
///
/// Android status: this is enforced by the Oboe-only backend,
/// which opens input with `VoiceCommunication` and output with
/// voice-communication usage / speech content. Setting `false`
/// is rejected on Android because the P0 path intentionally has
/// no generic mobile-audio fallback.
pub mobile_voice_preset: bool,
/// Optional input device id override. When `None`, the system
/// default input device is used. Set to a device id from
/// [`list_audio_devices`] to pin a specific microphone.
pub input_device_id: Option<String>,
/// Optional output device id override.
pub output_device_id: Option<String>,
/// Optional selector used by P1 VoiceActivity to publish VAD state.
#[doc(hidden)]
pub voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
}
impl std::fmt::Debug for AudioEngineConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AudioEngineConfig")
.field("mic_gain", &self.mic_gain)
.field("ptt_initial", &self.ptt_initial)
.field("effects", &self.effects)
.field("mobile_voice_preset", &self.mobile_voice_preset)
.field("input_device_id", &self.input_device_id)
.field("output_device_id", &self.output_device_id)
.field(
"voice_activity_selector",
&self.voice_activity_selector.as_ref().map(|_| "present"),
)
.finish()
}
}
impl Default for AudioEngineConfig {
fn default() -> Self {
Self {
mic_gain: 1.0,
ptt_initial: false,
effects: crate::AudioEffects::default(),
mobile_voice_preset: true,
input_device_id: None,
output_device_id: None,
voice_activity_selector: None,
}
}
}
/// Running audio engine. Drop = stop.
pub struct AudioEngine {
/// `transmit_active` is the authoritative gate for outbound
/// voice — the Opus encoder feed consults this flag once per
/// 20 ms frame. PTT subsystems (focused widget, future
/// Windows / macOS / Linux global backends) drive this flag
/// through [`Self::set_transmit_active`]; nothing else is
/// permitted to flip it (SAD-075 / SDD-089).
transmit_gate: crate::ptt::AudioTransmitGate,
frames_sent: Arc<AtomicU32>,
frames_received: Arc<AtomicU32>,
/// Master output gain as f32 bits in an AtomicU32. Default 1.0.
/// Adjusted via [`Self::set_output_gain`] from the bridge.
output_gain: Arc<AtomicU32>,
/// Master output mute. When true the output callback fills the
/// device buffer with silence regardless of incoming voice
/// frames. Used for self-output-mute on the local device,
/// independent of the server-side mute the protocol layer
/// broadcasts.
output_muted: Arc<AtomicBool>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
client_volume_overrides: Arc<Mutex<HashMap<SessionAudioId, f32>>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
voice_out_tx: mpsc::Sender<OutPacket>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
mic_gain: f32,
// Streams must be dropped to stop audio. Linux owns native
// PipeWire/PulseAudio threads; Windows owns cpal streams. On iOS both
// sides collapse into a single `IosVoiceUnit` (one VoiceProcessingIO
// AudioUnit hosts mic + speaker).
#[cfg(target_os = "linux")]
_input_stream: Mutex<Option<crate::linux_pipewire_input::LinuxInput>>,
#[cfg(target_os = "windows")]
_input_stream: Mutex<Option<cpal::Stream>>,
#[cfg(target_os = "linux")]
_output_stream: Mutex<Option<crate::linux_pipewire_input::LinuxOutput>>,
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
_output_stream: Mutex<Option<cpal::Stream>>,
#[cfg(any(target_os = "ios", target_os = "macos"))]
_ios_voice_backend: Mutex<Option<IosVoiceBackend>>,
/// SDD-111..SDD-115: Android Oboe voice backend. Owns the input
/// and output streams, SDD-113 hardware-effect handles, and the
/// foreground-service lifecycle; tearing it down on engine drop
/// unbinds effects and stops the service in SDD-115 reverse
/// order.
#[cfg(target_os = "android")]
_android_voice_unit: Arc<Mutex<Option<crate::android_voice_unit::AndroidVoiceUnit>>>,
/// Persist the Android stream request so route-change reopen uses the
/// same effect and latency policy as the original session start.
#[cfg(target_os = "android")]
android_voice_stream_config: Arc<Mutex<crate::mobile_voice_backend::AndroidVoiceStreamConfig>>,
/// SDD-108 §1/§2: refcount-composable audio-mode controller.
/// Snapshots `AudioManager.getMode()` on the 0 → 1 transition and
/// restores it on the 1 → 0 transition. Held in a `Mutex` so the
/// snapshot/restore critical section is serialized across
/// composed callers (SDD-108 §2). Engine-scoped because the
/// mode lifecycle is bound to the voice-session lifecycle
/// (SDD-108 §3).
#[cfg(target_os = "android")]
audio_mode_stack: Arc<Mutex<crate::mode_stack::ModeStack>>,
// Hand the inbound-voice forwarder task a shutdown signal.
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
/// True if the capture stream actually opened. If false (typical
/// in headless environments with null sources, or where the user
/// denied microphone permission), PTT becomes a no-op and
/// `frames_sent` stays at 0.
capture_active: bool,
}
// cpal::Stream is not Send. We keep the engine pinned to the thread
// it was constructed on — `chanora_core` spawns it inside a
// `tokio::task::spawn_blocking` so the streams stay on that worker.
// This `unsafe impl Send` is necessary because the outer Arc<AudioEngine>
// is stored in core's session and must move into a task. The streams
// themselves are only mutated through the Mutex and are dropped on
// the same thread that owns them.
//
// SAFETY: cpal's Stream is not Send because the underlying audio API
// callback thread may not be transferable. We never invoke methods on
// the streams from any thread but the owning one; we only ever *drop*
// them, which cpal documents as safe from any thread for ALSA and
// PipeWire (Linux backend used here). For Windows/macOS the contract
// may differ; production Beta+ work must revisit per-platform.
unsafe impl Send for AudioEngine {}
unsafe impl Sync for AudioEngine {}
#[cfg(any(target_os = "ios", target_os = "macos"))]
enum IosVoiceBackend {
Vpio(crate::ios_voice_unit::IosVoiceUnit),
#[cfg(target_os = "ios")]
Raw(crate::ios_raw_unit::IosRawUnit),
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
impl IosVoiceBackend {
fn pause(&mut self) -> Result<(), AudioError> {
#[cfg(target_os = "ios")]
{
match self {
Self::Vpio(unit) => unit.pause(),
Self::Raw(unit) => unit.pause(),
}
}
#[cfg(target_os = "macos")]
{
match self {
Self::Vpio(_unit) => Ok(()),
}
}
}
fn resume(&mut self) -> Result<(), AudioError> {
#[cfg(target_os = "ios")]
{
match self {
Self::Vpio(unit) => unit.resume(),
Self::Raw(unit) => unit.resume(),
}
}
#[cfg(target_os = "macos")]
{
match self {
Self::Vpio(_unit) => Ok(()),
}
}
}
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn open_ios_voice_backend(
params: crate::mobile_voice_backend::VoiceAudioParams,
) -> Result<IosVoiceBackend, AudioError> {
let _cfg = params.audio_processing_config.lock().unwrap().clone();
#[cfg(target_os = "ios")]
{
if _cfg.ios_mode == crate::IosVoiceProcessingMode::SonoraExperimental {
let raw_params = params.clone();
match crate::ios_raw_unit::IosRawUnit::start(raw_params) {
Ok(unit) => {
info!(target: "chanora_audio", "ios: RemoteIO/WebRTC APM backend selected");
return Ok(IosVoiceBackend::Raw(unit));
}
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
"ios: RemoteIO/WebRTC APM backend failed; falling back to VoiceProcessingIO"
);
}
}
}
}
let unit = crate::ios_voice_unit::IosVoiceUnit::start(params)?;
Ok(IosVoiceBackend::Vpio(unit))
}
fn apply_saved_client_volume(
audio_handler: &mut AudioHandler<SessionAudioId>,
client_volume_overrides: &Mutex<HashMap<SessionAudioId, f32>>,
client_id: SessionAudioId,
) {
let override_volume = match client_volume_overrides.lock() {
Ok(overrides) => overrides.get(&client_id).copied(),
Err(error) => {
tracing::warn!(
target: "chanora_audio",
client_id = client_id.0,
error = %error,
"client volume overrides lock poisoned — saved volume not applied"
);
None
}
};
if let Some(volume) = override_volume {
if let Some(queue) = audio_handler.get_mut_queues().get_mut(&client_id) {
queue.volume = volume;
}
}
}
impl AudioEngine {
#[cfg(target_os = "android")]
fn spawn_android_backend_event_task(
android_voice_unit: Arc<Mutex<Option<crate::android_voice_unit::AndroidVoiceUnit>>>,
android_voice_stream_config: Arc<
Mutex<crate::mobile_voice_backend::AndroidVoiceStreamConfig>,
>,
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_gate: crate::ptt::AudioTransmitGate,
frames_sent: Arc<AtomicU32>,
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
mic_gain: f32,
audio_mode_stack: Arc<Mutex<crate::mode_stack::ModeStack>>,
mut event_rx: crate::mobile_voice_backend::BackendEventRx,
) {
use crate::mobile_voice_backend::BackendEvent;
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
BackendEvent::Disconnected => {
warn!(target: "chanora_audio", "android: backend disconnected; reopening voice unit");
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
crate::android_voice_unit::chanora_android_abandon_audio_focus();
crate::android_voice_unit::clear_global_event_sender();
if let Some(mut unit) = android_voice_unit.lock().unwrap().take() {
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
if let Err(e) = unit.close() {
warn!(
target: "chanora_audio",
error = %e,
"android: failed to close disconnected voice unit before reopen"
);
}
}
if crate::android_voice_unit::chanora_android_start_voice_service() {
info!(
target: "chanora_audio",
"android: voice foreground service restart dispatched after backend disconnect"
);
}
match android_get_audio_mode() {
Ok(current_mode) => {
if current_mode != ANDROID_MODE_IN_COMMUNICATION {
match android_set_audio_mode(ANDROID_MODE_IN_COMMUNICATION) {
Ok(()) => info!(
target: "chanora_audio",
prior_mode = current_mode,
"android: AudioManager mode re-engaged after backend disconnect"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
prior_mode = current_mode,
"android: failed to re-engage MODE_IN_COMMUNICATION after backend disconnect"
),
}
}
}
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"android: AudioManager.getMode failed during backend reopen"
),
}
let cfg_av = android_voice_stream_config.lock().unwrap().clone();
let params = crate::mobile_voice_backend::VoiceAudioParams {
voice_out_tx: voice_out_tx.clone(),
transmit_active: transmit_gate.flag_arc(),
frames_sent: frames_sent.clone(),
mic_gain,
handler: audio_handler.clone(),
output_gain: output_gain.clone(),
output_muted: output_muted.clone(),
voice_activity_selector: voice_activity_selector.clone(),
audio_processing_config: audio_processing_config.clone(),
audio_processing_stats: audio_processing_stats.clone(),
};
match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params) {
Ok(mut reopened) => {
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
match reopened.start() {
Ok(()) => {
if let Some(next_rx) = reopened.take_event_rx() {
Self::spawn_android_backend_event_task(
android_voice_unit.clone(),
android_voice_stream_config.clone(),
voice_out_tx.clone(),
transmit_gate.clone(),
frames_sent.clone(),
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
voice_activity_selector.clone(),
audio_processing_config.clone(),
audio_processing_stats.clone(),
mic_gain,
audio_mode_stack.clone(),
next_rx,
);
}
crate::android_voice_unit::register_global_event_sender(
reopened.event_sender(),
);
if crate::android_voice_unit::chanora_android_request_audio_focus() {
info!(
target: "chanora_audio",
"android: audio focus re-requested after backend disconnect"
);
}
if crate::android_voice_unit::chanora_android_start_bluetooth_sco() {
info!(
target: "chanora_audio",
"android: bluetooth route re-engaged after backend disconnect"
);
}
*android_voice_unit.lock().unwrap() = Some(reopened);
}
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"android: reopened voice unit failed to start after backend disconnect"
),
}
}
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"android: failed to reopen voice unit after backend disconnect"
),
}
}
BackendEvent::FocusLost => {
warn!(
target: "chanora_audio",
"android: audio focus lost permanently (SDD-109); engine should leave session"
);
}
BackendEvent::FocusTransient => {
info!(
target: "chanora_audio",
"android: transient audio focus loss (SDD-109); pausing capture"
);
}
BackendEvent::FocusTransientCanDuck => {
info!(
target: "chanora_audio",
"android: transient audio focus loss with ducking (SDD-109); continuing"
);
}
BackendEvent::FocusGain => {
info!(
target: "chanora_audio",
"android: audio focus regained (SDD-109); resuming capture"
);
}
BackendEvent::BluetoothScoStateChanged(s) => {
info!(
target: "chanora_audio",
sco_state = s,
"android: Bluetooth SCO state changed (SDD-110)"
);
}
}
}
});
}
/// Start the engine: open capture + playback streams, spawn the
/// inbound-voice forwarder, return a handle.
pub fn start(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
voice_in_rx: mpsc::Receiver<InboundVoice>,
) -> Result<Self, AudioError> {
let gate = crate::ptt::AudioTransmitGate::new(cfg.ptt_initial);
Self::start_with_gate(cfg, voice_out_tx, voice_in_rx, gate)
}
/// Start the engine using an externally-owned
/// [`AudioTransmitGate`]. The gate is shared with whatever
/// upstream (typically [`crate::TransmitModeSelector`]) is the
/// authoritative writer of `transmit_active`. See SAD-083.
pub fn start_with_gate(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
Self::start_with_gate_platform(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
// Apple platforms route to a separate backend (VoiceProcessingIO
// via coreaudio-rs) because cpal does not expose the native
// voice-processing AudioUnit controls Chanora needs for VoIP.
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn start_with_gate_platform(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
#[cfg(target_os = "android")]
fn start_with_gate_platform(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
Self::start_with_gate_android(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn start_with_gate_platform(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
/// Non-Apple/non-Android implementation: native Linux voice I/O or cpal on Windows.
/// Kept as a separate function so the Apple and Android paths can
/// short-circuit at the top of `start_with_gate` without dragging a 200-line
/// cfg-gated block. Body is the pre-iOS-port code, unchanged
/// except for the new function name + signature.
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn start_with_gate_cpal(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
mut voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
let host = cpal::default_host();
info!(
target: "chanora_audio",
host_id = ?host.id(),
"starting audio engine: cpal host selected"
);
fn device_name(device: &cpal::Device) -> Option<String> {
device
.description()
.ok()
.map(|description| description.name().trim().to_owned())
.filter(|name| !name.is_empty())
}
/// Helper: find a device by stable id, falling back to default.
#[cfg(not(target_os = "linux"))]
fn find_device<DefaultFn, AllFn, Devices>(
host: &cpal::Host,
default_fn: DefaultFn,
all_fn: AllFn,
prefer: Option<&str>,
) -> Option<cpal::Device>
where
DefaultFn: Fn(&cpal::Host) -> Option<cpal::Device>,
AllFn: Fn(&cpal::Host) -> Result<Devices, cpal::DevicesError>,
Devices: IntoIterator<Item = cpal::Device>,
{
if let Some(id) = prefer {
if let Ok(devices) = all_fn(host) {
for d in devices {
if desktop_device_id(&d).as_deref() == Some(id) {
return Some(d);
}
}
}
}
default_fn(host)
}
#[cfg(target_os = "linux")]
let in_dev = host.default_input_device();
#[cfg(not(target_os = "linux"))]
let in_dev = find_device(
&host,
cpal::Host::default_input_device,
cpal::Host::input_devices,
cfg.input_device_id.as_deref(),
);
#[cfg(target_os = "linux")]
let out_dev = host.default_output_device();
#[cfg(not(target_os = "linux"))]
let out_dev = find_device(
&host,
cpal::Host::default_output_device,
cpal::Host::output_devices,
cfg.output_device_id.as_deref(),
);
#[cfg(not(target_os = "linux"))]
let in_dev = in_dev.ok_or(AudioError::NoInputDevice)?;
#[cfg(not(target_os = "linux"))]
let out_dev = out_dev.ok_or(AudioError::NoOutputDevice)?;
// Log the cpal-reported default configs *before* trying to
// open streams, so a downstream stream-build failure can
// be cross-referenced against what the platform reported
// as its default format. Some locale / driver combinations
// on Windows have been observed to expose configs that
// accept device enumeration but reject `default_*_config`
// afterwards (reported on the ko-KR Windows 11 host as
// "Start audio button not work"). Promote what would
// otherwise be silent or laconic errors into structured
// log records the user can paste back.
#[cfg(target_os = "linux")]
if let Some(in_dev) = in_dev.as_ref() {
match in_dev.default_input_config() {
Ok(c) => info!(
target: "chanora_audio",
channels = c.channels(),
sample_rate = c.sample_rate(),
sample_format = ?c.sample_format(),
"default_input_config reported"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"default_input_config FAILED — capture will be disabled"
),
}
} else {
warn!(
target: "chanora_audio",
"cpal default_input_device unavailable; Linux PipeWire capture will be tried directly"
);
}
#[cfg(not(target_os = "linux"))]
match in_dev.default_input_config() {
Ok(c) => info!(
target: "chanora_audio",
channels = c.channels(),
sample_rate = c.sample_rate(),
sample_format = ?c.sample_format(),
"default_input_config reported"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"default_input_config FAILED — capture will be disabled"
),
}
#[cfg(target_os = "linux")]
if let Some(out_dev) = out_dev.as_ref() {
match out_dev.default_output_config() {
Ok(c) => info!(
target: "chanora_audio",
channels = c.channels(),
sample_rate = c.sample_rate(),
sample_format = ?c.sample_format(),
"default_output_config reported"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"default_output_config FAILED — output stream will fail to build"
),
}
}
#[cfg(not(target_os = "linux"))]
match out_dev.default_output_config() {
Ok(c) => info!(
target: "chanora_audio",
channels = c.channels(),
sample_rate = c.sample_rate(),
sample_format = ?c.sample_format(),
"default_output_config reported"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"default_output_config FAILED — output stream will fail to build"
),
}
#[cfg(target_os = "windows")]
let transmit_flag_for_capture = transmit_gate.flag_arc();
let frames_sent = Arc::new(AtomicU32::new(0));
let frames_received = Arc::new(AtomicU32::new(0));
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
let output_muted = Arc::new(AtomicBool::new(false));
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
// ---------- Capture ----------
// Capture is best-effort. If the platform default input
// device refuses any supported config (typical for
// headless null sources or for users who deny the mic
// permission) we log and continue — playback alone is
// still useful. PTT becomes a no-op in that case.
#[cfg(target_os = "linux")]
let capture_result = match crate::linux_pipewire_input::start_capture(
cfg.input_device_id.as_deref(),
in_dev.as_ref().and_then(device_name).as_deref(),
voice_out_tx.clone(),
transmit_gate.clone(),
frames_sent.clone(),
cfg.mic_gain,
) {
Ok((stream, device_info)) => Ok((Some(stream), true, device_info.name)),
Err(linux_error) => {
warn!(
target: "chanora_audio",
error = %linux_error,
"Linux native capture unavailable"
);
Err(linux_error)
}
};
#[cfg(target_os = "windows")]
let capture_result = try_open_capture(
&in_dev,
voice_out_tx,
transmit_flag_for_capture,
frames_sent.clone(),
cfg.mic_gain,
)
.map(|stream| {
(
Some(stream),
true,
device_name(&in_dev).unwrap_or_else(|| "cpal capture".to_string()),
)
});
let (input_stream, capture_active, capture_device_name) = match capture_result {
Ok((stream, active, device_name)) => (stream, active, device_name),
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
"capture stream unavailable; continuing with playback only"
);
(None, false, "<capture unavailable>".to_string())
}
};
#[cfg(target_os = "windows")]
if let Some(s) = &input_stream {
s.play()
.map_err(|e| AudioError::Backend(format!("input play: {e}")))?;
}
// ---------- Playback ----------
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
// Linux uses native PipeWire/PulseAudio playback. PipeWire is primary;
// PulseAudio is fallback and can also be selected explicitly by id.
#[cfg(target_os = "linux")]
let (output_stream, output_device_info) = crate::linux_pipewire_input::start_output(
cfg.output_device_id.as_deref(),
out_dev.as_ref().and_then(device_name).as_deref(),
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
)?;
#[cfg(target_os = "linux")]
let output_device_name = output_device_info.name;
#[cfg(not(target_os = "linux"))]
let output_stream = {
let out_cfg = out_dev
.default_output_config()
.map_err(|e| AudioError::StreamConfig(format!("output default: {e}")))?;
let out_format = out_cfg.sample_format();
let dev_sample_rate = out_cfg.sample_rate();
let dev_channels = out_cfg.channels() as usize;
// Buffer-size rationale:
// * Windows (WASAPI via cpal): the default period is
// small enough to expose audio-thread scheduler
// jitter on shared-mode endpoints. Pinning at 2048
// frames (~46 ms @ 44.1 kHz) gives the Opus decode
// callback enough headroom while still being well
// under voice-chat latency tolerance.
// * Apple platforms do not reach this cpal path; they
// use direct VoiceProcessingIO AudioUnits.
#[cfg(target_os = "windows")]
let buffer_size = cpal::BufferSize::Fixed(2048);
#[cfg(not(target_os = "windows"))]
let buffer_size = cpal::BufferSize::Default;
let out_stream_cfg = cpal::StreamConfig {
channels: out_cfg.channels(),
sample_rate: out_cfg.sample_rate(),
buffer_size,
};
info!(
target: "chanora_audio",
dev_sample_rate,
dev_channels,
buffer_size = ?buffer_size,
"output stream using device native config (no 48k force)"
);
let stream = match out_format {
SampleFormat::F32 => build_output_stream::<f32>(
&out_dev,
&out_stream_cfg,
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
dev_sample_rate,
dev_channels,
)?,
SampleFormat::I16 => build_output_stream::<i16>(
&out_dev,
&out_stream_cfg,
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
dev_sample_rate,
dev_channels,
)?,
SampleFormat::U16 => build_output_stream::<u16>(
&out_dev,
&out_stream_cfg,
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
dev_sample_rate,
dev_channels,
)?,
other => {
return Err(AudioError::StreamConfig(format!(
"unsupported output format: {other:?}"
)))
}
};
stream
.play()
.map_err(|e| AudioError::Backend(format!("output play: {e}")))?;
stream
};
#[cfg(not(target_os = "linux"))]
let output_device_name = device_name(&out_dev).unwrap_or_default();
info!(
target: "chanora_audio",
in_device = %capture_device_name,
out_device = %output_device_name,
"starting audio engine"
);
// ---------- Inbound forwarder ----------
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone();
let client_volume_overrides_for_task = client_volume_overrides.clone();
let frames_received_for_task = frames_received.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = &mut shutdown_rx => {
debug!(target: "chanora_audio", "inbound forwarder shutting down");
break;
}
item = voice_in_rx.recv() => {
match item {
Some(v) => {
let id = SessionAudioId(v.from_client);
let mut h = handler_for_task.lock().unwrap();
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
apply_saved_client_volume(
&mut h,
&client_volume_overrides_for_task,
id,
);
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
None => break,
}
}
}
}
});
Ok(Self {
transmit_gate,
frames_sent,
frames_received,
output_gain,
output_muted,
audio_processing_config,
audio_processing_stats,
audio_handler,
client_volume_overrides,
#[cfg(target_os = "linux")]
_input_stream: Mutex::new(input_stream),
#[cfg(target_os = "windows")]
_input_stream: Mutex::new(input_stream),
_output_stream: Mutex::new(Some(output_stream)),
shutdown_tx: Some(shutdown_tx),
capture_active,
})
}
#[cfg(target_os = "android")]
fn start_with_gate_android(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
mut voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
info!(target: "chanora_audio", "starting audio engine: Android Oboe backend");
let transmit_flag_for_capture = transmit_gate.flag_arc();
let frames_sent = Arc::new(AtomicU32::new(0));
let frames_received = Arc::new(AtomicU32::new(0));
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
let output_muted = Arc::new(AtomicBool::new(false));
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
if !cfg.mobile_voice_preset {
return Err(AudioError::Backend(
"android: mobile_voice_preset=false is unsupported for Oboe-only backend"
.to_string(),
));
}
let mut audio_mode_stack = crate::mode_stack::ModeStack::new();
if crate::android_voice_unit::chanora_android_start_voice_service() {
info!(
target: "chanora_audio",
"android: voice foreground service start dispatched (SDD-115)"
);
} else {
warn!(
target: "chanora_audio",
"android: foreground service start failed; capture may be denied in background (SDD-115)"
);
}
match android_get_audio_mode() {
Ok(prior_now) => {
let outcome = audio_mode_stack.acquire(prior_now);
if let crate::mode_stack::ModeAcquire::FirstAcquire { prior } = outcome {
match android_set_audio_mode(ANDROID_MODE_IN_COMMUNICATION) {
Ok(()) => info!(
target: "chanora_audio",
prior_mode = prior,
"android: AudioManager mode set to MODE_IN_COMMUNICATION (SDD-108)"
),
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
prior_mode = prior,
"android: setMode failed; rolling back ModeStack acquire (SDD-108 §5)"
);
let _ = audio_mode_stack.release();
}
}
}
}
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"android: AudioManager.getMode failed; skipping mode engage (SDD-108)"
),
}
let cfg_av = crate::mobile_voice_backend::AndroidVoiceStreamConfig {
effects: cfg.effects,
..Default::default()
};
let params = crate::mobile_voice_backend::VoiceAudioParams {
voice_out_tx: voice_out_tx.clone(),
transmit_active: transmit_flag_for_capture,
frames_sent: frames_sent.clone(),
mic_gain: cfg.mic_gain,
handler: audio_handler.clone(),
output_gain: output_gain.clone(),
output_muted: output_muted.clone(),
voice_activity_selector: cfg.voice_activity_selector.clone(),
audio_processing_config: audio_processing_config.clone(),
audio_processing_stats: audio_processing_stats.clone(),
};
let mut android_voice_unit =
crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params).map_err(|e| {
AudioError::Backend(format!("android: failed to open Oboe voice unit: {e}"))
})?;
if let Err(e) = android_voice_unit.start() {
return Err(AudioError::Backend(format!(
"android: failed to start Oboe voice unit: {e}"
)));
}
let android_voice_unit = Arc::new(Mutex::new(Some(android_voice_unit)));
let android_voice_stream_config = Arc::new(Mutex::new(cfg_av.clone()));
let audio_mode_stack = Arc::new(Mutex::new(audio_mode_stack));
if let Some(event_rx) = android_voice_unit
.lock()
.unwrap()
.as_mut()
.and_then(|unit| unit.take_event_rx())
{
Self::spawn_android_backend_event_task(
android_voice_unit.clone(),
android_voice_stream_config.clone(),
voice_out_tx.clone(),
transmit_gate.clone(),
frames_sent.clone(),
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
cfg.voice_activity_selector.clone(),
audio_processing_config.clone(),
audio_processing_stats.clone(),
cfg.mic_gain,
audio_mode_stack.clone(),
event_rx,
);
}
crate::android_voice_unit::register_global_event_sender(
android_voice_unit
.lock()
.unwrap()
.as_ref()
.expect("android voice unit installed")
.event_sender(),
);
if crate::android_voice_unit::chanora_android_request_audio_focus() {
info!(
target: "chanora_audio",
"android: audio focus requested (SDD-109)"
);
} else {
warn!(
target: "chanora_audio",
"android: audio focus request failed; engine operates without focus (SDD-109)"
);
}
if crate::android_voice_unit::chanora_android_start_bluetooth_sco() {
info!(
target: "chanora_audio",
"android: Bluetooth SCO started (SDD-110)"
);
} else {
warn!(
target: "chanora_audio",
"android: Bluetooth SCO start failed; BT HFP may not route correctly (SDD-110)"
);
}
let capture_active = true;
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone();
let client_volume_overrides_for_task = client_volume_overrides.clone();
let frames_received_for_task = frames_received.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = &mut shutdown_rx => {
debug!(target: "chanora_audio", "inbound forwarder shutting down");
break;
}
item = voice_in_rx.recv() => {
match item {
Some(v) => {
let id = SessionAudioId(v.from_client);
let mut h = handler_for_task.lock().unwrap();
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
apply_saved_client_volume(
&mut h,
&client_volume_overrides_for_task,
id,
);
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
None => break,
}
}
}
}
});
Ok(Self {
transmit_gate,
frames_sent,
frames_received,
output_gain,
output_muted,
audio_processing_config,
audio_processing_stats,
audio_handler,
client_volume_overrides,
voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain,
_android_voice_unit: android_voice_unit,
android_voice_stream_config,
audio_mode_stack,
shutdown_tx: Some(shutdown_tx),
capture_active,
})
}
/// Apple implementation: a single VoiceProcessingIO AudioUnit
/// drives mic capture + speaker playback (see
/// `ios_voice_unit.rs` for why cpal is unsuitable on iOS).
/// This mirrors `start_with_gate_cpal` in scaffolding —
/// atomics, audio handler, inbound forwarder task — but
/// replaces the two cpal stream constructions with a single
/// `IosVoiceUnit::start` call.
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn start_with_gate_ios(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
mut voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
info!(
target: "chanora_audio",
"starting audio engine: Apple VoiceProcessingIO backend"
);
// Apple audio-session configuration is performed Swift-side
// in `apps/chanora_flutter/ios/Runner/AppDelegate.swift`
// BEFORE Flutter starts its audio pipeline. The category +
// mode pair set there (`.playAndRecord` + `.default`) is
// what VPIO binds against. Logging here just records that
// the engine-start path acknowledges the request; the
// actual session mutation lives in Swift because it must
// happen before Dart loads.
if cfg.mobile_voice_preset {
info!(
target: "chanora_audio",
"ios: voice-chat session mode requested — AVAudioSession configured in AppDelegate"
);
}
let transmit_flag_for_capture = transmit_gate.flag_arc();
let frames_sent = Arc::new(AtomicU32::new(0));
let frames_received = Arc::new(AtomicU32::new(0));
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
let output_muted = Arc::new(AtomicBool::new(false));
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
let voice_out_tx_for_backend = voice_out_tx.clone();
// Construct the live iOS voice backend. Platform VPIO stays
// the default shipping path; Sonora/RemoteIO remains opt-in.
let ios_voice_backend =
open_ios_voice_backend(crate::mobile_voice_backend::VoiceAudioParams {
handler: audio_handler.clone(),
output_gain: output_gain.clone(),
output_muted: output_muted.clone(),
voice_out_tx: voice_out_tx_for_backend,
transmit_active: transmit_flag_for_capture,
frames_sent: frames_sent.clone(),
mic_gain: cfg.mic_gain,
voice_activity_selector: cfg.voice_activity_selector.clone(),
audio_processing_config: audio_processing_config.clone(),
audio_processing_stats: audio_processing_stats.clone(),
})?;
// Capture is always considered active on iOS — VPIO's
// input element is wired up by the AudioUnit itself, no
// separate "did the capture stream open" question to
// answer. If the user denied mic permission VPIO will
// simply hand us silence buffers.
let capture_active = true;
// Inbound forwarder: same shape as the non-iOS path. Pumps
// Opus packets from `voice_in_rx` into AudioHandler so the
// VPIO render callback (commit 4) finds decoded frames
// waiting.
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone();
let client_volume_overrides_for_task = client_volume_overrides.clone();
let frames_received_for_task = frames_received.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = &mut shutdown_rx => {
debug!(target: "chanora_audio", "inbound forwarder shutting down");
break;
}
item = voice_in_rx.recv() => {
match item {
Some(v) => {
let id = SessionAudioId(v.from_client);
let mut h = handler_for_task.lock().unwrap();
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
apply_saved_client_volume(
&mut h,
&client_volume_overrides_for_task,
id,
);
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
None => break,
}
}
}
}
});
Ok(Self {
transmit_gate,
frames_sent,
frames_received,
output_gain,
output_muted,
audio_processing_config,
audio_processing_stats,
audio_handler,
client_volume_overrides,
voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain,
_ios_voice_backend: Mutex::new(Some(ios_voice_backend)),
shutdown_tx: Some(shutdown_tx),
capture_active,
})
}
/// Stop the engine. Idempotent.
pub fn stop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
// Drop the streams, which stops their callback threads.
// Each platform has a slightly different backend; the
// common contract is that dropping the wrapper stops
// audio. iOS collapses input + output into one
// `IosVoiceUnit` (see `ios_voice_unit.rs`); every other
// platform has separate input + output stream owners.
#[cfg(any(target_os = "linux", target_os = "windows"))]
{
let _ = self._input_stream.lock().unwrap().take();
let _ = self._output_stream.lock().unwrap().take();
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
let _ = self._ios_voice_backend.lock().unwrap().take();
}
// SDD-115 reverse-order teardown on Android:
// 1) stop Bluetooth SCO + abandon audio focus;
// 2) close the voice unit (releases SDD-113 hardware
// effects then stops + closes the Oboe streams);
// 3) restore the prior audio mode (SDD-108 §1 on 1 → 0
// transition);
// 4) stop the foreground service.
#[cfg(target_os = "android")]
{
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
crate::android_voice_unit::chanora_android_abandon_audio_focus();
crate::android_voice_unit::clear_global_event_sender();
if let Some(mut unit) = self._android_voice_unit.lock().unwrap().take() {
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
if let Err(e) = unit.close() {
warn!(
target: "chanora_audio",
error = %e,
"android: AndroidVoiceUnit::close failed (SDD-115)"
);
}
}
// SDD-108 §1/§2: release the audio-mode stack. Only the
// 1 → 0 transition writes the platform; mid-stack
// releases stay engaged. Underflow (release without a
// matching acquire) is clamped without panic per
// SDD-108 §1.
// SDD-108 §5: tolerate a poisoned mutex on the teardown
// path — if a panicking thread held the lock, we still
// need to drive the release to completion (otherwise the
// platform stays in MODE_IN_COMMUNICATION).
let release = self
.audio_mode_stack
.lock()
.unwrap_or_else(|e| e.into_inner())
.release();
match release {
crate::mode_stack::ModeRelease::LastRelease { prior } => {
match android_set_audio_mode(prior) {
Ok(()) => info!(
target: "chanora_audio",
restored_mode = prior,
"android: AudioManager mode restored (SDD-108)"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
restored_mode = prior,
"android: failed to restore prior AudioManager mode (SDD-108)"
),
}
}
crate::mode_stack::ModeRelease::StillHeld => {
info!(
target: "chanora_audio",
"android: audio mode still held by composed session (SDD-108)"
);
}
crate::mode_stack::ModeRelease::AlreadyReleased => {
// No engage ever happened (e.g. mobile_voice_preset
// was false, or getMode failed). Silent no-op.
}
}
if crate::android_voice_unit::chanora_android_stop_voice_service() {
info!(
target: "chanora_audio",
"android: voice foreground service stop dispatched (SDD-115)"
);
}
}
info!(target: "chanora_audio", "audio engine stopped");
}
/// iOS-only: restart the underlying VoiceProcessingIO unit after
/// route changes.
pub fn ios_restart_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
let backend = open_ios_voice_backend(crate::mobile_voice_backend::VoiceAudioParams {
handler: self.audio_handler.clone(),
output_gain: self.output_gain.clone(),
output_muted: self.output_muted.clone(),
voice_out_tx: self.voice_out_tx.clone(),
transmit_active: self.transmit_gate.flag_arc(),
frames_sent: self.frames_sent.clone(),
mic_gain: self.mic_gain,
voice_activity_selector: self.voice_activity_selector.clone(),
audio_processing_config: self.audio_processing_config.clone(),
audio_processing_stats: self.audio_processing_stats.clone(),
})?;
let mut guard = self._ios_voice_backend.lock().unwrap();
*guard = Some(backend);
Ok(())
}
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
{
Ok(())
}
}
/// Android-only: reopen the underlying Oboe voice backend after a
/// route/device change while preserving the engine-owned state.
pub fn android_restart_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(target_os = "android")]
{
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
crate::android_voice_unit::chanora_android_abandon_audio_focus();
crate::android_voice_unit::clear_global_event_sender();
if let Some(mut unit) = self._android_voice_unit.lock().unwrap().take() {
unit.close().map_err(|e| {
AudioError::Backend(format!(
"android: failed to close Oboe voice unit during route restart: {e}"
))
})?;
}
if crate::android_voice_unit::chanora_android_start_voice_service() {
info!(
target: "chanora_audio",
"android: voice foreground service restart dispatched after route change"
);
}
if crate::android_voice_unit::chanora_android_request_audio_focus() {
info!(
target: "chanora_audio",
"android: audio focus re-requested after route change"
);
}
let cfg_av = self.android_voice_stream_config.lock().unwrap().clone();
let params = crate::mobile_voice_backend::VoiceAudioParams {
voice_out_tx: self.voice_out_tx.clone(),
transmit_active: self.transmit_gate.flag_arc(),
frames_sent: self.frames_sent.clone(),
mic_gain: self.mic_gain,
handler: self.audio_handler.clone(),
output_gain: self.output_gain.clone(),
output_muted: self.output_muted.clone(),
voice_activity_selector: self.voice_activity_selector.clone(),
audio_processing_config: self.audio_processing_config.clone(),
audio_processing_stats: self.audio_processing_stats.clone(),
};
let mut reopened = crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params)
.map_err(|e| {
AudioError::Backend(format!("android: failed to reopen Oboe voice unit: {e}"))
})?;
reopened.start().map_err(|e| {
AudioError::Backend(format!("android: failed to restart Oboe voice unit: {e}"))
})?;
if let Some(event_rx) = reopened.take_event_rx() {
Self::spawn_android_backend_event_task(
self._android_voice_unit.clone(),
self.android_voice_stream_config.clone(),
self.voice_out_tx.clone(),
self.transmit_gate.clone(),
self.frames_sent.clone(),
self.audio_handler.clone(),
self.output_gain.clone(),
self.output_muted.clone(),
self.voice_activity_selector.clone(),
self.audio_processing_config.clone(),
self.audio_processing_stats.clone(),
self.mic_gain,
self.audio_mode_stack.clone(),
event_rx,
);
}
crate::android_voice_unit::register_global_event_sender(reopened.event_sender());
if crate::android_voice_unit::chanora_android_start_bluetooth_sco() {
info!(
target: "chanora_audio",
"android: bluetooth route re-engaged after route change"
);
}
let mut guard = self._android_voice_unit.lock().unwrap();
*guard = Some(reopened);
Ok(())
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
/// iOS-only: pause the underlying VoiceProcessingIO unit.
pub fn ios_pause_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
let mut guard = self._ios_voice_backend.lock().unwrap();
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice backend not running".to_string()))?;
unit.pause()
}
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
{
Ok(())
}
}
/// iOS-only: resume the underlying VoiceProcessingIO unit.
pub fn ios_resume_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
let mut guard = self._ios_voice_backend.lock().unwrap();
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice backend not running".to_string()))?;
unit.resume()
}
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
{
Ok(())
}
}
/// Set the **transmission gate** (SRS-201). When true the
/// encoder feed is allowed to emit Opus frames; when false the
/// captured audio is discarded before encoding. This is the
/// only writer permitted on `transmit_active` (SAD-075 /
/// SDD-089). Push-to-Talk subsystems — focused PTT today,
/// per-platform global backends in a follow-up — call this
/// method exclusively. No-op when capture is inactive.
pub fn set_transmit_active(&self, active: bool) {
self.transmit_gate.set(active);
}
/// Current transmit gate state.
pub fn transmit_active(&self) -> bool {
self.transmit_gate.load()
}
/// Shared handle to the underlying transmit gate (SAD-075 /
/// SDD-089). Returned for diagnostics and integration tests
/// only; never mutate the underlying atomic directly — use
/// [`Self::set_transmit_active`] instead.
pub fn transmit_gate(&self) -> &crate::ptt::AudioTransmitGate {
&self.transmit_gate
}
/// True if the capture stream opened. When false, the engine
/// runs in playback-only mode and the transmit gate is a
/// no-op (no frames will ever be encoded).
pub fn capture_active(&self) -> bool {
self.capture_active
}
/// Number of Opus frames sent since the engine started.
pub fn frames_sent(&self) -> u32 {
self.frames_sent.load(Ordering::Relaxed)
}
/// Number of inbound voice packets received and decoded.
pub fn frames_received(&self) -> u32 {
self.frames_received.load(Ordering::Relaxed)
}
/// Current audio-processing config snapshot.
pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig {
self.audio_processing_config.lock().unwrap().clone()
}
/// Apply a voice-processing config after validating iOS invariants.
pub fn set_audio_processing_config(
&self,
config: crate::AudioProcessingConfig,
) -> Result<(), AudioError> {
#[cfg(target_os = "ios")]
config.validate_for_ios()?;
let mut guard = self.audio_processing_config.lock().unwrap();
*guard = config;
Ok(())
}
/// Current voice-processing stats snapshot.
pub fn audio_processing_stats(&self) -> crate::AudioProcessingStats {
let config = self.audio_processing_config.lock().unwrap().clone();
self.audio_processing_stats.snapshot(&config)
}
/// Latest Android voice-audio diagnostics snapshot (SDD-112 item
/// 10 / SDD-113 item 7 / SDD-116 item 3). Returns `Some(...)`
/// once `AndroidVoiceUnit::open()` has published a snapshot; the
/// slot is cleared on `close()` / `Drop`. Per SDD-090 the snapshot
/// contains only device-side technical scalars — no PII.
#[cfg(target_os = "android")]
pub fn android_diagnostics(
&self,
) -> Option<crate::mobile_voice_backend::AndroidAudioDiagnostics> {
crate::mobile_voice_backend::current_android_audio_diagnostics()
}
/// Set master output mute. When true the output stream emits
/// silence regardless of incoming voice frames.
pub fn set_output_muted(&self, muted: bool) {
self.output_muted.store(muted, Ordering::Relaxed);
}
/// True if the master output is currently muted locally.
pub fn output_muted(&self) -> bool {
self.output_muted.load(Ordering::Relaxed)
}
/// Set master output gain. 1.0 is unity; 0.0 is silent. Values
/// above 1.0 amplify (and may clip downstream). Clamped to a
/// sensible range internally.
pub fn set_output_gain(&self, gain: f32) {
let clamped = gain.clamp(0.0, 4.0);
self.output_gain.store(clamped.to_bits(), Ordering::Relaxed);
}
/// Current master output gain.
pub fn output_gain(&self) -> f32 {
f32::from_bits(self.output_gain.load(Ordering::Relaxed))
}
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. Values above `1.0` amplify and may clip. Clamped to
/// `0.0..4.0`.
pub fn set_client_volume(&self, client_id: u64, volume: f32) {
let clamped = volume.clamp(0.0, 4.0);
match self.audio_handler.lock() {
Ok(mut h) => {
if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) {
q.volume = clamped;
}
}
Err(e) => {
tracing::warn!(
target: "chanora_audio",
client_id,
volume = clamped,
error = %e,
"set_client_volume: audio_handler lock poisoned — volume not applied"
);
}
}
match self.client_volume_overrides.lock() {
Ok(mut overrides) => {
if (clamped - 1.0).abs() <= f32::EPSILON {
overrides.remove(&SessionAudioId(client_id));
} else {
overrides.insert(SessionAudioId(client_id), clamped);
}
}
Err(e) => {
tracing::warn!(
target: "chanora_audio",
client_id,
volume = clamped,
error = %e,
"set_client_volume: client volume overrides lock poisoned"
);
}
}
}
}
impl Drop for AudioEngine {
fn drop(&mut self) {
self.stop();
}
}
// ---------- Capture pipeline ----------
#[cfg(target_os = "windows")]
fn try_open_capture(
in_dev: &cpal::Device,
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
) -> Result<cpal::Stream, AudioError> {
let in_cfg = in_dev
.default_input_config()
.map_err(|e| AudioError::StreamConfig(format!("input default: {e}")))?;
let in_sample_rate = in_cfg.sample_rate();
let in_channels = in_cfg.channels() as usize;
let in_format = in_cfg.sample_format();
// Buffer-size rationale (same shape as the output path):
// * Windows: pin to 2048 frames to avoid the small-period
// jitter of WASAPI shared mode.
// * macOS / iOS: CoreAudio picks a HAL-friendly default;
// iOS RemoteIO rejects arbitrary buffer-size requests.
// * Linux does not reach this cpal capture path in production;
// it uses native PipeWire/PulseAudio capture instead.
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
#[cfg(target_os = "windows")]
{
in_stream_cfg.buffer_size = cpal::BufferSize::Fixed(2048);
}
#[cfg(not(target_os = "windows"))]
{
in_stream_cfg.buffer_size = cpal::BufferSize::Default;
}
let opus_enc = crate::opus_voice::new_voip_encoder("cpal capture")?;
let capture_state = Arc::new(Mutex::new(CaptureState::new(
opus_enc,
in_sample_rate,
in_channels,
mic_gain,
voice_out_tx,
transmit_active,
frames_sent,
)));
let stream = match in_format {
SampleFormat::F32 => build_input_stream::<f32>(in_dev, &in_stream_cfg, capture_state)?,
SampleFormat::I16 => build_input_stream::<i16>(in_dev, &in_stream_cfg, capture_state)?,
SampleFormat::U16 => build_input_stream::<u16>(in_dev, &in_stream_cfg, capture_state)?,
other => {
return Err(AudioError::StreamConfig(format!(
"unsupported input format: {other:?}"
)))
}
};
Ok(stream)
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
pub(crate) struct CaptureState {
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
mic_gain: f32,
/// 48 kHz mono buffer accumulated to FRAME_SAMPLES before each encode.
pcm_accum: Vec<f32>,
/// Resampling state for non-48k sources (simple linear resampler).
resample_pos: f64,
/// Last input sample carried over from the previous cpal callback
/// so the resampler can interpolate across the buffer boundary
/// without dropping continuity. Without this, every cpal period
/// boundary produces a discontinuity → audible buzz / popping
/// roughly at the period rate (~100 Hz for a 10 ms period on
/// Linux ALSA defaults).
resample_last: f32,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
/// The PTT transmission gate. Read once per outbound frame; the
/// CaptureState never mutates this flag.
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
/// Pre-allocated mono downmix buffer. Resized in-place each
/// callback; `clear()` retains capacity. SDD-094 realtime-thread
/// invariant: this avoids the heap allocation that the prior fix
/// at engine.rs:1389-1397 (output-side `scratch` buffer)
/// addressed; the capture side has the same pattern and the same
/// user-perceptible cost on glibc malloc when the callback runs
/// on a realtime SCHED_FIFO thread.
mono_scratch: Vec<f32>,
/// Pre-allocated Opus-frame buffer. Sized to FRAME_SAMPLES (960)
/// on construction; reused across frames. `clear()` retains
/// capacity so the drain-into-frame path skips the allocator
/// after warmup. Same precedent as `mono_scratch` above.
frame_scratch: Vec<f32>,
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl CaptureState {
pub(crate) fn new(
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
mic_gain: f32,
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
) -> Self {
Self {
encoder,
in_sample_rate,
in_channels,
mic_gain,
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
resample_pos: 0.0,
resample_last: 0.0,
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
frames_sent,
// Generous upper bound for typical cpal periods
// (commonly 256..1024 frames); `clear()` retains the
// backing allocation across callbacks. See struct doc.
mono_scratch: Vec::with_capacity(4096),
// Exact upper bound: drain pulls FRAME_SAMPLES at a time.
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
}
}
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
/// 48 kHz mono frames; encode and send when `transmit_active`
/// is true (PTT engaged).
pub(crate) fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
if !self.transmit_active.load(Ordering::Relaxed) {
// Drain accumulator while muted so we don't pop on PTT release.
self.pcm_accum.clear();
return;
}
// 1. Down-mix to mono + gain.
// Reuse `self.mono_scratch` to avoid a per-callback Vec
// allocation on the realtime audio thread; see struct
// doc and the engine.rs:1389-1397 precedent for why this
// matters for user-perceptible audio popping.
let in_channels = self.in_channels;
let mic_gain = self.mic_gain;
self.mono_scratch.clear();
let frame_count = buf.len() / in_channels.max(1);
self.mono_scratch.reserve(frame_count);
for frame in buf.chunks(in_channels) {
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
self.mono_scratch
.push((sum / frame.len() as f32) * mic_gain);
}
// 2. Resample to 48 kHz if needed. We re-borrow
// `mono_scratch` as a shared slice per branch to satisfy
// the borrow checker against `&mut self` on the
// resample path.
if self.in_sample_rate == SAMPLE_RATE {
// Disjoint-borrow: copy the slice into pcm_accum without
// aliasing &mut self.
let (src, dst) = (&self.mono_scratch, &mut self.pcm_accum);
dst.extend_from_slice(src);
} else {
// resample_into_accum reads `mono` and writes
// `self.pcm_accum` / `self.resample_*`. These fields are
// disjoint from `self.mono_scratch`, but the function
// signature takes `&mut self`, so we take ownership of
// the scratch buffer briefly via `std::mem::take`, run
// the resampler, then move the buffer back so its
// capacity is preserved across callbacks.
let mono = std::mem::take(&mut self.mono_scratch);
self.resample_into_accum(&mono);
self.mono_scratch = mono;
}
// 3. Encode any complete frames. Clamp each sample to
// [-1.0, 1.0] before handing to libopus's float encoder —
// out-of-range samples are hard-clipped inside libopus,
// which produces audible distortion on transient peaks.
// Soft-clamping at the engine boundary preserves headroom
// and matches what every other VoIP client does.
while self.pcm_accum.len() >= FRAME_SAMPLES {
// Reuse `self.frame_scratch` to avoid a per-frame Vec
// allocation on the realtime audio thread; same
// rationale as the engine.rs:1389-1397 precedent.
let frame = &mut self.frame_scratch;
frame.clear();
frame.extend(self.pcm_accum.drain(..FRAME_SAMPLES));
for s in frame.iter_mut() {
if *s > 1.0 {
*s = 1.0;
} else if *s < -1.0 {
*s = -1.0;
}
}
match self
.encoder
.encode_float(&frame[..], &mut self.opus_out[..])
{
Ok(len) => {
crate::opus_voice::send_voip_frame(
&self.voice_out_tx,
&self.frames_sent,
&self.opus_out,
len,
|| {
warn!(
target: "chanora_audio",
"voice_out queue full; dropping frame"
);
},
|| {
warn!(target: "chanora_audio", "voice_out closed; stopping send");
},
);
}
Err(e) => {
error!(target: "chanora_audio", error = %e, "opus encode failed");
}
}
}
}
/// Simple linear resampler for `in_sample_rate → 48000`.
///
/// The resampler maintains continuity across cpal buffer
/// boundaries by treating `self.resample_last` as a virtual
/// sample at fractional index `0.0`, followed by the incoming
/// `mono` slice at indices `1.0..=mono.len()`. Without the
/// virtual anchor sample, the first interpolation point of
/// every new buffer collapses to `mono[0]` for both `a` and
/// `b`, producing a sample-and-hold step at every cpal period
/// boundary. On Linux ALSA defaults that's a ~100 Hz buzz /
/// popping. Production-quality work would use a windowed sinc
/// kernel; this carries the previous sample only and keeps the
/// CPU cost trivial.
fn resample_into_accum(&mut self, mono: &[f32]) {
if mono.is_empty() {
return;
}
let ratio = self.in_sample_rate as f64 / SAMPLE_RATE as f64;
let mut pos = self.resample_pos;
// The virtual buffer has length mono.len() + 1: index 0 is
// the carried-over last sample, indices 1..=mono.len() are
// the new buffer. We emit output samples while `pos` is
// strictly less than mono.len() so we always have a valid
// right-hand neighbour. The leftover sub-sample offset is
// carried over via `resample_pos` (rebased to the next
// buffer's virtual index 0 below).
while pos < mono.len() as f64 {
let i = pos.floor() as isize;
let frac = pos - i as f64;
let a = if i <= 0 {
self.resample_last
} else {
mono[(i - 1) as usize]
};
let b = if i < mono.len() as isize {
mono[i as usize]
} else {
// Should not happen given the while-condition, but
// guard for the boundary where ratio < 1.0 and `pos`
// can step past mono.len() in the last iteration.
a
};
self.pcm_accum
.push((a as f64 + frac * (b - a) as f64) as f32);
pos += ratio;
}
// Carry the leftover sub-sample offset, rebased so the next
// buffer's virtual index 0 is the new `resample_last`.
self.resample_pos = pos - mono.len() as f64;
// Anchor for the next buffer's interpolation.
self.resample_last = *mono.last().unwrap();
}
}
/// Per-sample format conversion to f32 in the range [-1.0, 1.0].
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
pub(crate) trait ToF32 {
fn to_f32_sample(self) -> f32;
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl ToF32 for f32 {
fn to_f32_sample(self) -> f32 {
self
}
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl ToF32 for i16 {
fn to_f32_sample(self) -> f32 {
f32::from(self) / f32::from(i16::MAX)
}
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl ToF32 for u16 {
fn to_f32_sample(self) -> f32 {
(f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX)
}
}
#[cfg(target_os = "windows")]
fn build_input_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
state: Arc<Mutex<CaptureState>>,
) -> Result<cpal::Stream, AudioError>
where
T: SizedSample + ToF32 + Send + 'static,
{
let stream = device
.build_input_stream(
config,
move |data: &[T], _| {
let mut s = state.lock().unwrap();
s.ingest(data);
},
move |e| {
error!(target: "chanora_audio", error = %e, "input stream error");
},
None,
)
.map_err(|e| AudioError::Backend(format!("build_input_stream: {e}")))?;
Ok(stream)
}
// ---------- Playback pipeline ----------
#[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn build_output_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
dev_sample_rate: u32,
dev_channels: usize,
) -> Result<cpal::Stream, AudioError>
where
T: SizedSample + FromF32 + Send + 'static,
{
// Per-channel resampler state shared across cpal callbacks for
// continuity at buffer boundaries. AudioHandler produces 48 kHz
// stereo f32; we map the first two device channels to L/R and
// fill any extra channels with silence. `pos` carries the
// fractional source-sample offset; `last_l` / `last_r` are the
// anchor samples from the previous callback (avoid the sample-
// and-hold step that would otherwise pop at every period
// boundary — same trick as the capture-side resampler).
let resample_ratio = SAMPLE_RATE as f64 / dev_sample_rate as f64;
// `same_rate` is the common case where the device is already at
// 48 kHz — bypass the resampler entirely.
let same_rate = dev_sample_rate == SAMPLE_RATE;
let resample_state: Arc<Mutex<PlaybackResampleState>> =
Arc::new(Mutex::new(PlaybackResampleState {
pos: 0.0,
last_l: 0.0,
last_r: 0.0,
}));
// Reusable scratch buffer for 48 kHz stereo samples coming out
// of AudioHandler. Allocating a fresh `Vec` per cpal callback on
// glibc malloc was costing measurable time on the realtime audio
// thread and contributing to the popping users heard. We resize
// the buffer to the per-callback need and only grow the
// backing allocation when it must — typical buffer-size jitter
// stays under the high-water mark and skips the allocator
// entirely after the first few callbacks.
let mut scratch: Vec<f32> = Vec::with_capacity(8192);
// Diagnostic: log a warning if the cpal callback wall-clock
// exceeds the period budget so we can correlate user-perceived
// popping with measurable underruns. The threshold is half a
// period at 48 kHz / 2 ch / 1024-frame typical period — about
// 10 ms. We rate-limit the warning to once per second.
let mut last_slow_warn = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(2))
.unwrap_or_else(std::time::Instant::now);
let stream = device
.build_output_stream(
config,
move |out: &mut [T], _| {
let cb_start = std::time::Instant::now();
let muted = output_muted.load(Ordering::Relaxed);
let dev_frames = out.len() / dev_channels.max(1);
let src_frames = if same_rate {
dev_frames
} else {
// Ask for a few extra source frames so we never
// starve on the resample fractional boundary.
((dev_frames as f64 * resample_ratio).ceil() as usize) + 2
};
let needed = src_frames * 2;
if scratch.len() < needed {
scratch.resize(needed, 0.0);
}
// Zero the live slice; AudioHandler::fill_buffer
// writes silence into untouched samples, but
// resizing up from a smaller call leaves residual
// values from earlier callbacks. Use `fill` which
// optimises to memset on f32.
scratch[..needed].fill(0.0);
// Lock-and-decode. We deliberately hold the lock
// only for the duration of fill_buffer; the inbound
// forwarder uses handle_packet which is queue-fast.
{
let mut h = handler.lock().unwrap();
h.fill_buffer(&mut scratch[..needed]);
}
if muted {
for dst in out.iter_mut() {
*dst = T::from_f32_sample(0.0);
}
} else {
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
if same_rate && dev_channels == 2 {
// Fast path: device is already 48 kHz stereo.
for (dst, s) in out.iter_mut().zip(scratch[..needed].iter().copied()) {
*dst = T::from_f32_sample(s * gain);
}
} else {
// Resample 48 kHz stereo → device-rate ×
// device-channels with continuity across
// callback boundaries.
let mut state = resample_state.lock().unwrap();
let mut pos = state.pos;
let mut last_l = state.last_l;
let mut last_r = state.last_r;
for frame_idx in 0..dev_frames {
let i = pos.floor() as isize;
let frac = pos - i as f64;
let (a_l, a_r) = if i <= 0 {
(last_l, last_r)
} else {
let idx = ((i - 1) as usize) * 2;
(scratch[idx], scratch[idx + 1])
};
let i_usize = i.max(0) as usize;
let (b_l, b_r) = if i_usize < src_frames {
let idx = i_usize * 2;
(scratch[idx], scratch[idx + 1])
} else {
(a_l, a_r)
};
let l = (a_l as f64 + frac * (b_l - a_l) as f64) as f32 * gain;
let r = (a_r as f64 + frac * (b_r - a_r) as f64) as f32 * gain;
let base = frame_idx * dev_channels;
if dev_channels == 1 {
// Mono output device (typical on iOS
// .voiceChat / phone-call audio path):
// downmix L+R to a single channel
// rather than dropping the R side.
// Without the downmix, anything panned
// right in the AudioHandler stereo mix
// is silently lost \u2014 on speakerphone
// this manifested as quiet remote
// speakers being inaudible.
out[base] = T::from_f32_sample((l + r) * 0.5);
} else {
out[base] = T::from_f32_sample(l);
if dev_channels >= 2 {
out[base + 1] = T::from_f32_sample(r);
}
for c in 2..dev_channels {
out[base + c] = T::from_f32_sample(0.0);
}
}
pos += resample_ratio;
}
let consumed = pos.floor() as usize;
state.pos = pos - consumed as f64;
if consumed > 0 && consumed <= src_frames {
let idx = (consumed - 1) * 2;
last_l = scratch[idx];
last_r = scratch[idx + 1];
state.last_l = last_l;
state.last_r = last_r;
}
}
}
// Period-budget diagnostic.
let elapsed = cb_start.elapsed();
let period_us = (dev_frames as u64 * 1_000_000) / dev_sample_rate as u64;
if elapsed.as_micros() as u64 > period_us / 2
&& last_slow_warn.elapsed() > std::time::Duration::from_secs(1)
{
last_slow_warn = std::time::Instant::now();
warn!(
target: "chanora_audio",
callback_us = elapsed.as_micros() as u64,
period_us,
dev_frames,
"output callback exceeded half the period budget — possible underrun cause"
);
}
},
move |e| {
error!(target: "chanora_audio", error = %e, "output stream error");
},
None,
)
.map_err(|e| {
error!(
target: "chanora_audio",
error = %e,
requested_channels = config.channels,
requested_sample_rate = config.sample_rate,
"build_output_stream FAILED"
);
AudioError::Backend(format!("build_output_stream: {e}"))
})?;
Ok(stream)
}
/// Resampler state carried across output cpal callbacks. See
/// `build_output_stream` for the rationale.
#[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
struct PlaybackResampleState {
pos: f64,
last_l: f32,
last_r: f32,
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
trait FromF32 {
fn from_f32_sample(v: f32) -> Self;
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl FromF32 for f32 {
fn from_f32_sample(v: f32) -> Self {
v
}
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl FromF32 for i16 {
fn from_f32_sample(v: f32) -> Self {
(v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16
}
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl FromF32 for u16 {
fn from_f32_sample(v: f32) -> Self {
let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;
(s + i32::from(i16::MAX) + 1) as u16
}
}
// ---------- Android voice-communication routing ----------
//
// SDD-108 §1: `AudioManager.setMode(MODE_IN_COMMUNICATION)` engagement
// is the routing-level lever that tells Android "this is a voice
// call, please use the earpiece / engage hardware AEC / NS / AGC
// where the device supports it". The helpers here are the
// platform-write surface invoked by the engine's `ModeStack`
// (`crate::mode_stack`) — the stack owns refcount + snapshot
// semantics (SDD-108 §2), these helpers only perform the platform
// write / read.
//
// Placement rationale (SDD-108 §4): the JNI helpers for
// `AudioManager.getMode` / `AudioManager.setMode` live in
// `chanora_audio::engine` (not `chanora_bridge::android_init`) per
// SDD-108 §4, which assigns the AudioManager JNI surface to the Rust
// audio engine. This keeps the AudioManager interaction co-located
// with the engine state (`ModeStack`) that owns it, so the
// snapshot/restore lifecycle and the JNI calls evolve together.
//
// `AudioManager.MODE_IN_COMMUNICATION == 3` per the Android SDK.
#[cfg(target_os = "android")]
pub const ANDROID_MODE_IN_COMMUNICATION: i32 = 3;
/// SDD-108 §5: typed error for the Android `AudioManager` JNI surface.
///
/// Replaces the previous ad-hoc `Result<_, String>` so callers can
/// pattern-match on the failure category (attach vs. method call vs.
/// other) and log/route accordingly. `Display` renders a stable
/// human-readable form that is safe to feed into the existing
/// `tracing::warn!(error = %e, ...)` sites.
#[cfg(target_os = "android")]
#[derive(Debug, Clone)]
pub enum AudioModeError {
/// `JavaVM::from_raw` or `attach_current_thread` failed: the
/// engine could not reach the JVM at all.
JniAttachFailed(String),
/// A specific JNI method call failed (e.g. `getMode`, `setMode`,
/// `getSystemService`). `method` is a static string for grep-ability.
MethodCallFailed {
method: &'static str,
detail: String,
},
/// Catch-all for non-JNI-method failures (null context, panic in
/// the JNI body, etc.).
Other(String),
}
#[cfg(target_os = "android")]
impl std::fmt::Display for AudioModeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::JniAttachFailed(d) => write!(f, "JNI attach failed: {d}"),
Self::MethodCallFailed { method, detail } => {
write!(f, "JNI method `{method}` failed: {detail}")
}
Self::Other(d) => write!(f, "{d}"),
}
}
}
#[cfg(target_os = "android")]
impl std::error::Error for AudioModeError {}
/// JNI helper shared by `android_get_audio_mode` and
/// `android_set_audio_mode`: attach to the current thread and return
/// the `AudioManager` jobject. Centralised so SDD-108's two platform
/// entry points share one bootstrapping path.
#[cfg(target_os = "android")]
fn android_audio_manager_call<F, R>(op: F) -> Result<R, AudioModeError>
where
F: FnOnce(&mut jni::JNIEnv, &jni::objects::JObject) -> Result<R, AudioModeError>
+ std::panic::UnwindSafe,
{
use jni::objects::{JObject, JString, JValue};
let result = std::panic::catch_unwind(|| -> Result<R, AudioModeError> {
let ctx = ndk_context::android_context();
let vm_ptr = ctx.vm();
if vm_ptr.is_null() {
return Err(AudioModeError::JniAttachFailed(
"ndk_context vm is null".to_string(),
));
}
// SAFETY: ndk_context::android_context guarantees `vm` points
// at a live JavaVM* set by our bridge_init JNI hook. The
// unsafe block contains only the cast required by
// `JavaVM::from_raw`.
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) }
.map_err(|e| AudioModeError::JniAttachFailed(format!("jvm from_raw: {e}")))?;
let mut env = jvm
.attach_current_thread()
.map_err(|e| AudioModeError::JniAttachFailed(format!("attach: {e}")))?;
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
let service_name: JString =
env.new_string("audio")
.map_err(|e| AudioModeError::MethodCallFailed {
method: "new_string",
detail: e.to_string(),
})?;
let audio_manager = env
.call_method(
&context_obj,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[JValue::Object(&service_name.into())],
)
.map_err(|e| AudioModeError::MethodCallFailed {
method: "getSystemService",
detail: e.to_string(),
})?
.l()
.map_err(|e| AudioModeError::MethodCallFailed {
method: "getSystemService",
detail: format!("obj cast: {e}"),
})?;
if audio_manager.is_null() {
return Err(AudioModeError::Other(
"AudioManager service is null".to_string(),
));
}
op(&mut env, &audio_manager)
});
match result {
Ok(inner) => inner,
Err(_) => Err(AudioModeError::Other(
"panic in JNI audio_manager call".to_string(),
)),
}
}
/// SDD-108 §1 platform-read: `AudioManager.getMode()`.
///
/// Returns the integer mode constant currently active on the system.
/// Called by the engine on first acquire (0 → 1 transition) so that
/// `ModeStack` can snapshot the prior mode for restoration on last
/// release.
#[cfg(target_os = "android")]
pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
android_audio_manager_call(|env, audio_manager| {
env.call_method(audio_manager, "getMode", "()I", &[])
.map_err(|e| AudioModeError::MethodCallFailed {
method: "getMode",
detail: e.to_string(),
})?
.i()
.map_err(|e| AudioModeError::MethodCallFailed {
method: "getMode",
detail: format!("int cast: {e}"),
})
})
}
/// SDD-108 §1 platform-write: `AudioManager.setMode(mode)`.
///
/// `mode` is the Android `AudioManager.MODE_*` integer constant.
/// Use [`ANDROID_MODE_IN_COMMUNICATION`] for engagement; pass back
/// the snapshotted prior mode (from
/// [`crate::mode_stack::ModeRelease::LastRelease`]) for restoration.
#[cfg(target_os = "android")]
pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
use jni::objects::JValue;
android_audio_manager_call(move |env, audio_manager| {
env.call_method(audio_manager, "setMode", "(I)V", &[JValue::Int(mode)])
.map_err(|e| AudioModeError::MethodCallFailed {
method: "setMode",
detail: e.to_string(),
})?;
Ok(())
})
}
// ---------------------------------------------------------------------------
// SDD-120 §3 bench seam.
//
// Exposes a minimal factory for `CaptureState` plus a thin `ingest_f32`
// shim so the criterion bench harness in `crates/chanora_audio/benches/`
// can drive the same realtime capture code path the production cpal
// callback uses, without re-implementing CaptureState in the bench file.
// Marked `#[doc(hidden)]` so the public API surface is unaffected; this
// is not a supported external API. Only compiled on cpal-capture targets
// because Apple and Android use native voice backends instead.
// ---------------------------------------------------------------------------
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
#[doc(hidden)]
pub mod bench_seam {
use super::{Arc, AtomicBool, AtomicU32, CaptureState, OutPacket};
use tokio::sync::mpsc;
/// Opaque handle wrapping a CaptureState plus the dummy mpsc
/// receiver that prevents the channel sender from erroring out
/// when the bench drives `ingest`. The receiver is held inside
/// the handle so it lives for the bench's lifetime.
pub struct CaptureBenchHandle {
state: CaptureState,
// Keep the receiver alive so sends from CaptureState::encode_and_send
// do not fail; the bench discards what would be transmitted.
_rx: mpsc::Receiver<OutPacket>,
transmit_active: Arc<AtomicBool>,
}
impl CaptureBenchHandle {
/// Construct a CaptureState wired to a private mpsc + a
/// pre-asserted transmit-active flag so `ingest` exercises
/// the full down-mix → resample → encode → send pipeline.
///
/// `in_sample_rate` selects the input rate (48000 for the
/// passthrough path, 44100 / 16000 to exercise the linear
/// resampler). `in_channels` selects the channel layout
/// (typically 1 or 2).
pub fn new(in_sample_rate: u32, in_channels: usize) -> Self {
let encoder =
crate::opus_voice::new_voip_encoder("cpal bench").expect("opus encoder init");
let (tx, rx) = mpsc::channel::<OutPacket>(64);
let transmit_active = Arc::new(AtomicBool::new(true));
let frames_sent = Arc::new(AtomicU32::new(0));
let state = CaptureState::new(
encoder,
in_sample_rate,
in_channels,
1.0,
tx,
transmit_active.clone(),
frames_sent,
);
Self {
state,
_rx: rx,
transmit_active,
}
}
/// Drive one cpal-callback-equivalent buffer through the
/// realtime capture pipeline.
#[inline]
pub fn ingest_f32(&mut self, buf: &[f32]) {
self.state.ingest(buf);
}
/// Set the PTT gate. Defaults to true (bench measures the
/// transmitting path).
pub fn set_transmit_active(&self, active: bool) {
self.transmit_active
.store(active, std::sync::atomic::Ordering::Relaxed);
}
}
}