chore: restore product scaffold to rollback baseline

This commit is contained in:
Edison Jwa
2026-05-29 14:02:04 +09:00
parent 2896f14ec9
commit fe6e07353e
434 changed files with 27278 additions and 63230 deletions
+82 -261
View File
@@ -5,35 +5,28 @@
//! 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")
@@ -110,7 +103,6 @@ pub struct AudioDeviceInfo {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -120,7 +112,6 @@ fn desktop_device_id(device: &cpal::Device) -> Option<String> {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -132,7 +123,6 @@ fn short_device_id(id: &str) -> String {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -188,53 +178,29 @@ pub fn list_audio_devices() -> AudioDeviceList {
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_in = host
.default_input_device()
.and_then(|device| desktop_device_id(&device));
let default_out = host
.default_output_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);
}
}
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);
}
}
}
@@ -340,23 +306,31 @@ pub struct AudioEngine {
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")]
#[cfg(any(target_os = "ios", target_os = "macos"))]
// 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(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
_input_stream: Mutex<Option<cpal::Stream>>,
#[cfg(target_os = "linux")]
_output_stream: Mutex<Option<crate::linux_pipewire_input::LinuxOutput>>,
_output_stream: Mutex<Option<crate::sdl_output::SdlOutput>>,
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
@@ -483,31 +457,6 @@ fn open_ios_voice_backend(
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(
@@ -743,7 +692,7 @@ impl AudioEngine {
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.
/// Non-Apple/non-Android implementation: cpal capture + (cpal | SDL2) output.
/// 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
@@ -762,16 +711,7 @@ impl AudioEngine {
"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,
@@ -795,30 +735,28 @@ impl AudioEngine {
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(),
);
)
.ok_or(AudioError::NoInputDevice)?;
#[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(),
);
)
.ok_or(AudioError::NoOutputDevice)?;
#[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)?;
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
@@ -830,29 +768,6 @@ impl AudioEngine {
// "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",
@@ -867,24 +782,6 @@ impl AudioEngine {
"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",
@@ -900,7 +797,6 @@ impl AudioEngine {
),
}
#[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));
@@ -908,7 +804,6 @@ impl AudioEngine {
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
@@ -916,52 +811,24 @@ impl AudioEngine {
// 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),
);
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, "<capture unavailable>".to_string())
(None, false)
}
};
#[cfg(target_os = "windows")]
if let Some(s) = &input_stream {
s.play()
.map_err(|e| AudioError::Backend(format!("input play: {e}")))?;
@@ -971,18 +838,21 @@ impl AudioEngine {
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.
// 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, 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(),
let output_stream = crate::sdl_output::SdlOutput::start(
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 = {
@@ -1057,20 +927,10 @@ impl AudioEngine {
.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 {
@@ -1087,11 +947,6 @@ impl AudioEngine {
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);
}
}
@@ -1111,10 +966,6 @@ impl AudioEngine {
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),
@@ -1140,7 +991,6 @@ impl AudioEngine {
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()));
@@ -1287,7 +1137,6 @@ impl AudioEngine {
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 {
@@ -1304,11 +1153,6 @@ impl AudioEngine {
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);
}
}
@@ -1328,7 +1172,6 @@ impl AudioEngine {
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,
@@ -1381,7 +1224,6 @@ impl AudioEngine {
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()));
@@ -1416,7 +1258,6 @@ impl AudioEngine {
// 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 {
@@ -1433,11 +1274,6 @@ impl AudioEngine {
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);
}
}
@@ -1457,7 +1293,6 @@ impl AudioEngine {
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,
@@ -1477,8 +1312,12 @@ impl AudioEngine {
// 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"))]
// platform has separate cpal input + cpal/SDL output.
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
{
let _ = self._input_stream.lock().unwrap().take();
let _ = self._output_stream.lock().unwrap().take();
@@ -1774,11 +1613,11 @@ impl AudioEngine {
}
/// Latest Android voice-audio diagnostics snapshot (SDD-112 item
/// 10 / SDD-113 item 7 / SDD-116 item 3). Returns `Some(...)`
/// 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.
#[cfg(target_os = "android")]
/// 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> {
@@ -1830,25 +1669,6 @@ impl AudioEngine {
);
}
}
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"
);
}
}
}
}
@@ -1860,7 +1680,7 @@ impl Drop for AudioEngine {
// ---------- Capture pipeline ----------
#[cfg(target_os = "windows")]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn try_open_capture(
in_dev: &cpal::Device,
voice_out_tx: mpsc::Sender<OutPacket>,
@@ -1879,8 +1699,9 @@ fn try_open_capture(
// 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.
// * 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")]
{
@@ -1917,7 +1738,7 @@ fn try_open_capture(
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
pub(crate) struct CaptureState {
struct CaptureState {
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
@@ -1956,7 +1777,7 @@ pub(crate) struct CaptureState {
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl CaptureState {
pub(crate) fn new(
fn new(
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
@@ -1989,7 +1810,7 @@ impl CaptureState {
/// 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]) {
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();
@@ -2138,7 +1959,7 @@ impl CaptureState {
/// 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 {
trait ToF32 {
fn to_f32_sample(self) -> f32;
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
@@ -2160,7 +1981,7 @@ impl ToF32 for u16 {
}
}
#[cfg(target_os = "windows")]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn build_input_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
+15 -11
View File
@@ -25,7 +25,7 @@
//! stream-format requests on bus 0 (output) and bus 1 (input).
//!
//! This file replaces the cpal capture + playback streams on iOS and macOS.
//! Linux uses native PipeWire/PulseAudio voice I/O;
//! Linux uses SDL2 for output (see `sdl_output.rs`) and cpal for capture;
//! Windows uses cpal's WASAPI backend.
//!
//! ## What VPIO gives us
@@ -53,7 +53,7 @@
//! thread that owns the unit. We open the unit on the same thread
//! that calls `Self::start` (the tokio worker that runs
//! `chanora_core::ChanoraSession::start_audio`, the same pattern
//! the desktop backends use) and never move it. The outer `AudioEngine`
//! cpal + SDL use) and never move it. The outer `AudioEngine`
//! already carries an `unsafe impl Send` to satisfy the same
//! constraint for cpal's `!Send` Stream type; that impl covers
//! VPIO too.
@@ -589,16 +589,16 @@ impl IosVoiceUnit {
/// captures samples, render callback fires when the hardware
/// needs samples to play.
///
/// Parameters mirror the capture + playback inputs the desktop
/// backends accept so engine.rs can swap backends with
/// Parameters mirror the capture + playback inputs the cpal
/// and SDL backends accept so engine.rs can swap backends with
/// a `cfg`.
///
/// * `handler` — shared AudioHandler the inbound forwarder
/// feeds Opus packets into. The output render callback pulls
/// decoded f32 frames from it (48 kHz stereo) and downmixes
/// to the i16 mono buffer VPIO expects.
/// * `output_gain` / `output_muted` — same atomics the desktop
/// output paths read on every callback so the master
/// * `output_gain` / `output_muted` — same atomics the cpal
/// and SDL output paths read on every callback so the master
/// volume + local-mute UI works identically across backends.
/// * `voice_out_tx` — channel the capture pipeline sends
/// encoded `OutPacket`s on.
@@ -734,7 +734,7 @@ impl IosVoiceUnit {
// 1. Lock the AudioHandler, ask it to fill a scratch
// f32 stereo buffer (length = 2 * num_frames). The
// handler runs Opus decode + per-client jitter
// buffer + mix. Same primitive desktop output
// buffer + mix. Same primitive cpal + SDL output
// paths use; this is the platform-neutral playback
// contract from `tsclientlib::audio::AudioHandler`.
// 2. Downmix to i16 mono with master gain. VPIO expects
@@ -748,10 +748,12 @@ impl IosVoiceUnit {
// AudioHandler in step 1 so its jitter buffer
// doesn't grow unbounded while muted. This is the
// contract every other backend follows (matches
// Linux PipeWire/PulseAudio and the cpal output stream).
// SdlOutput::callback and the cpal output stream).
//
// Build the playback pipeline (direct fill_buffer in
// render callback; matches the desktop voice backends).
// render callback; matches tsclientlib's reference SDL
// example at
// tsclientlib/examples/audio_utils/ts_to_audio.rs).
//
// The earlier ring-buffer attempt (rc.8+73..+74) decoupled
// AudioHandler from the render callback via a 50 Hz
@@ -764,12 +766,14 @@ impl IosVoiceUnit {
// consumer pull being larger, the ring averaged out empty
// — fill_buffer was returning silence 65-84% of ticks
// because we drained it too aggressively before packets
// arrived. Direct desktop callbacks follow the same pattern.
// arrived. Linux/SDL's same pattern works fine because
// SDL calls fill_buffer at exactly the device callback
// rate.
//
// Revert to direct call: render callback locks
// AudioHandler, asks for `num_frames` stereo frames, and
// immediately downmixes to i16 mono into the output
// buffer. Same as desktop output, just stereo-f32 -> mono-i16
// buffer. Same as Linux/SDL, just stereo-f32 -> mono-i16
// converted at the boundary.
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048);
let handler_for_render = params.handler.clone();
+2 -4
View File
@@ -6,7 +6,7 @@
//! ## What's wired in this Beta
//!
//! * Default-input capture via platform audio backends (Oboe on Android,
//! VoiceProcessingIO on Apple platforms, PipeWire/PulseAudio on Linux, cpal on Windows)
//! VoiceProcessingIO on Apple platforms, cpal/SDL elsewhere)
//! * Frame-aligned 20 ms / 48 kHz mono Opus encoding via `audiopus`
//! * Forward encoded frames to the protocol crate as `OutPacket`s
//! * Inbound voice packets fed to `tsclientlib::audio::AudioHandler`
@@ -32,7 +32,6 @@ pub mod audio_processing;
pub mod debug_wav;
mod engine;
pub mod frame;
#[cfg(any(target_os = "android", target_os = "ios", target_os = "macos"))]
pub mod mobile_voice_backend;
pub mod mode_stack;
pub(crate) mod opus_voice;
@@ -45,11 +44,10 @@ pub mod transmit_mode;
pub mod transmit_selector;
pub mod vad;
pub mod voice_activity;
#[cfg(any(target_os = "android", target_os = "ios", test))]
pub(crate) mod voice_render;
#[cfg(target_os = "linux")]
mod linux_pipewire_input;
mod sdl_output;
#[cfg(any(target_os = "ios", target_os = "macos"))]
mod ios_voice_unit;
@@ -1,967 +0,0 @@
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{mpsc as std_mpsc, Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use libpulse_binding as pulse;
use libpulse_simple_binding as psimple;
use pipewire as pw;
use pulse::callbacks::ListResult;
use pulse::context::{self, Context};
use pulse::mainloop::threaded::Mainloop;
use pulse::proplist::Proplist;
use pulse::sample::{Format, Spec};
use pulse::stream::Direction;
use pw::spa::pod::Pod;
use pw::{properties::properties, spa};
use tokio::sync::mpsc;
use tracing::{info, warn};
use tsclientlib::audio::AudioHandler;
use crate::engine::{AudioDeviceInfo, CaptureState, SessionAudioId};
use crate::{AudioError, AudioTransmitGate};
use chanora_protocol::OutPacket;
const INPUT_ID_PREFIX: &str = "pw-input:";
const OUTPUT_ID_PREFIX: &str = "pw-output:";
const PULSE_INPUT_ID_PREFIX: &str = "pulse-input:";
const PULSE_OUTPUT_ID_PREFIX: &str = "pulse-output:";
const SAMPLE_RATE: u32 = 48_000;
const CAPTURE_CHANNELS: u32 = 1;
const PLAYBACK_CHANNELS: u32 = 2;
const PLAYBACK_FRAMES: usize = 960;
#[derive(Clone, Debug)]
struct LinuxDevice {
backend: LinuxBackend,
direction: LinuxDirection,
raw_id: String,
label: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LinuxBackend {
PipeWire,
PulseAudio,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LinuxDirection {
Input,
Output,
}
#[derive(Clone, Debug)]
struct PipeWireDevice {
node_id: u32,
label: String,
}
impl LinuxDevice {
fn to_audio_device_info(&self, default_label: Option<&str>) -> AudioDeviceInfo {
let is_default = default_label.is_some_and(|label| label == self.label);
let (backend, id) = match (self.backend, self.direction) {
(LinuxBackend::PipeWire, LinuxDirection::Input) => (
"PipeWire",
input_device_id(self.raw_id.parse::<u32>().unwrap_or_default()),
),
(LinuxBackend::PipeWire, LinuxDirection::Output) => (
"PipeWire",
output_device_id(self.raw_id.parse::<u32>().unwrap_or_default()),
),
(LinuxBackend::PulseAudio, LinuxDirection::Input) => {
("PulseAudio", pulse_input_device_id(&self.raw_id))
}
(LinuxBackend::PulseAudio, LinuxDirection::Output) => {
("PulseAudio", pulse_output_device_id(&self.raw_id))
}
};
AudioDeviceInfo {
id,
name: self.label.clone(),
details: format!("{backend} · id={}", self.raw_id),
is_default,
}
}
}
pub(crate) fn input_device_id(node_id: u32) -> String {
format!("{INPUT_ID_PREFIX}{node_id}")
}
#[cfg(test)]
pub(crate) fn input_device_node_id_from_id(id: &str) -> Option<u32> {
id.strip_prefix(INPUT_ID_PREFIX)?.parse().ok()
}
pub(crate) fn output_device_id(node_id: u32) -> String {
format!("{OUTPUT_ID_PREFIX}{node_id}")
}
#[cfg(test)]
pub(crate) fn output_device_node_id_from_id(id: &str) -> Option<u32> {
id.strip_prefix(OUTPUT_ID_PREFIX)?.parse().ok()
}
fn pulse_input_device_id(name: &str) -> String {
format!("{PULSE_INPUT_ID_PREFIX}{name}")
}
fn pulse_output_device_id(name: &str) -> String {
format!("{PULSE_OUTPUT_ID_PREFIX}{name}")
}
#[cfg(test)]
fn pulse_input_name_from_id(id: &str) -> Option<&str> {
id.strip_prefix(PULSE_INPUT_ID_PREFIX)
}
#[cfg(test)]
fn pulse_output_name_from_id(id: &str) -> Option<&str> {
id.strip_prefix(PULSE_OUTPUT_ID_PREFIX)
}
pub(crate) fn list_input_devices(default_label: Option<&str>) -> Vec<AudioDeviceInfo> {
list_devices(LinuxDirection::Input)
.iter()
.map(|device| device.to_audio_device_info(default_label))
.collect()
}
pub(crate) fn list_output_devices(default_label: Option<&str>) -> Vec<AudioDeviceInfo> {
list_devices(LinuxDirection::Output)
.iter()
.map(|device| device.to_audio_device_info(default_label))
.collect()
}
pub(crate) fn start_capture(
prefer: Option<&str>,
default_label: Option<&str>,
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_gate: AudioTransmitGate,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
) -> Result<(LinuxInput, AudioDeviceInfo), AudioError> {
let devices = list_devices(LinuxDirection::Input);
let device = select_device(&devices, prefer, default_label).ok_or_else(|| {
AudioError::Backend("Linux audio did not expose a usable capture source".to_string())
})?;
let info = device.to_audio_device_info(default_label);
let encoder = crate::opus_voice::new_voip_encoder("pipewire capture")?;
let capture_state = Arc::new(Mutex::new(CaptureState::new(
encoder,
SAMPLE_RATE,
CAPTURE_CHANNELS as usize,
mic_gain,
voice_out_tx,
transmit_gate.flag_arc(),
frames_sent,
)));
let stream = match device.backend {
LinuxBackend::PipeWire => LinuxInput::PipeWire(PipeWireInput::start(
PipeWireDevice {
node_id: device.raw_id.parse().map_err(|_| {
AudioError::Backend(format!("invalid PipeWire source id `{}`", device.raw_id))
})?,
label: device.label.clone(),
},
capture_state,
)?),
LinuxBackend::PulseAudio => LinuxInput::PulseAudio(PulseAudioInput::start(
device.raw_id.clone(),
capture_state,
)?),
};
Ok((stream, info))
}
pub(crate) fn start_output(
prefer: Option<&str>,
default_label: Option<&str>,
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
) -> Result<(LinuxOutput, AudioDeviceInfo), AudioError> {
let devices = list_devices(LinuxDirection::Output);
let device = select_device(&devices, prefer, default_label).ok_or_else(|| {
AudioError::Backend("Linux audio did not expose a usable playback sink".to_string())
})?;
let info = device.to_audio_device_info(default_label);
let stream = match device.backend {
LinuxBackend::PipeWire => LinuxOutput::PipeWire(PipeWireOutput::start(
PipeWireDevice {
node_id: device.raw_id.parse().map_err(|_| {
AudioError::Backend(format!("invalid PipeWire sink id `{}`", device.raw_id))
})?,
label: device.label.clone(),
},
handler,
output_gain,
output_muted,
)?),
LinuxBackend::PulseAudio => LinuxOutput::PulseAudio(PulseAudioOutput::start(
device.raw_id.clone(),
handler,
output_gain,
output_muted,
)?),
};
Ok((stream, info))
}
fn select_device(
devices: &[LinuxDevice],
prefer: Option<&str>,
default_label: Option<&str>,
) -> Option<LinuxDevice> {
if devices.is_empty() {
return None;
}
if let Some(prefer) = prefer {
if let Some(device) = devices
.iter()
.find(|device| device.to_audio_device_info(default_label).id == prefer)
{
return Some(device.clone());
}
}
if let Some(default_label) = default_label {
if let Some(device) = devices.iter().find(|device| device.label == default_label) {
return Some(device.clone());
}
}
devices.first().cloned()
}
fn list_devices(direction: LinuxDirection) -> Vec<LinuxDevice> {
match list_pipewire_nodes(direction) {
Ok(pipewire) if !pipewire.is_empty() => return pipewire,
Ok(_) => {
warn!(target: "chanora_audio", "PipeWire device enumeration returned no devices; trying PulseAudio fallback")
}
Err(error) => {
warn!(target: "chanora_audio", error = %error, "PipeWire device enumeration failed")
}
}
match list_pulse_devices(direction) {
Ok(pulse) => pulse,
Err(error) => {
warn!(target: "chanora_audio", error = %error, "PulseAudio device enumeration failed");
Vec::new()
}
}
}
fn list_pipewire_nodes(direction: LinuxDirection) -> Result<Vec<LinuxDevice>, String> {
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
let context = pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
let registry = core.get_registry().map_err(|err| err.to_string())?;
let done = Rc::new(Cell::new(false));
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
let class_prefix = match direction {
LinuxDirection::Input => "Audio/Source",
LinuxDirection::Output => "Audio/Sink",
};
let devices_for_registry = devices.clone();
let _registry_listener = registry
.add_listener_local()
.global(move |global| {
if global.type_ != pw::types::ObjectType::Node {
return;
}
let Some(props) = global.props else {
return;
};
let Some(media_class) = props.get(*pw::keys::MEDIA_CLASS) else {
return;
};
if !media_class.starts_with(class_prefix) {
return;
}
let label = props
.get(*pw::keys::NODE_DESCRIPTION)
.or_else(|| props.get(*pw::keys::NODE_NICK))
.or_else(|| props.get(*pw::keys::NODE_NAME))
.unwrap_or("Unnamed PipeWire node")
.to_string();
devices_for_registry.borrow_mut().push(LinuxDevice {
backend: LinuxBackend::PipeWire,
direction,
raw_id: global.id.to_string(),
label,
});
})
.register();
let pending = core.sync(0).map_err(|err| err.to_string())?;
let done_for_core = done.clone();
let loop_for_core = mainloop.clone();
let _core_listener = core
.add_listener_local()
.done(move |id, seq| {
if id == pw::core::PW_ID_CORE && seq == pending {
done_for_core.set(true);
loop_for_core.quit();
}
})
.error(move |_id, _seq, _res, message| {
warn!(
target: "chanora_audio",
message,
"PipeWire core error during input enumeration"
);
})
.register();
while !done.get() {
mainloop.run();
}
let mut devices = devices.borrow().clone();
devices.sort_by(|left, right| {
left.label
.cmp(&right.label)
.then_with(|| left.raw_id.cmp(&right.raw_id))
});
Ok(devices)
}
fn list_pulse_devices(direction: LinuxDirection) -> Result<Vec<LinuxDevice>, String> {
with_pulse_context(|context, mainloop| {
let done = Rc::new(Cell::new(false));
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
let done_for_cb = done.clone();
let devices_for_cb = devices.clone();
let mainloop_for_cb = mainloop.clone();
let introspector = context.borrow().introspect();
match direction {
LinuxDirection::Input => {
let _operation = introspector.get_source_info_list(move |result| match result {
ListResult::Item(info) => {
if info.monitor_of_sink.is_some() {
return;
}
let Some(name) = info.name.as_ref() else {
return;
};
let label = info
.description
.as_ref()
.map(|value| value.to_string())
.unwrap_or_else(|| name.to_string());
devices_for_cb.borrow_mut().push(LinuxDevice {
backend: LinuxBackend::PulseAudio,
direction,
raw_id: name.to_string(),
label,
});
}
ListResult::End | ListResult::Error => {
done_for_cb.set(true);
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
}
});
while !done.get() {
mainloop.borrow_mut().wait();
}
}
LinuxDirection::Output => {
let _operation = introspector.get_sink_info_list(move |result| match result {
ListResult::Item(info) => {
let Some(name) = info.name.as_ref() else {
return;
};
let label = info
.description
.as_ref()
.map(|value| value.to_string())
.unwrap_or_else(|| name.to_string());
devices_for_cb.borrow_mut().push(LinuxDevice {
backend: LinuxBackend::PulseAudio,
direction,
raw_id: name.to_string(),
label,
});
}
ListResult::End | ListResult::Error => {
done_for_cb.set(true);
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
}
});
while !done.get() {
mainloop.borrow_mut().wait();
}
}
}
let mut devices = devices.borrow().clone();
devices.sort_by(|left, right| {
left.label
.cmp(&right.label)
.then_with(|| left.raw_id.cmp(&right.raw_id))
});
Ok(devices)
})
}
fn with_pulse_context<T, F>(operation: F) -> Result<T, String>
where
F: FnOnce(&Rc<RefCell<Context>>, &Rc<RefCell<Mainloop>>) -> Result<T, String>,
{
let proplist = build_pulse_proplist()?;
let mainloop = Rc::new(RefCell::new(
Mainloop::new().ok_or("failed to create PulseAudio mainloop")?,
));
let context = Rc::new(RefCell::new(
Context::new_with_proplist(&*mainloop.borrow(), "chanora", &proplist)
.ok_or("failed to create PulseAudio context")?,
));
let mainloop_for_cb = mainloop.clone();
let context_for_cb = context.clone();
context
.borrow_mut()
.set_state_callback(Some(Box::new(move || {
let state = unsafe { (*context_for_cb.as_ptr()).get_state() };
match state {
context::State::Ready | context::State::Failed | context::State::Terminated => {
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
}
_ => {}
}
})));
context
.borrow_mut()
.connect(None, context::FlagSet::NOFLAGS, None)
.map_err(pa_error)?;
mainloop.borrow_mut().lock();
mainloop.borrow_mut().start().map_err(pa_error)?;
loop {
match context.borrow().get_state() {
context::State::Ready => break,
context::State::Failed | context::State::Terminated => {
mainloop.borrow_mut().unlock();
mainloop.borrow_mut().stop();
return Err(format!(
"PulseAudio context state {:?}",
context.borrow().get_state()
));
}
_ => mainloop.borrow_mut().wait(),
}
}
context.borrow_mut().set_state_callback(None);
let result = operation(&context, &mainloop);
mainloop.borrow_mut().unlock();
mainloop.borrow_mut().stop();
result
}
fn build_pulse_proplist() -> Result<Proplist, String> {
let mut proplist = Proplist::new().ok_or("failed to create PulseAudio proplist")?;
proplist
.set_str(pulse::proplist::properties::APPLICATION_NAME, "chanora")
.map_err(|_| "failed to set PulseAudio application name".to_string())?;
Ok(proplist)
}
fn pulse_capture_spec() -> Spec {
Spec {
format: Format::F32le,
channels: CAPTURE_CHANNELS as u8,
rate: SAMPLE_RATE,
}
}
fn pulse_playback_spec() -> Spec {
Spec {
format: Format::F32le,
channels: PLAYBACK_CHANNELS as u8,
rate: SAMPLE_RATE,
}
}
fn pa_error(err: pulse::error::PAErr) -> String {
err.to_string()
.unwrap_or_else(|| format!("PulseAudio error {err:?}"))
}
fn wait_for_startup(
ready_rx: std_mpsc::Receiver<Result<(), String>>,
handle: JoinHandle<Result<(), String>>,
label: &str,
) -> Result<JoinHandle<Result<(), String>>, AudioError> {
match ready_rx.recv() {
Ok(Ok(())) => Ok(handle),
Ok(Err(err)) => {
let _ = handle.join();
Err(AudioError::Backend(err))
}
Err(err) => {
let _ = handle.join();
Err(AudioError::Backend(format!(
"{label} startup failed: {err}"
)))
}
}
}
pub(crate) enum LinuxInput {
#[allow(dead_code)]
PipeWire(PipeWireInput),
#[allow(dead_code)]
PulseAudio(PulseAudioInput),
}
pub(crate) enum LinuxOutput {
#[allow(dead_code)]
PipeWire(PipeWireOutput),
#[allow(dead_code)]
PulseAudio(PulseAudioOutput),
}
pub(crate) struct PipeWireInput {
device: PipeWireDevice,
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<Result<(), String>>>,
}
impl PipeWireInput {
fn start(
device: PipeWireDevice,
capture_state: Arc<Mutex<CaptureState>>,
) -> Result<Self, AudioError> {
let stop = Arc::new(AtomicBool::new(false));
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
let stop_for_thread = stop.clone();
let device_for_thread = device.clone();
let handle = thread::spawn(move || {
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
let context =
pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Communication",
};
let target = device_for_thread.node_id.to_string();
props.insert(*pw::keys::TARGET_OBJECT, target.as_str());
let stream = pw::stream::StreamBox::new(&core, "chanora-pipewire-capture", props)
.map_err(|err| err.to_string())?;
let _listener = stream
.add_local_listener_with_user_data(capture_state)
.process(|stream, state| {
if let Some(mut buffer) = stream.dequeue_buffer() {
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data_ref = &mut datas[0];
let byte_count = data_ref.chunk().size() as usize;
let Some(bytes) = data_ref.data() else {
return;
};
let byte_count = byte_count.min(bytes.len());
if byte_count < 4 {
return;
}
let mut scratch = Vec::with_capacity(byte_count / 4);
for chunk in bytes[..byte_count].chunks_exact(4) {
scratch
.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
state.lock().unwrap().ingest(&scratch);
}
})
.register()
.map_err(|err| err.to_string())?;
let values = build_format_param()?;
let mut params =
[Pod::from_bytes(&values).ok_or("invalid PipeWire capture format pod")?];
stream
.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|err| err.to_string())?;
let _ = ready_tx.send(Ok(()));
while !stop_for_thread.load(Ordering::SeqCst) {
let _ = mainloop
.loop_()
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(100)));
}
mainloop.quit();
Ok(())
});
let handle = wait_for_startup(ready_rx, handle, "PipeWire capture")?;
info!(target: "chanora_audio", device = %device.label, node_id = device.node_id, "PipeWire capture backend initialised");
Ok(Self {
device,
stop,
handle: Some(handle),
})
}
}
pub(crate) struct PipeWireOutput {
device: PipeWireDevice,
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<Result<(), String>>>,
}
impl PipeWireOutput {
fn start(
device: PipeWireDevice,
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
) -> Result<Self, AudioError> {
let stop = Arc::new(AtomicBool::new(false));
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
let stop_for_thread = stop.clone();
let device_for_thread = device.clone();
let handle = thread::spawn(move || {
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
let context =
pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback",
*pw::keys::MEDIA_ROLE => "Communication",
};
let target = device_for_thread.node_id.to_string();
props.insert(*pw::keys::TARGET_OBJECT, target.as_str());
let stream = pw::stream::StreamBox::new(&core, "chanora-pipewire-playback", props)
.map_err(|err| err.to_string())?;
let _listener = stream
.add_local_listener_with_user_data((handler, output_gain, output_muted))
.process(|stream, state| {
if let Some(mut buffer) = stream.dequeue_buffer() {
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data_ref = &mut datas[0];
let byte_len = {
let Some(bytes) = data_ref.data() else {
return;
};
let samples = bytes.len() / std::mem::size_of::<f32>();
let mut scratch = vec![0.0_f32; samples];
if let Ok(mut handler) = state.0.lock() {
handler.fill_buffer(&mut scratch);
}
apply_output_controls(&mut scratch, &state.1, &state.2);
for (chunk, sample) in
bytes.chunks_exact_mut(4).zip(scratch.into_iter())
{
chunk.copy_from_slice(&sample.to_le_bytes());
}
bytes.len()
};
let chunk = data_ref.chunk_mut();
*chunk.offset_mut() = 0;
*chunk.stride_mut() =
(PLAYBACK_CHANNELS * std::mem::size_of::<f32>() as u32) as i32;
*chunk.size_mut() = byte_len as u32;
}
})
.register()
.map_err(|err| err.to_string())?;
let values = build_playback_format_param()?;
let mut params =
[Pod::from_bytes(&values).ok_or("invalid PipeWire playback format pod")?];
stream
.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|err| err.to_string())?;
let _ = ready_tx.send(Ok(()));
while !stop_for_thread.load(Ordering::SeqCst) {
let _ = mainloop
.loop_()
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(100)));
}
mainloop.quit();
Ok(())
});
let handle = wait_for_startup(ready_rx, handle, "PipeWire playback")?;
info!(target: "chanora_audio", device = %device.label, node_id = device.node_id, "PipeWire playback backend initialised");
Ok(Self {
device,
stop,
handle: Some(handle),
})
}
}
fn apply_output_controls(samples: &mut [f32], output_gain: &AtomicU32, output_muted: &AtomicBool) {
if output_muted.load(Ordering::Relaxed) {
samples.fill(0.0);
return;
}
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
if (gain - 1.0).abs() > f32::EPSILON {
for sample in samples.iter_mut() {
*sample *= gain;
}
}
}
pub(crate) struct PulseAudioInput {
device_name: String,
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<Result<(), String>>>,
}
impl PulseAudioInput {
fn start(
device_name: String,
capture_state: Arc<Mutex<CaptureState>>,
) -> Result<Self, AudioError> {
let stop = Arc::new(AtomicBool::new(false));
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
let stop_for_thread = stop.clone();
let device_for_thread = device_name.clone();
let handle = thread::spawn(move || {
let spec = pulse_capture_spec();
let stream = psimple::Simple::new(
None,
"chanora",
Direction::Record,
Some(device_for_thread.as_str()),
"chanora-record",
&spec,
None,
None,
)
.map_err(pa_error)?;
let _ = ready_tx.send(Ok(()));
let mut bytes =
vec![
0_u8;
PLAYBACK_FRAMES * CAPTURE_CHANNELS as usize * std::mem::size_of::<f32>()
];
while !stop_for_thread.load(Ordering::SeqCst) {
stream.read(&mut bytes).map_err(pa_error)?;
let mut scratch = Vec::with_capacity(bytes.len() / 4);
for chunk in bytes.chunks_exact(4) {
scratch.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
capture_state.lock().unwrap().ingest(&scratch);
}
stream.flush().map_err(pa_error)?;
Ok(())
});
let handle = wait_for_startup(ready_rx, handle, "PulseAudio capture")?;
info!(target: "chanora_audio", device = %device_name, "PulseAudio capture backend initialised");
Ok(Self {
device_name,
stop,
handle: Some(handle),
})
}
}
pub(crate) struct PulseAudioOutput {
device_name: String,
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<Result<(), String>>>,
}
impl PulseAudioOutput {
fn start(
device_name: String,
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
) -> Result<Self, AudioError> {
let stop = Arc::new(AtomicBool::new(false));
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
let stop_for_thread = stop.clone();
let device_for_thread = device_name.clone();
let handle = thread::spawn(move || {
let spec = pulse_playback_spec();
let stream = psimple::Simple::new(
None,
"chanora",
Direction::Playback,
Some(device_for_thread.as_str()),
"chanora-playback",
&spec,
None,
None,
)
.map_err(pa_error)?;
let _ = ready_tx.send(Ok(()));
let mut samples = vec![0.0_f32; PLAYBACK_FRAMES * PLAYBACK_CHANNELS as usize];
let mut bytes = vec![0_u8; samples.len() * std::mem::size_of::<f32>()];
while !stop_for_thread.load(Ordering::SeqCst) {
samples.fill(0.0);
if let Ok(mut handler) = handler.lock() {
handler.fill_buffer(&mut samples);
}
apply_output_controls(&mut samples, &output_gain, &output_muted);
for (chunk, sample) in bytes.chunks_exact_mut(4).zip(samples.iter().copied()) {
chunk.copy_from_slice(&sample.to_le_bytes());
}
stream.write(&bytes).map_err(pa_error)?;
}
stream.drain().map_err(pa_error)?;
Ok(())
});
let handle = wait_for_startup(ready_rx, handle, "PulseAudio playback")?;
info!(target: "chanora_audio", device = %device_name, "PulseAudio playback backend initialised");
Ok(Self {
device_name,
stop,
handle: Some(handle),
})
}
}
impl Drop for PipeWireInput {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
if let Err(_panic) = handle.join() {
warn!(
target: "chanora_audio",
device = %self.device.label,
node_id = self.device.node_id,
"PipeWire capture thread panicked during shutdown"
);
}
}
}
}
impl Drop for PipeWireOutput {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
if handle.join().is_err() {
warn!(target: "chanora_audio", device = %self.device.label, node_id = self.device.node_id, "PipeWire playback thread panicked during shutdown");
}
}
}
}
impl Drop for PulseAudioInput {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
if handle.join().is_err() {
warn!(target: "chanora_audio", device = %self.device_name, "PulseAudio capture thread panicked during shutdown");
}
}
}
}
impl Drop for PulseAudioOutput {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
if handle.join().is_err() {
warn!(target: "chanora_audio", device = %self.device_name, "PulseAudio playback thread panicked during shutdown");
}
}
}
}
fn build_format_param() -> Result<Vec<u8>, String> {
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
audio_info.set_rate(SAMPLE_RATE);
audio_info.set_channels(CAPTURE_CHANNELS);
build_format_param_for_audio_info(audio_info)
}
fn build_playback_format_param() -> Result<Vec<u8>, String> {
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
audio_info.set_rate(SAMPLE_RATE);
audio_info.set_channels(PLAYBACK_CHANNELS);
build_format_param_for_audio_info(audio_info)
}
fn build_format_param_for_audio_info(
audio_info: spa::param::audio::AudioInfoRaw,
) -> Result<Vec<u8>, String> {
let object = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: audio_info.into(),
};
pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(object),
)
.map_err(|err| err.to_string())
.map(|serializer| serializer.0.into_inner())
}
#[cfg(test)]
mod tests {
use super::{
input_device_id, input_device_node_id_from_id, output_device_id,
output_device_node_id_from_id, pulse_input_device_id, pulse_input_name_from_id,
pulse_output_device_id, pulse_output_name_from_id,
};
#[test]
fn input_device_id_round_trip() {
let id = input_device_id(43);
assert_eq!(input_device_node_id_from_id(&id), Some(43));
assert_eq!(input_device_node_id_from_id("legacy-id"), None);
}
#[test]
fn output_device_id_round_trip() {
let id = output_device_id(44);
assert_eq!(output_device_node_id_from_id(&id), Some(44));
assert_eq!(output_device_node_id_from_id("legacy-id"), None);
}
#[test]
fn pulse_device_id_round_trip() {
let input = pulse_input_device_id("alsa_input.pci-0000_00_1f.3");
let output = pulse_output_device_id("alsa_output.pci-0000_00_1f.3");
assert_eq!(
pulse_input_name_from_id(&input),
Some("alsa_input.pci-0000_00_1f.3")
);
assert_eq!(
pulse_output_name_from_id(&output),
Some("alsa_output.pci-0000_00_1f.3")
);
}
}
+35 -51
View File
@@ -17,13 +17,11 @@
//! * `try_select` runs a synchronous portal-version probe on
//! the blocking proxy. Failure → `None` → Focused fallback.
//! * `start(gate, binding)` spawns a worker tokio task that
//! owns an async `zbus::Connection` and calls `CreateSession`.
//! If a binding was already persisted, it also calls
//! `BindShortcuts` (sentinel id `"chanora-ptt"`). Otherwise
//! `BindShortcuts` waits for the user's explicit Configure
//! action, which is when the portal opens its system dialog.
//! While the dialog is open the engine continues at L0Focused —
//! the audio path is not blocked.
//! owns an async `zbus::Connection`, calls `CreateSession`,
//! then `BindShortcuts` (sentinel id `"chanora-ptt"`). The
//! portal opens its own system dialog asking the user to
//! pick a key; while the dialog is open the engine continues
//! at L0Focused — the audio path is not blocked.
//! * Once the user accepts, the task subscribes to
//! `Activated`/`Deactivated` signals scoped to the session
//! handle and calls `gate.set(true/false)` accordingly.
@@ -107,7 +105,7 @@ fn is_gnome_on_wayland() -> bool {
trait BlockingGlobalShortcuts {
/// Version property (read on the blocking proxy as a fast
/// reachability probe).
#[zbus(property, name = "version")]
#[zbus(property)]
fn version(&self) -> zbus::Result<u32>;
}
@@ -221,7 +219,7 @@ struct BackendInner {
enum WorkerCmd {
/// Rebind: re-issue `BindShortcuts` on the same session.
Bind,
Rebind,
/// Stop: close the session and exit.
Stop,
}
@@ -298,10 +296,9 @@ impl DesktopPttBackend for LinuxGnomeWaylandBackend {
// the bridge's tokio runtime, so this is satisfied.
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let desc_tx = self.desc_tx.clone();
let bind_on_start = binding.input_class != PttInputClass::None;
let class_hint = binding.input_class;
let worker = tokio::spawn(async move {
run_worker(gate, cmd_rx, desc_tx, class_hint, bind_on_start).await;
run_worker(gate, cmd_rx, desc_tx, class_hint).await;
});
// Stash command + worker handles. `try_lock` is fine: the
// backend isn't yet shared, and `start` is called once at
@@ -353,7 +350,7 @@ impl DesktopPttBackend for LinuxGnomeWaylandBackend {
// only — the portal decides the actual binding.
if let Ok(inner) = self.inner.try_lock() {
if let Some(tx) = inner.cmd_tx.as_ref() {
let _ = tx.send(WorkerCmd::Bind);
let _ = tx.send(WorkerCmd::Rebind);
return Ok(());
}
}
@@ -376,7 +373,6 @@ async fn run_worker(
mut cmd_rx: mpsc::UnboundedReceiver<WorkerCmd>,
desc_tx: watch::Sender<PttBackendDescriptor>,
initial_class_hint: PttInputClass,
bind_on_start: bool,
) {
// Open an async D-Bus session connection. If this fails we
// emit a warning and exit; the descriptor stays at L0Focused
@@ -421,13 +417,26 @@ async fn run_worker(
"linux ptt: portal session created"
);
if bind_on_start {
run_bind_shortcut(&proxy, &conn, &session_handle, &desc_tx, initial_class_hint).await;
} else {
info!(
target: "chanora_audio",
"linux ptt: portal session ready; waiting for explicit BindShortcuts request"
);
// Bind the initial shortcut. The portal opens its own dialog.
match bind_shortcut(&proxy, &conn, &session_handle).await {
Ok(class) => publish_bound(&desc_tx, class.unwrap_or(initial_class_hint)),
Err(BindError::Cancelled) => {
warn!(
target: "chanora_audio",
bind_status = "cancelled",
"linux ptt: user cancelled BindShortcuts; descriptor stays at L0Focused"
);
publish_l0(&desc_tx);
}
Err(BindError::Failed(e)) => {
warn!(
target: "chanora_audio",
bind_status = "failed",
error = %e,
"linux ptt: BindShortcuts failed; descriptor stays at L0Focused"
);
publish_l0(&desc_tx);
}
}
// Subscribe to Activated / Deactivated signals scoped to the
@@ -459,8 +468,12 @@ async fn run_worker(
tokio::select! {
cmd = cmd_rx.recv() => {
match cmd {
Some(WorkerCmd::Bind) => {
run_bind_shortcut(&proxy, &conn, &session_handle, &desc_tx, initial_class_hint).await;
Some(WorkerCmd::Rebind) => {
match bind_shortcut(&proxy, &conn, &session_handle).await {
Ok(class) => publish_bound(&desc_tx, class.unwrap_or(initial_class_hint)),
Err(BindError::Cancelled) => publish_l0(&desc_tx),
Err(BindError::Failed(_)) => publish_l0(&desc_tx),
}
}
Some(WorkerCmd::Stop) | None => {
// Close the portal session via the
@@ -573,35 +586,6 @@ async fn bind_shortcut(
Ok(class)
}
async fn run_bind_shortcut(
proxy: &GlobalShortcutsProxy<'_>,
conn: &AsyncConnection,
session_handle: &zbus::zvariant::OwnedObjectPath,
desc_tx: &watch::Sender<PttBackendDescriptor>,
class_hint: PttInputClass,
) {
match bind_shortcut(proxy, conn, session_handle).await {
Ok(class) => publish_bound(desc_tx, class.unwrap_or(class_hint)),
Err(BindError::Cancelled) => {
warn!(
target: "chanora_audio",
bind_status = "cancelled",
"linux ptt: user cancelled BindShortcuts; descriptor stays at L0Focused"
);
publish_l0(desc_tx);
}
Err(BindError::Failed(e)) => {
warn!(
target: "chanora_audio",
bind_status = "failed",
error = %e,
"linux ptt: BindShortcuts failed; descriptor stays at L0Focused"
);
publish_l0(desc_tx);
}
}
}
/// Wait for the `Response` signal on `request_path`. Returns the
/// `results` dict on success (response code 0) or an Error
/// otherwise.
+203
View File
@@ -0,0 +1,203 @@
//! Linux output stream via SDL2 (Qint / upstream `tsclientlib`
//! `ts_to_audio` pattern).
//!
//! ## Why SDL2 and not cpal on Linux
//!
//! cpal's Linux backend opens raw ALSA's `default` PCM. On most
//! modern distributions (Arch with `pipewire-alsa`, Fedora 38+,
//! Debian/Ubuntu with PipeWire) that virtual device still goes
//! through ALSA's `dmix` + `plug` layers when bypassing the
//! PulseAudio/PipeWire client. The `plug` layer's default
//! resampler is **nearest-neighbour**, which causes pronounced
//! aliasing on the 48 kHz → 44.1 kHz step that the
//! `tsclientlib::AudioHandler` output requires. Users perceive
//! this as constant crackling and popping. cpal also picks a
//! small default period size (≈256 frames), which leaves no
//! headroom for kernel scheduler jitter and causes additional
//! xruns.
//!
//! SDL2 on the same systems opens the audio device through the
//! SDL audio driver — which prefers PulseAudio when available
//! and falls back to ALSA otherwise. On a PipeWire box the SDL
//! PulseAudio driver lands inside PipeWire's PulseAudio
//! compatibility layer, whose resampler is high quality. SDL
//! also defaults to a buffer ≈ samples-requested, so we get
//! exactly one Opus frame (20 ms) per callback.
//!
//! This file replaces the cpal output path on Linux only. The
//! capture path stays on cpal until we have a reason to swap it
//! (the user reports outbound is currently fine). Windows continues
//! to use cpal's WASAPI backend; Apple platforms use direct
//! VoiceProcessingIO AudioUnits for the voice path.
//!
//! ## Threading & lifecycle
//!
//! `sdl2::AudioDevice<CB>` is `!Send + !Sync` — the SDL audio
//! lock is associated with the calling thread. We open the device
//! on the same thread that calls `Self::start_with_gate` (the
//! tokio worker that runs `chanora_core::ChanoraSession::start_audio`)
//! and never move it. The outer `AudioEngine` already carries an
//! `unsafe impl Send` to bypass cpal's identical constraint; we
//! reuse that and stash the SDL device behind a `Mutex<Option<…>>`
//! the same way cpal does.
//!
//! Dropping the `SdlOutput` closes the SDL device cleanly and
//! drops the playback callback — that releases the
//! `Arc<Mutex<AudioHandler>>` clone the callback held.
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use sdl2::audio::{AudioCallback, AudioDevice, AudioSpecDesired};
use sdl2::AudioSubsystem;
use tracing::{info, warn};
use tsclientlib::audio::AudioHandler;
use crate::engine::SessionAudioId;
use crate::AudioError;
/// 20 ms at 48 kHz mono == one Opus frame's worth of samples.
/// Stereo doubles the byte count but the frame-count stays the
/// same. Aligning the callback size to the Opus frame keeps the
/// jitter-buffer / playback handshake tight (no fractional frame
/// reads inside fill_buffer).
const FRAME_SAMPLES: u16 = 960;
/// SDL output device wrapper. Holds the live `AudioDevice` so its
/// callback keeps firing for the engine's lifetime, plus a
/// reference to the same `AudioHandler` the inbound forwarder
/// pushes into.
pub struct SdlOutput {
// Drop order: device first (stops the callback), then any
// remaining references release naturally. We keep `_subsystem`
// alive because dropping the AudioSubsystem before the device
// would invalidate SDL's internal state.
device: AudioDevice<TsPlaybackCallback>,
_subsystem: AudioSubsystem,
}
impl SdlOutput {
/// Open the default SDL playback device at the AudioHandler's
/// native format (48 kHz stereo f32) and start the device
/// playing immediately. The callback drains the AudioHandler
/// directly — no user-side resampling.
///
/// `output_gain` is read on every callback to apply the
/// master-volume slider; `output_muted` zeroes the output (but
/// still drains AudioHandler so its jitter buffer doesn't grow
/// unbounded while muted). These two atomics share the same
/// definitions the cpal path uses, so the same FFI surface
/// (`set_output_gain` / `set_output_muted`) drives both.
pub fn start(
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
) -> Result<Self, AudioError> {
let sdl = sdl2::init().map_err(|e| AudioError::Backend(format!("sdl init: {e}")))?;
let subsystem = sdl
.audio()
.map_err(|e| AudioError::Backend(format!("sdl audio subsystem: {e}")))?;
info!(
target: "chanora_audio",
driver = subsystem.current_audio_driver(),
"sdl audio subsystem initialised"
);
let desired = AudioSpecDesired {
freq: Some(48_000),
channels: Some(2),
samples: Some(FRAME_SAMPLES),
};
let device = AudioDevice::open_playback(&subsystem, None, &desired, |spec| {
info!(
target: "chanora_audio",
freq = spec.freq,
channels = spec.channels,
samples = spec.samples,
"sdl playback spec accepted"
);
TsPlaybackCallback {
handler,
output_gain,
output_muted,
}
})
.map_err(|e| AudioError::Backend(format!("sdl open_playback: {e}")))?;
// Begin pumping audio frames out. SDL's device starts paused;
// resume() flips it into the playing state. The callback
// will fire repeatedly at ~50 Hz (every 20 ms) thereafter.
device.resume();
Ok(Self {
device,
_subsystem: subsystem,
})
}
}
impl Drop for SdlOutput {
fn drop(&mut self) {
// AudioDevice::drop closes the device which stops the
// callback. We log so the chanora.log timeline matches
// engine shutdown.
warn!(target: "chanora_audio", "sdl playback device closing");
}
}
/// Playback callback invoked by SDL's audio thread. The shape
/// mirrors the upstream `tsclientlib` example's `SdlCallback`:
/// zero the output buffer (so silent regions emit silence rather
/// than stale memory), then ask the `AudioHandler` to fill in
/// whatever decoded frames it has buffered.
struct TsPlaybackCallback {
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
}
impl AudioCallback for TsPlaybackCallback {
type Channel = f32;
fn callback(&mut self, buffer: &mut [f32]) {
// The buffer is interleaved stereo at 48 kHz from SDL.
// Length is FRAME_SAMPLES * 2 (= 1920 f32) per the spec
// we requested.
for sample in buffer.iter_mut() {
*sample = 0.0;
}
// Lock window kept as tight as the upstream example —
// fill_buffer does the actual Opus decode + jitter logic.
// Contention with the inbound forwarder is the same as
// in the cpal path, but the upstream design has shipped
// this way for years.
{
let mut data = self.handler.lock().unwrap();
let _removed_ids = data.fill_buffer(buffer);
// `_removed_ids` is the list of clients whose stream the
// handler just finished draining. We could publish that
// upward as a "stopped speaking" hint, but the existing
// BridgeEvent::VoiceState already covers that case via
// the bridge layer; ignoring matches upstream behaviour.
}
// Apply local mute + master gain after fill so the jitter
// buffer still drains while muted (matching the cpal path's
// contract). `output_gain` is encoded as f32 bits inside an
// AtomicU32 — same encoding the cpal path uses.
if self.output_muted.load(Ordering::Relaxed) {
for sample in buffer.iter_mut() {
*sample = 0.0;
}
return;
}
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
if (gain - 1.0).abs() > f32::EPSILON {
for sample in buffer.iter_mut() {
*sample *= gain;
}
}
}
}
+2 -137
View File
@@ -33,16 +33,12 @@
//! ## Fallback
//!
//! `try_new` returns `None` when the model file is missing, the ONNX
//! Runtime is unavailable, or the platform cannot load ONNX Runtime. The caller
//! Runtime is unavailable, or the platform is not iOS/macOS. The caller
//! falls back to `WebRtcFallbackVad`.
use super::{VadOutput, VoiceActivityDetector};
#[cfg(target_os = "linux")]
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
#[cfg(target_os = "linux")]
use std::sync::OnceLock;
use std::thread::JoinHandle;
/// 16 kHz frame size for Silero VAD v6 (32 ms).
@@ -92,7 +88,7 @@ impl SileroOnnxVad {
/// Attempt to load the Silero v6 ONNX model from `model_path`.
///
/// Returns `None` when the model file is missing, the ONNX Runtime
/// is unavailable, or the platform cannot initialize ONNX Runtime.
/// is unavailable, or the platform does not support ONNX.
pub fn try_new(model_path: &str) -> Option<Self> {
Self::try_new_onnx(model_path)
}
@@ -109,17 +105,6 @@ impl SileroOnnxVad {
return None;
}
#[cfg(target_os = "linux")]
if let Err(err) = ensure_linux_onnxruntime_loaded() {
error!(
target: "chanora_audio",
path = model_path,
error = %err,
"SileroOnnxVad: ONNX Runtime is unavailable on Linux; falling back to WebRtcFallbackVad"
);
return None;
}
let session_result = std::panic::catch_unwind(|| {
ort::session::Session::builder().and_then(|mut b| b.commit_from_file(model_path))
});
@@ -316,111 +301,6 @@ impl VoiceActivityDetector for SileroOnnxVad {
}
}
#[cfg(target_os = "linux")]
fn ensure_linux_onnxruntime_loaded() -> Result<(), String> {
static ORT_INIT: OnceLock<Result<(), String>> = OnceLock::new();
ORT_INIT
.get_or_init(|| {
let dylib_path = linux_onnxruntime_dylib_path();
match dylib_path.as_ref() {
Some(path) => {
tracing::info!(
target: "chanora_audio",
path = %path.display(),
"SileroOnnxVad: loading Linux ONNX Runtime from discovered shared library"
);
ort::init_from(path)
.map_err(|e| format!("init_from({}): {e}", path.display()))?
.with_name("chanora_audio")
.commit()
.then_some(())
.ok_or_else(|| {
format!("commit(init_from {}): environment already initialized", path.display())
})
}
None => {
tracing::warn!(
target: "chanora_audio",
"SileroOnnxVad: no explicit Linux ONNX Runtime shared library path found; trying loader default"
);
ort::init()
.with_name("chanora_audio")
.commit()
.then_some(())
.ok_or_else(|| "commit(init): environment already initialized".to_string())
}
}
})
.clone()
}
#[cfg(target_os = "linux")]
fn linux_onnxruntime_dylib_path() -> Option<PathBuf> {
if let Some(path) = existing_env_file("ORT_DYLIB_PATH") {
return Some(path);
}
let mut candidates = Vec::new();
if let Ok(exe) = std::env::current_exe() {
if let Some(exe_dir) = exe.parent() {
candidates.push(exe_dir.join("lib").join("libonnxruntime.so"));
candidates.push(exe_dir.join("libonnxruntime.so"));
}
}
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.join("libonnxruntime.so"));
}
candidates.extend([
PathBuf::from("/usr/lib/libonnxruntime.so"),
PathBuf::from("/usr/lib64/libonnxruntime.so"),
PathBuf::from("/usr/local/lib/libonnxruntime.so"),
PathBuf::from("/lib/x86_64-linux-gnu/libonnxruntime.so"),
PathBuf::from("/usr/lib/x86_64-linux-gnu/libonnxruntime.so"),
PathBuf::from("/lib/aarch64-linux-gnu/libonnxruntime.so"),
PathBuf::from("/usr/lib/aarch64-linux-gnu/libonnxruntime.so"),
]);
for candidate in candidates {
if candidate.is_file() {
return Some(candidate);
}
}
first_matching_dir_entry("/usr/lib", "libonnxruntime.so")
.or_else(|| first_matching_dir_entry("/usr/lib64", "libonnxruntime.so"))
.or_else(|| first_matching_dir_entry("/usr/local/lib", "libonnxruntime.so"))
.or_else(|| first_matching_dir_entry("/usr/lib/x86_64-linux-gnu", "libonnxruntime.so"))
.or_else(|| first_matching_dir_entry("/usr/lib/aarch64-linux-gnu", "libonnxruntime.so"))
}
#[cfg(target_os = "linux")]
fn existing_env_file(name: &str) -> Option<PathBuf> {
let path = std::env::var_os(name).map(PathBuf::from)?;
path.is_file().then_some(path)
}
#[cfg(target_os = "linux")]
fn first_matching_dir_entry(dir: &str, prefix: &str) -> Option<PathBuf> {
let mut matches: Vec<PathBuf> = std::fs::read_dir(dir)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| {
path.is_file()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(prefix))
})
.collect();
matches.sort();
matches.into_iter().next()
}
// SAFETY: ONNX Runtime sessions are thread-safe for inference.
// State arrays are owned by this struct and accessed only from
// the single capture callback thread.
@@ -521,8 +401,6 @@ impl Drop for SileroOnnxVadWorker {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
use std::path::PathBuf;
fn make_stub_vad() -> SileroOnnxVad {
SileroOnnxVad {
@@ -618,17 +496,4 @@ mod tests {
&completed_frame[SILERO_FRAME_16K - SILERO_CONTEXT_16K..]
);
}
#[cfg(target_os = "linux")]
#[test]
fn existing_env_file_ignores_missing_paths() {
let var_name = format!("CHANORA_TEST_ORT_{}", std::process::id());
std::env::remove_var(&var_name);
assert!(existing_env_file(&var_name).is_none());
let missing = PathBuf::from("/definitely/missing/libonnxruntime.so");
std::env::set_var(&var_name, &missing);
assert!(existing_env_file(&var_name).is_none());
std::env::remove_var(&var_name);
}
}