feat: promote linux native audio path
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user