chore: restore product scaffold to rollback baseline
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user