Files
chanora/crates/chanora_audio/Cargo.toml
T
EdisonJwa d9c330c23b feat(audio,linux): output via SDL2; cpal stays on Windows/macOS
User reported persistent crackling/popping from peer audio on Linux even
after fixing the 48k->device-rate resampler boundary discontinuities,
clamping pre-Opus-encode peaks, and pre-allocating the playback scratch
buffer. Logs confirmed cpal opened raw ALSA at 44.1k native, no callback
budget violations, no underrun warnings -- yet the audio was still poor.

Root cause: cpal on Linux opens raw ALSA's 'default' PCM. On modern
PipeWire / pipewire-alsa boxes that virtual device routes through ALSA's
dmix + plug layers, whose default resampler is nearest-neighbour. cpal
also picks a small default period size (~256 frames / 5.8 ms) leaving no
headroom for kernel scheduler jitter. Both effects compound into the
crackling the user heard.

Upstream tsclientlib's own audio example
(tsclientlib/examples/audio_utils/ts_to_audio.rs) and the official Qint
client both use SDL2 with AudioSpecDesired { freq: 48000, channels: 2,
samples: 960 }. SDL2 on the same systems routes through PipeWire's PA
bridge (or PulseAudio directly), both carrying high-quality resamplers.

Fix:
  * Add sdl2 = '0.37' as a target_os=linux dependency. Links libSDL2-2.0
    .so (Arch sdl2-compat over SDL3, Debian libsdl2-2.0-0, Fedora SDL2).
  * New module crates/chanora_audio/src/sdl_output.rs implementing
    SdlOutput: opens a 48 kHz stereo 960-frame callback that zeroes the
    buffer and calls AudioHandler::fill_buffer directly (no user-side
    resampler). Master gain + hard-mute atomics wired in identically to
    the cpal callback so set_output_gain / set_output_muted keep working.
  * engine.rs cfg-gated: target_os='linux' builds SdlOutput; everywhere
    else continues with the cpal output path (including the device-native-
    rate negotiation and resampler-continuity fixes shipped earlier --
    those remain correct on Windows/macOS where cpal targets WASAPI /
    CoreAudio cleanly).
  * The cpal output helpers (build_output_stream, PlaybackResampleState,
    FromF32) are now cfg(not(target_os='linux'))-gated so the Linux
    build doesn't emit dead-code warnings.

Capture path still cpal on every platform -- outbound audio was not
reported as bad. Resampler-continuity fix on the capture side stays:
microphone -> Opus encoder still goes through the linear interpolator
with the last-sample anchor.

Tests: 32 / 0 / 0 (chanora_audio), workspace 78 / 0 / 1 unchanged.
2026-05-16 12:15:00 +08:00

96 lines
4.4 KiB
TOML

[package]
name = "chanora_audio"
description = "Chanora audio subsystem — cpal-based capture/playback, audiopus encode, tsclientlib AudioHandler for decode + jitter buffer + mix. DEC-011, DEC-011.1."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
[dependencies]
chanora_protocol = { path = "../chanora_protocol" }
thiserror.workspace = true
tracing.workspace = true
# Cross-platform audio I/O (DEC-011.1).
cpal = "0.16"
# Opus encoder. tsclientlib already pulls this; we depend explicitly so
# this crate can compile against it without going through tsclientlib.
audiopus = "0.3.0-rc.0"
# AudioHandler lives in the tsclientlib crate behind the `audio`
# feature. We import the crate just for the AudioHandler type; the
# Connection type stays inside chanora_protocol.
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls", "audio"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
[target.'cfg(target_os = "android")'.dependencies]
# JNI bindings to flip Android's AudioManager into MODE_IN_COMMUNICATION
# when the voice-comm preset is requested. ndk_context is initialised
# by the bridge crate's android_init shim.
jni = { version = "0.21", default-features = false }
ndk-context = "0.1"
[target.'cfg(target_os = "windows")'.dependencies]
# Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices
# + WM_INPUT translation backed by a hidden message-only window, and
# SetWindowsHookExW(WH_KEYBOARD_LL / WH_MOUSE_LL) fallback. Both
# require a per-backend OS thread that owns a message pump.
windows = { version = "0.54", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_System_LibraryLoader",
"Win32_System_Threading",
"Win32_UI_Input",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
] }
[dev-dependencies]
# `test-util` enables `start_paused` / virtual-clock tests used by
# the missed-key-up watchdog unit tests.
tokio = { version = "1", features = ["sync", "rt", "macros", "time", "test-util"] }
# Cross-platform recording Layer for the SDD-090 / DEC-027 privacy
# invariant integration test (`tests/ptt_privacy.rs`).
tracing-subscriber = { version = "0.3", features = ["registry"] }
[target.'cfg(target_os = "linux")'.dependencies]
# GNOME-on-Wayland Global Push-to-Talk uses the freedesktop
# `org.freedesktop.portal.GlobalShortcuts` interface over D-Bus.
# `zbus` is the standard async D-Bus crate; the `tokio` runtime
# selector is mandatory in zbus 5; we share the tokio runtime
# the rest of the audio + core crates already depend on. The
# `blocking-api` feature is retained so the audio-engine
# probe path can do a synchronous portal-version read without
# starting an async runtime; the live session flow uses the
# async surface.
zbus = { version = "5", default-features = false, features = ["tokio", "blocking-api"] }
# Stream / sink utilities for consuming portal signals on the
# async path.
futures-util = { version = "0.3", default-features = false, features = ["std"] }
# Random token bytes for the portal handle_token / session_handle_token
# options. The portal recommends fresh tokens to scope its own
# object paths per call.
rand = "0.8"
# SDL2 audio for Linux. Replaces the cpal capture / playback paths
# on Linux only; cpal stays in use on Windows/macOS. Rationale: the
# cpal Linux backend opens raw ALSA `default`, which on most Arch
# / Fedora / Debian installs routes through `dmix` + `plug` with
# nearest-neighbour resampling and very small period sizes — the
# combination produces audible crackling/popping. SDL2 on the same
# systems routes through PipeWire's PulseAudio compat bridge (or
# real PulseAudio), both of which carry a high-quality resampler
# and a sensible default period. The upstream tsclientlib audio
# example (`tsclientlib/examples/audio_utils/ts_to_audio.rs`) and
# the official Qint client both use SDL2 in exactly this shape;
# this dep brings Chanora in line with that pattern.
#
# `bundled` is OFF deliberately — we link against the system
# libSDL2.so. Arch ships `sdl2-compat`; Debian/Ubuntu ship
# `libsdl2-2.0-0`; Fedora ships `SDL2`. The chanora-flutter Linux
# build documentation lists this as a runtime dependency.
sdl2 = { version = "0.37", default-features = false }