feat: promote linux native audio path

This commit is contained in:
Edison Jwa
2026-05-25 17:42:06 +09:00
parent c19de3a370
commit a2d686d9d0
73 changed files with 26156 additions and 467 deletions
+27 -35
View File
@@ -9,6 +9,10 @@ license.workspace = true
repository.workspace = true
publish.workspace = true
[features]
default = []
bench-harness = ["dep:criterion", "dep:dhat", "dep:serde_json"]
[dependencies]
chanora_protocol = { path = "../chanora_protocol" }
thiserror.workspace = true
@@ -29,10 +33,13 @@ audiopus = "0.3.0-rc.0"
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
rustfft = "6.2.0"
criterion = { version = "0.5", optional = true }
dhat = { version = "0.3", optional = true }
serde_json = { version = "1", optional = true }
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
# Desktop audio I/O for Windows capture/playback and Linux capture.
# Linux playback uses SDL2; Apple platforms use direct VoiceProcessingIO
# Desktop audio I/O for Windows capture/playback and Linux fallback metadata.
# Linux voice I/O uses PipeWire first and PulseAudio as fallback; Apple platforms use direct VoiceProcessingIO
# AudioUnits via `coreaudio-rs` for the voice path.
cpal = "0.17.3"
@@ -101,34 +108,33 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time", "test-util"
# Cross-platform recording Layer for the SDD-090 / DEC-027 privacy
# invariant integration test (`tests/ptt_privacy.rs`).
tracing-subscriber = { version = "0.3", features = ["registry"] }
# SDD-120 §3 — criterion bench harness (realtime_capture / opus_codec /
# resampler). `harness = false` per bench entry below disables the
# default libtest harness so criterion can install its own.
criterion = "0.5"
# SDD-120 §3 item 1 — dhat is used as the global allocator inside
# `benches/realtime_capture.rs` to count post-warmup heap allocations
# on the realtime capture path. Dev-dep only — does NOT affect
# production builds.
dhat = "0.3"
# SDD-120 §5 / §8 — JSON serialization for `emit_baseline` /
# `compare_baseline` binaries that consume criterion's per-bench
# `estimates.json` outputs and emit the SRS-217 baseline schema.
serde_json = "1"
[[bench]]
name = "realtime_capture"
harness = false
path = "benches/realtime_capture.rs"
required-features = ["bench-harness"]
[[bench]]
name = "opus_codec"
harness = false
path = "benches/opus_codec.rs"
required-features = ["bench-harness"]
[[bench]]
name = "resampler"
harness = false
path = "benches/resampler.rs"
required-features = ["bench-harness"]
[[example]]
name = "compare_baseline"
path = "examples/compare_baseline.rs"
required-features = ["bench-harness"]
[[example]]
name = "emit_baseline"
path = "examples/emit_baseline.rs"
required-features = ["bench-harness"]
[target.'cfg(target_os = "linux")'.dependencies]
# GNOME-on-Wayland Global Push-to-Talk uses the freedesktop
@@ -148,22 +154,8 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
# options. The portal recommends fresh tokens to scope its own
# object paths per call.
rand = "0.8"
# SDL2 audio for Linux. Replaces the cpal playback path on Linux only;
# cpal stays in use for Linux capture and Windows capture/playback.
# Apple platforms use direct VoiceProcessingIO AudioUnits. Rationale: the
# cpal Linux backend opens raw ALSA `default`, which on most Arch
# / Fedora / Debian installs routes through `dmix` + `plug` with
# nearest-neighbour resampling and very small period sizes — the
# combination produces audible crackling/popping. SDL2 on the same
# systems routes through PipeWire's PulseAudio compat bridge (or
# real PulseAudio), both of which carry a high-quality resampler
# and a sensible default period. The upstream tsclientlib audio
# example (`tsclientlib/examples/audio_utils/ts_to_audio.rs`) and
# the official Qint client both use SDL2 in exactly this shape;
# this dep brings Chanora in line with that pattern.
#
# `bundled` is OFF deliberately — we link against the system
# libSDL2.so. Arch ships `sdl2-compat`; Debian/Ubuntu ship
# `libsdl2-2.0-0`; Fedora ships `SDL2`. The chanora-flutter Linux
# build documentation lists this as a runtime dependency.
sdl2 = { version = "0.37", default-features = false }
# Linux-native voice I/O. PipeWire is primary on modern desktops;
# PulseAudio is retained as fallback and for older environments.
pipewire = { version = "0.10", features = ["v0_3_44"] }
libpulse-binding = "2.30"
libpulse-simple-binding = "2.29"
+257 -78
View File
@@ -5,28 +5,35 @@
//! 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")
@@ -103,6 +110,7 @@ pub struct AudioDeviceInfo {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -112,6 +120,7 @@ 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")
@@ -123,6 +132,7 @@ 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")
@@ -178,29 +188,53 @@ pub fn list_audio_devices() -> AudioDeviceList {
output_devices: Vec::new(),
};
let host = cpal::default_host();
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);
#[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);
}
}
}
}
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);
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);
}
}
}
}
@@ -306,31 +340,23 @@ 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,
#[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")
))]
// Streams must be dropped to stop audio. Linux owns native
// PipeWire/PulseAudio threads; Windows owns cpal streams. On iOS both
// sides collapse into a single `IosVoiceUnit` (one VoiceProcessingIO
// AudioUnit hosts mic + speaker).
#[cfg(target_os = "linux")]
_input_stream: Mutex<Option<crate::linux_pipewire_input::LinuxInput>>,
#[cfg(target_os = "windows")]
_input_stream: Mutex<Option<cpal::Stream>>,
#[cfg(target_os = "linux")]
_output_stream: Mutex<Option<crate::sdl_output::SdlOutput>>,
_output_stream: Mutex<Option<crate::linux_pipewire_input::LinuxOutput>>,
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
@@ -457,6 +483,31 @@ 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(
@@ -692,7 +743,7 @@ impl AudioEngine {
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
/// Non-Apple/non-Android implementation: cpal capture + (cpal | SDL2) output.
/// Non-Apple/non-Android implementation: native Linux voice I/O or cpal on Windows.
/// Kept as a separate function so the Apple and Android paths can
/// short-circuit at the top of `start_with_gate` without dragging a 200-line
/// cfg-gated block. Body is the pre-iOS-port code, unchanged
@@ -711,7 +762,16 @@ 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,
@@ -735,29 +795,31 @@ 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)?;
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"
);
#[cfg(not(target_os = "linux"))]
let in_dev = in_dev.ok_or(AudioError::NoInputDevice)?;
#[cfg(not(target_os = "linux"))]
let out_dev = out_dev.ok_or(AudioError::NoOutputDevice)?;
// Log the cpal-reported default configs *before* trying to
// open streams, so a downstream stream-build failure can
// be cross-referenced against what the platform reported
@@ -768,6 +830,29 @@ 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",
@@ -782,6 +867,24 @@ 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",
@@ -797,6 +900,7 @@ 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));
@@ -804,6 +908,7 @@ 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
@@ -811,24 +916,52 @@ 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,
);
let (input_stream, capture_active) = match capture_result {
Ok(s) => (Some(s), true),
)
.map(|stream| {
(
Some(stream),
true,
device_name(&in_dev).unwrap_or_else(|| "cpal capture".to_string()),
)
});
let (input_stream, capture_active, capture_device_name) = match capture_result {
Ok((stream, active, device_name)) => (stream, active, device_name),
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
"capture stream unavailable; continuing with playback only"
);
(None, false)
(None, false, "<capture unavailable>".to_string())
}
};
#[cfg(target_os = "windows")]
if let Some(s) = &input_stream {
s.play()
.map_err(|e| AudioError::Backend(format!("input play: {e}")))?;
@@ -838,21 +971,18 @@ impl AudioEngine {
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.
// Linux uses native PipeWire/PulseAudio playback. PipeWire is primary;
// PulseAudio is fallback and can also be selected explicitly by id.
#[cfg(target_os = "linux")]
let output_stream = crate::sdl_output::SdlOutput::start(
let (output_stream, output_device_info) = crate::linux_pipewire_input::start_output(
cfg.output_device_id.as_deref(),
out_dev.as_ref().and_then(device_name).as_deref(),
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
)?;
#[cfg(target_os = "linux")]
let output_device_name = output_device_info.name;
#[cfg(not(target_os = "linux"))]
let output_stream = {
@@ -927,10 +1057,20 @@ 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 {
@@ -947,6 +1087,11 @@ 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);
}
}
@@ -966,6 +1111,10 @@ 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),
@@ -991,6 +1140,7 @@ 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()));
@@ -1137,6 +1287,7 @@ 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 {
@@ -1153,6 +1304,11 @@ 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);
}
}
@@ -1172,6 +1328,7 @@ 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,
@@ -1224,6 +1381,7 @@ 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()));
@@ -1258,6 +1416,7 @@ 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 {
@@ -1274,6 +1433,11 @@ 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);
}
}
@@ -1293,6 +1457,7 @@ 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,
@@ -1312,12 +1477,8 @@ 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 cpal input + cpal/SDL output.
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
// platform has separate input + output stream owners.
#[cfg(any(target_os = "linux", target_os = "windows"))]
{
let _ = self._input_stream.lock().unwrap().take();
let _ = self._output_stream.lock().unwrap().take();
@@ -1669,6 +1830,25 @@ 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"
);
}
}
}
}
@@ -1680,7 +1860,7 @@ impl Drop for AudioEngine {
// ---------- Capture pipeline ----------
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
#[cfg(target_os = "windows")]
fn try_open_capture(
in_dev: &cpal::Device,
voice_out_tx: mpsc::Sender<OutPacket>,
@@ -1699,9 +1879,8 @@ 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: 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.
// * Linux does not reach this cpal capture path in production;
// it uses native PipeWire/PulseAudio capture instead.
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
#[cfg(target_os = "windows")]
{
@@ -1738,7 +1917,7 @@ fn try_open_capture(
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
struct CaptureState {
pub(crate) struct CaptureState {
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
@@ -1777,7 +1956,7 @@ struct CaptureState {
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl CaptureState {
fn new(
pub(crate) fn new(
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
@@ -1810,7 +1989,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).
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
pub(crate) fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
if !self.transmit_active.load(Ordering::Relaxed) {
// Drain accumulator while muted so we don't pop on PTT release.
self.pcm_accum.clear();
@@ -1959,7 +2138,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")))]
trait ToF32 {
pub(crate) trait ToF32 {
fn to_f32_sample(self) -> f32;
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
@@ -1981,7 +2160,7 @@ impl ToF32 for u16 {
}
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
#[cfg(target_os = "windows")]
fn build_input_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
+11 -15
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 SDL2 for output (see `sdl_output.rs`) and cpal for capture;
//! Linux uses native PipeWire/PulseAudio voice I/O;
//! 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
//! cpal + SDL use) and never move it. The outer `AudioEngine`
//! the desktop backends 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 cpal
/// and SDL backends accept so engine.rs can swap backends with
/// Parameters mirror the capture + playback inputs the desktop
/// 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 cpal
/// and SDL output paths read on every callback so the master
/// * `output_gain` / `output_muted` — same atomics the desktop
/// 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 cpal + SDL output
// buffer + mix. Same primitive desktop 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,12 +748,10 @@ 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
// SdlOutput::callback and the cpal output stream).
// Linux PipeWire/PulseAudio and the cpal output stream).
//
// Build the playback pipeline (direct fill_buffer in
// render callback; matches tsclientlib's reference SDL
// example at
// tsclientlib/examples/audio_utils/ts_to_audio.rs).
// render callback; matches the desktop voice backends).
//
// The earlier ring-buffer attempt (rc.8+73..+74) decoupled
// AudioHandler from the render callback via a 50 Hz
@@ -766,14 +764,12 @@ 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. Linux/SDL's same pattern works fine because
// SDL calls fill_buffer at exactly the device callback
// rate.
// arrived. Direct desktop callbacks follow the same pattern.
//
// 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 Linux/SDL, just stereo-f32 -> mono-i16
// buffer. Same as desktop output, 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();
+1 -1
View File
@@ -47,7 +47,7 @@ pub mod voice_activity;
pub(crate) mod voice_render;
#[cfg(target_os = "linux")]
mod sdl_output;
mod linux_pipewire_input;
#[cfg(any(target_os = "ios", target_os = "macos"))]
mod ios_voice_unit;
@@ -0,0 +1,961 @@
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}")
}
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}")
}
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}")
}
fn pulse_input_name_from_id(id: &str) -> Option<&str> {
id.strip_prefix(PULSE_INPUT_ID_PREFIX)
}
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> {
let mut devices = Vec::new();
match list_pipewire_nodes(direction) {
Ok(mut pipewire) => devices.append(&mut pipewire),
Err(error) => {
warn!(target: "chanora_audio", error = %error, "PipeWire device enumeration failed")
}
}
match list_pulse_devices(direction) {
Ok(mut pulse) => devices.append(&mut pulse),
Err(error) => {
warn!(target: "chanora_audio", error = %error, "PulseAudio device enumeration failed")
}
}
devices
}
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")
);
}
}
-203
View File
@@ -1,203 +0,0 @@
//! 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;
}
}
}
}
+137 -2
View File
@@ -33,12 +33,16 @@
//! ## Fallback
//!
//! `try_new` returns `None` when the model file is missing, the ONNX
//! Runtime is unavailable, or the platform is not iOS/macOS. The caller
//! Runtime is unavailable, or the platform cannot load ONNX Runtime. 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).
@@ -88,7 +92,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 does not support ONNX.
/// is unavailable, or the platform cannot initialize ONNX Runtime.
pub fn try_new(model_path: &str) -> Option<Self> {
Self::try_new_onnx(model_path)
}
@@ -105,6 +109,17 @@ 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))
});
@@ -301,6 +316,111 @@ 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.
@@ -401,6 +521,8 @@ impl Drop for SileroOnnxVadWorker {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
use std::path::PathBuf;
fn make_stub_vad() -> SileroOnnxVad {
SileroOnnxVad {
@@ -496,4 +618,17 @@ 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);
}
}
+3
View File
@@ -355,6 +355,8 @@ pub struct BridgeChannel {
pub struct BridgeClient {
/// Stable client id.
pub id: u64,
/// Stable TeamSpeak unique identifier.
pub uid: String,
/// Channel id the client is currently in.
pub channel: u64,
/// Nickname.
@@ -418,6 +420,7 @@ impl From<chanora_core::ServerSnapshot> for BridgeSnapshot {
.into_iter()
.map(|c| BridgeClient {
id: c.id.0,
uid: c.uid,
channel: c.channel.0,
name: c.name,
input_muted: c.input_muted,
@@ -1940,6 +1940,7 @@ impl SseDecode for crate::api::BridgeClient {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_id = <u64>::sse_decode(deserializer);
let mut var_uid = <String>::sse_decode(deserializer);
let mut var_channel = <u64>::sse_decode(deserializer);
let mut var_name = <String>::sse_decode(deserializer);
let mut var_inputMuted = <bool>::sse_decode(deserializer);
@@ -1950,6 +1951,7 @@ impl SseDecode for crate::api::BridgeClient {
let mut var_talkPowerGranted = <bool>::sse_decode(deserializer);
return crate::api::BridgeClient {
id: var_id,
uid: var_uid,
channel: var_channel,
name: var_name,
input_muted: var_inputMuted,
@@ -2796,6 +2798,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeClient {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.id.into_into_dart().into_dart(),
self.uid.into_into_dart().into_dart(),
self.channel.into_into_dart().into_dart(),
self.name.into_into_dart().into_dart(),
self.input_muted.into_into_dart().into_dart(),
@@ -3434,6 +3437,7 @@ impl SseEncode for crate::api::BridgeClient {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<u64>::sse_encode(self.id, serializer);
<String>::sse_encode(self.uid, serializer);
<u64>::sse_encode(self.channel, serializer);
<String>::sse_encode(self.name, serializer);
<bool>::sse_encode(self.input_muted, serializer);
+9 -6
View File
@@ -13,6 +13,7 @@ publish.workspace = true
thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
base64 = "0.22"
chanora_resolver = { path = "../chanora_resolver" }
# tsclientlib is git-only and not on crates.io. The "audio" feature
@@ -31,12 +32,14 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"
futures = "0.3"
async-trait = "0.1"
[target.'cfg(not(target_os = "android"))'.dependencies]
# Desktop/iOS: native TLS maps to the platform TLS backend (Security.framework
# on Apple, SChannel on Windows, system OpenSSL on Linux/BSD).
[target.'cfg(any(target_os = "ios", target_os = "macos", target_os = "windows"))'.dependencies]
# Apple + Windows keep the platform TLS backend (Security.framework /
# SChannel). iOS in particular avoids the rustls/aws-lc path here.
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
[target.'cfg(target_os = "android")'.dependencies]
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
# native-tls for Apple targets where aws-lc/rustls is problematic for iOS.
[target.'cfg(any(target_os = "android", target_os = "linux"))'.dependencies]
# Android + Linux avoid a system OpenSSL dependency and use rustls instead.
# This keeps Linux CI / dev hosts buildable without `openssl-devel`, while
# preserving the native-tls path for Apple targets where rustls/aws-lc has
# been problematic.
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] }
+9 -1
View File
@@ -20,6 +20,7 @@
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use base64::prelude::*;
use chanora_resolver::ChanoraResolver;
use futures::prelude::*;
use std::collections::HashMap;
@@ -975,7 +976,9 @@ fn send_text_message(
) -> Result<MessageHandle, ProtocolError> {
use tsproto_types::TextMessageTargetMode;
match target {
MessageTarget::Server => send_text_to_mode(con, message, TextMessageTargetMode::Server, "server"),
MessageTarget::Server => {
send_text_to_mode(con, message, TextMessageTargetMode::Server, "server")
}
MessageTarget::Channel => {
// Fix: previously channel messages were sent via
// state.server.send_textmessage() which always uses
@@ -1216,6 +1219,11 @@ fn build_snapshot(
.iter()
.map(|c| ClientInfo {
id: ClientId(c.id.0 as u64),
uid: c
.uid
.as_ref()
.map(|uid| BASE64_STANDARD.encode(&uid.0))
.unwrap_or_default(),
channel: ChannelId(c.channel.0),
name: sanitize(&c.name),
input_muted: c.input_muted,
+2
View File
@@ -68,6 +68,8 @@ pub struct ServerActivity {
pub struct ClientInfo {
/// Stable client id.
pub id: ClientId,
/// Stable TeamSpeak unique identifier for cross-session preference keys.
pub uid: String,
/// Channel the client is currently in.
pub channel: ChannelId,
/// Nickname, preserved verbatim per ADR-008.