2663 lines
109 KiB
Rust
2663 lines
109 KiB
Rust
//! 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);
|
||
}
|
||
}
|
||
}
|