Files
chanora/crates/chanora_audio/src/engine.rs
T
EdisonJwa 78190c0694 feat(audio,android): wire engine + AudioManager JNI through ModeStack (SDD-108 §2)
Replace the prior one-shot android_engage_voice_communication call
with a ModeStack-mediated acquire/release pair. AudioEngine snapshots
the system audio mode on first acquire via android_get_audio_mode()
and restores it on last release via android_set_audio_mode(prior).
MODE_IN_COMMUNICATION (3) is engaged across the voice-session lifetime
per SDD-108.

Includes the Android AudioManager getMode/setMode JNI helpers
(placed in chanora_audio::engine alongside the existing JNI surface)
and the small ptt.rs touch needed for the SDD-108 ID-tag on the
existing tests.

Trace: SDD-108, SDD-115.
2026-05-18 10:53:18 +08:00

1754 lines
74 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Audio engine — owns the cpal input/output streams, 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::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
#[cfg(not(target_os = "ios"))]
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
#[cfg(not(target_os = "ios"))]
use cpal::{SampleFormat, SizedSample};
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(not(target_os = "ios"))]
use tracing::{error, warn};
#[cfg(not(target_os = "ios"))]
use audiopus::coder::Encoder as OpusEncoder;
#[cfg(not(target_os = "ios"))]
use audiopus::{
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
SampleRate as OpusSampleRate,
};
use tsclientlib::audio::AudioHandler;
// `AudioData`, `CodecType`, `OutAudio` are referenced only by the
// cpal capture pipeline's Opus encode path (`CaptureState::encode_and_send`).
// `InboundVoice` + `OutPacket` are used by every platform — the
// inbound forwarder task pumps `InboundVoice` into AudioHandler on
// iOS too, and `OutPacket` flows out of the capture pipeline once
// commit 3 lands. Cfg-gate the cpal-only ones to keep iOS warnings
// clean.
#[cfg(not(target_os = "ios"))]
use chanora_protocol::{AudioData, CodecType, OutAudio};
use chanora_protocol::{InboundVoice, OutPacket};
use crate::AudioError;
/// 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.
/// These constants are framing invariants of the engine and are
/// referenced from per-platform helpers (`try_open_capture` and
/// the CaptureState on cpal platforms; `ios_voice_unit` on iOS once
/// commits 3+4 land). The `allow(dead_code)` is here because in
/// the current commit the iOS VPIO callbacks are still no-op stubs
/// and don't reach these constants yet — they will in commit 3
/// when the input callback wires into CaptureState.
#[allow(dead_code)]
const SAMPLE_RATE: u32 = 48_000;
#[allow(dead_code)]
const FRAME_SAMPLES: usize = 48_000 / 50; // 960
#[allow(dead_code)]
const MAX_OPUS_FRAME: usize = 1275;
/// Engine configuration.
#[derive(Debug, 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.
///
/// Beta status: the *config flag* is plumbed through every
/// layer; the *Android-side preset switch* is documented but
/// not yet wired through cpal, which currently uses the
/// AAudio default input. RISK-AUDIO-MOBILE-001 tracks this gap.
/// Setting `true` is a forward-compatible no-op for Beta and
/// will become active once cpal exposes input-preset hooks (or
/// when Chanora ships an Oboe-based fork).
pub mobile_voice_preset: bool,
}
impl Default for AudioEngineConfig {
fn default() -> Self {
Self {
mic_gain: 1.0,
ptt_initial: false,
effects: crate::AudioEffects::default(),
mobile_voice_preset: true,
}
}
}
/// 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>,
// Streams must be dropped to stop audio. Both are `!Send` because
// cpal's Stream isn't Send on some backends; we keep them in an
// Option wrapped by Mutex so stop() can move them out. On Linux
// the output side is `crate::sdl_output::SdlOutput` instead of a
// cpal Stream (see the SDD note inside `sdl_output.rs`); the
// same unsafe Send/Sync impl below covers both. On iOS both
// sides collapse into a single `IosVoiceUnit` (one
// VoiceProcessingIO AudioUnit hosts mic + speaker) — cpal is
// unused on iOS for the reasons documented in
// `ios_voice_unit.rs`.
#[cfg(not(target_os = "ios"))]
_input_stream: Mutex<Option<cpal::Stream>>,
#[cfg(target_os = "linux")]
_output_stream: Mutex<Option<crate::sdl_output::SdlOutput>>,
#[cfg(all(not(target_os = "linux"), not(target_os = "ios")))]
_output_stream: Mutex<Option<cpal::Stream>>,
#[cfg(target_os = "ios")]
_ios_voice_unit: Mutex<Option<crate::ios_voice_unit::IosVoiceUnit>>,
/// SDD-111..SDD-115: Android voice backend held parallel to the
/// cpal streams. Owns the 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. The cpal capture/playback pair continues to
/// carry voice frames for the transitional period — replacing
/// the data path with the Oboe streams is a follow-up.
#[cfg(target_os = "android")]
_android_voice_unit: Mutex<Option<crate::android_voice_unit::AndroidVoiceUnit>>,
/// 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: 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,
/// Missed-key-up watchdog (SDD-092). Dropping aborts the task.
/// The watchdog is independent of the PTT input backend — it
/// observes the gate directly. The platform input backend is
/// owned by `chanora_core::ptt::PttController` (SDD-088), not
/// by the engine.
///
/// In the post-rc.7 architecture this field is unused: the
/// missed-key-up watchdog now lives on the session and
/// subscribes to `TransmitModeSelector::subscribe_ptt_held`
/// rather than the gate. Watching the gate caused the watchdog
/// to fire in Continuous mode (where the gate is intentionally
/// pinned to `true`) which clearing surfaced as the bug
/// "Continuous transmission disabled after some time". The
/// field stays here as `None` for now to preserve the existing
/// engine-stop teardown flow; a follow-up commit can remove it
/// entirely.
ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog>,
}
// 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 {}
impl AudioEngine {
/// 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> {
// iOS routes to a separate backend (VoiceProcessingIO via
// coreaudio-rs) because cpal's iOS RemoteIO path produces
// mono-only output bound to a stale physical transducer
// (see `ios_voice_unit.rs` for the long version). Every
// other platform stays on the cpal / SDL flow below.
#[cfg(target_os = "ios")]
{
return Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate);
}
#[cfg(not(target_os = "ios"))]
{
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
}
/// Non-iOS implementation: cpal capture + (cpal | SDL2) output.
/// Kept as a separate function so the iOS path 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(target_os = "ios"))]
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"
);
let in_dev = host
.default_input_device()
.ok_or(AudioError::NoInputDevice)?;
let out_dev = host
.default_output_device()
.ok_or(AudioError::NoOutputDevice)?;
info!(
target: "chanora_audio",
in_device = %in_dev.description().map(|d| d.name().to_owned()).unwrap_or_default(),
out_device = %out_dev.description().map(|d| d.name().to_owned()).unwrap_or_default(),
"starting audio engine"
);
// 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.
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"
),
}
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"
),
}
// SDD-115 lifecycle sequencing on engine start. Forward order:
// 1) bridge -> engine receives voice_join (here we are
// already inside `start`, the engine-side trigger).
// 2) start the Android voice foreground service so that
// the platform records the microphone capture under
// `foregroundServiceType="microphone"` (SDD-107 + SRS-215).
// 3) open the AAudio voice streams (SDD-111 + SDD-112).
// 4) engage `MODE_IN_COMMUNICATION` (SDD-108).
// 5) bind hardware effects (SDD-113) — performed inside
// `AndroidVoiceUnit::open()` once the input stream has a
// session id.
// The reverse order on engine drop is enforced by the field
// drop order (`_android_voice_unit` is dropped before the
// engine returns; `close()` is invoked from its Drop impl).
#[cfg(target_os = "android")]
let mut audio_mode_stack = crate::mode_stack::ModeStack::new();
#[cfg(target_os = "android")]
let _android_voice_unit = {
if cfg.mobile_voice_preset {
// Step 2: foreground service.
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)"
);
}
// Step 4 (mode engage) BEFORE Step 5 (effect bind);
// hardware-effect routing only engages reliably under
// MODE_IN_COMMUNICATION (SDD-113 item 6 / SDD-115).
//
// SDD-108 §1/§2: route through `ModeStack` so the
// 0 → 1 transition snapshots the prior platform mode
// (via `android_get_audio_mode`) and only that
// transition writes `MODE_IN_COMMUNICATION` via
// `android_set_audio_mode`. P0 only ever observes
// refcount {0, 1} per SRS-189 but the composition
// model is in place for P1.
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) => {
// SDD-108 §5: setMode failed AFTER
// the 0 → 1 ModeStack transition.
// Roll the stack back so refcount
// returns to 0 and the snapshot is
// cleared; otherwise a future
// release would issue an
// unmatched setMode(prior) against
// a system that never had its mode
// changed by us.
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)"
),
}
// Step 3 + 5: open streams (SDD-111/112) and bind
// hardware effects (SDD-113). Failure here is logged
// and the engine continues with software AEC/NS/AGC
// via the existing engine path; the cpal data path
// remains the in-flight carrier.
//
// The prior silent-no-op log line at this site
// ("engagement depends on device AEC/NS support
// under MODE_IN_COMMUNICATION") is removed: the
// AndroidVoiceUnit either succeeds in engaging
// hardware effects (SDD-113) or logs the per-effect
// fallback, so engagement is now observable rather
// than rationalised.
let cfg_av = crate::mobile_voice_backend::AndroidVoiceStreamConfig {
effects: cfg.effects,
..Default::default()
};
match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av) {
Ok(mut unit) => {
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
if let Err(e) = unit.start() {
warn!(
target: "chanora_audio",
error = %e,
"android: AndroidVoiceUnit::start failed — cpal path remains active (SDD-115)"
);
}
Some(unit)
}
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
"android: AndroidVoiceUnit::open failed — software AEC/NS/AGC fallback engages (SDD-111/SDD-113)"
);
None
}
}
} else {
None
}
};
#[cfg(target_os = "ios")]
{
if cfg.mobile_voice_preset {
// iOS AVAudioSession configuration is performed
// Swift-side in `apps/chanora_flutter/ios/Runner/
// AppDelegate.swift::application(_:didFinishLaunching\
// WithOptions:)` BEFORE Flutter starts its audio
// pipeline. The category/mode set there
// (`.playAndRecord` + `.voiceChat`,
// `defaultToSpeaker | allowBluetooth |
// allowBluetoothA2DP`) is the recommended iOS shape
// for voice clients and engages on-device AEC / NS
// routing where supported. cpal's CoreAudio
// backend then opens its streams against that
// session and inherits the routing. 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.
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));
// ---------- 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.
let capture_result = try_open_capture(
&in_dev,
voice_out_tx,
transmit_flag_for_capture,
frames_sent.clone(),
cfg.mic_gain,
);
let (input_stream, capture_active) = match capture_result {
Ok(s) => (Some(s), true),
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
"capture stream unavailable; continuing with playback only"
);
(None, false)
}
};
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 SDL2 for output (Qint / upstream tsclientlib
// pattern). cpal's Linux backend opens raw ALSA which routes
// through `dmix`+`plug` and produces audible crackling /
// popping on the 48 kHz → device-rate step. SDL2 on the same
// box routes through PipeWire's PA bridge (or PulseAudio)
// whose resampler is high-quality. We keep cpal on Windows
// and macOS — both have native backends (WASAPI / CoreAudio)
// without this problem. See `crates/chanora_audio/src/sdl_output.rs`
// for the full rationale.
#[cfg(target_os = "linux")]
let output_stream = crate::sdl_output::SdlOutput::start(
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
)?;
#[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.
// * macOS (CoreAudio via cpal): the default period
// is fine and the OS picks a HAL-friendly size.
// * iOS (CoreAudio via cpal): RemoteIO units reject
// arbitrary buffer-size requests and surface them
// as `build_output_stream: The requested stream
// configuration is not supported by the device`.
// Must use BufferSize::Default.
#[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
};
// ---------- Inbound forwarder ----------
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.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 {
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
None => break,
}
}
}
}
});
// Select and arm the desktop PTT backend is no longer the
// engine's job (SDD-088). The PTT controller lives in
// `chanora_core::ptt::PttController`; the engine is
// responsible only for the cpal streams and the
// missed-key-up watchdog (SDD-092).
// The engine no longer spawns a watchdog against the
// gate (see comment on the `ptt_watchdog` field for the
// rationale). The session spawns the watchdog against the
// selector's `ptt_held` signal instead, so it never fires
// in Continuous mode.
let ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog> = None;
Ok(Self {
transmit_gate,
frames_sent,
frames_received,
output_gain,
output_muted,
_input_stream: Mutex::new(input_stream),
_output_stream: Mutex::new(Some(output_stream)),
#[cfg(target_os = "android")]
_android_voice_unit: Mutex::new(_android_voice_unit),
#[cfg(target_os = "android")]
audio_mode_stack: Mutex::new(audio_mode_stack),
shutdown_tx: Some(shutdown_tx),
capture_active,
ptt_watchdog,
})
}
/// iOS 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(target_os = "ios")]
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: iOS VoiceProcessingIO backend"
);
// iOS AVAudioSession 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_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
// Construct the VPIO unit. Commit 1 ships a no-op callback
// pair; commits 3 + 4 land the real capture + playback
// wiring. Construction failure here is fatal (mirrors how
// the cpal output-stream construction failure is fatal in
// the non-iOS path).
let ios_voice_unit = crate::ios_voice_unit::IosVoiceUnit::start(
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
voice_out_tx,
transmit_flag_for_capture,
frames_sent.clone(),
cfg.mic_gain,
)?;
// 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 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 {
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
None => break,
}
}
}
}
});
let ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog> = None;
Ok(Self {
transmit_gate,
frames_sent,
frames_received,
output_gain,
output_muted,
_ios_voice_unit: Mutex::new(Some(ios_voice_unit)),
shutdown_tx: Some(shutdown_tx),
capture_active,
ptt_watchdog,
})
}
/// Stop the engine. Idempotent.
pub fn stop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
// The platform PTT backend is no longer owned by the
// engine (SDD-088); its lifecycle is managed by
// `chanora_core::ptt::PttController`. The engine only
// needs to abort its watchdog and drop the audio streams.
// Aborting the watchdog cancels its tokio task.
self.ptt_watchdog.take();
// 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 cpal input + cpal/SDL output.
#[cfg(not(target_os = "ios"))]
{
let _ = self._input_stream.lock().unwrap().take();
let _ = self._output_stream.lock().unwrap().take();
}
#[cfg(target_os = "ios")]
{
let _ = self._ios_voice_unit.lock().unwrap().take();
}
// SDD-115 reverse-order teardown on Android:
// 1) close the voice unit (releases SDD-113 hardware
// effects then stops + closes the Oboe streams);
// 2) restore the prior audio mode (SDD-108 §1 on 1 → 0
// transition);
// 3) stop the foreground service.
#[cfg(target_os = "android")]
{
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(target_os = "ios")]
{
let mut guard = self._ios_voice_unit.lock().unwrap();
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
return unit.restart();
}
#[cfg(not(target_os = "ios"))]
{
Ok(())
}
}
/// iOS-only: pause the underlying VoiceProcessingIO unit.
pub fn ios_pause_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(target_os = "ios")]
{
let mut guard = self._ios_voice_unit.lock().unwrap();
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
return unit.pause();
}
#[cfg(not(target_os = "ios"))]
{
Ok(())
}
}
/// iOS-only: resume the underlying VoiceProcessingIO unit.
pub fn ios_resume_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(target_os = "ios")]
{
let mut guard = self._ios_voice_unit.lock().unwrap();
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
return unit.resume();
}
#[cfg(not(target_os = "ios"))]
{
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
}
/// Privacy-safe descriptor of the engine's PTT view. The
/// platform backend lives in `chanora_core::ptt::PttController`
/// (SDD-088); the engine itself no longer owns it. This getter
/// always returns the universal Focused fallback descriptor
/// and is retained only for legacy callers that constructed
/// engines directly without a controller (tests, headless
/// diagnostics).
pub fn ptt_descriptor(&self) -> crate::ptt::PttBackendDescriptor {
crate::ptt::PttBackendDescriptor::focused()
}
/// Legacy alias for [`Self::set_transmit_active`]. Retained so
/// the existing bridge `set_ptt` command and the existing
/// Flutter UI continue to compile during the v0.9.3 PTT
/// migration (SRS-201 splits the conceptual `ptt` flag into
/// `transmit_active` / `capture_active`).
#[doc(hidden)]
pub fn set_ptt(&self, active: bool) {
self.set_transmit_active(active);
}
/// Legacy alias for [`Self::transmit_active`].
#[doc(hidden)]
pub fn ptt(&self) -> bool {
self.transmit_active()
}
/// 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)
}
/// Latest Android voice-audio diagnostics snapshot (SDD-112 item
/// 10 / SDD-113 item 7 / SDD-116 item 3). On non-Android targets
/// this always returns `None`. On Android it 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.
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))
}
}
impl Drop for AudioEngine {
fn drop(&mut self) {
self.stop();
}
}
// ---------- Capture pipeline ----------
#[cfg(not(target_os = "ios"))]
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: same SDL2-vs-cpal split as the output path; we
// still use cpal for capture but leave Default since
// PipeWire's ALSA shim works well there.
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 mut opus_enc = OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
.map_err(|e| AudioError::Opus(format!("encoder new: {e}")))?;
// Opus VOIP tuning. Defaults give us 'auto' bitrate (can drop
// to ~6 kbps during silence \u2014 which sounds garbled when
// talking resumes) and inband FEC disabled. On lossy mobile
// networks (cellular / iPhone WiFi roaming), packet loss
// without FEC produces audible clicks + cut-out frames.
//
// Settings derived from the Opus IETF VoIP recommendations
// (RFC 6716 \u00a7 7.1) and Discord's voice client tuning:
//
// * Bitrate 32 kbps : sweet spot for mono voice. Lower
// than 24 kbps starts to sound watery; higher than
// 64 kbps wastes bandwidth without perceptual gain on a
// human voice. Discord uses 64 kbps; mumble defaults to
// 40 kbps; we pick 32 kbps as a conservative VoIP value
// that survives 100 kbps uplinks comfortably.
// * Complexity 10 : max quality. The CPU cost on a modern
// iPhone (A14+) or any desktop is negligible (~0.5 % of
// a single core for 48 kHz mono).
// * Inband FEC on : opus inserts a low-bitrate redundancy
// copy of the previous frame inside the current packet
// so a single dropped packet can be reconstructed from
// the next one. Essential on lossy mobile.
// * Packet loss perc 5 % : tells the encoder to expect 5 %
// loss and pre-emptively budget bits for FEC. Higher
// values trade audio quality for resilience.
//
// Errors here are non-fatal: log + continue. The encoder
// works with defaults if any setter fails on an exotic
// libopus build.
if let Err(e) = opus_enc.set_bitrate(OpusBitrate::BitsPerSecond(32_000)) {
warn!(target: "chanora_audio", error = %e, "opus: set_bitrate(32000) failed");
}
if let Err(e) = opus_enc.set_complexity(10) {
warn!(target: "chanora_audio", error = %e, "opus: set_complexity(10) failed");
}
if let Err(e) = opus_enc.set_inband_fec(true) {
warn!(target: "chanora_audio", error = %e, "opus: set_inband_fec(true) failed");
}
if let Err(e) = opus_enc.set_packet_loss_perc(5) {
warn!(target: "chanora_audio", error = %e, "opus: set_packet_loss_perc(5) failed");
}
info!(
target: "chanora_audio",
bitrate_bps = 32_000,
complexity = 10,
inband_fec = true,
packet_loss_perc = 5,
"opus encoder tuned for VoIP"
);
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(target_os = "ios"))]
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; 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>,
}
#[cfg(not(target_os = "ios"))]
impl CaptureState {
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; MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
frames_sent,
}
}
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
/// 48 kHz mono frames; encode and send when `transmit_active`
/// is true (PTT engaged).
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.
let mono: Vec<f32> = buf
.chunks(self.in_channels)
.map(|frame| {
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
(sum / frame.len() as f32) * self.mic_gain
})
.collect();
// 2. Resample to 48 kHz if needed.
if self.in_sample_rate == SAMPLE_RATE {
self.pcm_accum.extend_from_slice(&mono);
} else {
self.resample_into_accum(&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 {
let mut frame: Vec<f32> = self.pcm_accum.drain(..FRAME_SAMPLES).collect();
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) => {
let packet = OutAudio::new(&AudioData::C2S {
id: 0,
codec: CodecType::OpusVoice,
data: &self.opus_out[..len],
});
match self.voice_out_tx.try_send(packet) {
Ok(()) => {
self.frames_sent.fetch_add(1, Ordering::Relaxed);
}
Err(mpsc::error::TrySendError::Full(_)) => {
warn!(target: "chanora_audio", "voice_out queue full; dropping frame");
}
Err(mpsc::error::TrySendError::Closed(_)) => {
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(target_os = "ios"))]
trait ToF32 {
fn to_f32_sample(self) -> f32;
}
#[cfg(not(target_os = "ios"))]
impl ToF32 for f32 {
fn to_f32_sample(self) -> f32 {
self
}
}
#[cfg(not(target_os = "ios"))]
impl ToF32 for i16 {
fn to_f32_sample(self) -> f32 {
f32::from(self) / f32::from(i16::MAX)
}
}
#[cfg(not(target_os = "ios"))]
impl ToF32 for u16 {
fn to_f32_sample(self) -> f32 {
(f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX)
}
}
#[cfg(not(target_os = "ios"))]
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(target_os = "ios"))]
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(target_os = "ios"))]
struct PlaybackResampleState {
pos: f64,
last_l: f32,
last_r: f32,
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(target_os = "ios"))]
trait FromF32 {
fn from_f32_sample(v: f32) -> Self;
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(target_os = "ios"))]
impl FromF32 for f32 {
fn from_f32_sample(v: f32) -> Self {
v
}
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(target_os = "ios"))]
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(target_os = "ios"))]
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(())
})
}