The ring-buffer architecture (rc.8+73..+74) was making playback
strictly worse. Diagnostic data at +74 conclusively showed:
* Producer task ran perfectly at 50 Hz (250 ticks per 5 s).
* AudioHandler returned silence on 65-84% of fill_buffer calls
even when window_peak_f32 reached 0.98 (full-scale audio).
* Ring buffer never accumulated beyond 30 ms because consumer
(VPIO render callback at 43.5 Hz, ~1440 samples per call)
drained samples faster than the 50 Hz producer could push
them, in net effect.
The producer drained AudioHandler at 50 Hz \u2014 slightly faster
than iOS VPIO actually consumes audio. Each fill_buffer call
asked for 20 ms but adjacent Opus packets hadn't arrived yet, so
fill_buffer returned mostly silence. Linux/SDL's same pattern
works because SDL calls fill_buffer at EXACTLY the device
callback rate (50 Hz = 20 ms per buffer); the rates match.
Fix: revert to direct fill_buffer call from the render callback
(the SDL pattern in tsclientlib's own reference example at
tsclientlib/examples/audio_utils/ts_to_audio.rs). The render
callback now:
1. Resizes scratch_stereo Vec to 2 * num_frames f32 if needed
2. Zeros the live slice (fill_buffer is additive, not clearing)
3. Locks AudioHandler, calls fill_buffer(scratch_stereo)
4. Downmixes L+R -> mono i16 with master gain into out[]
5. Applies output_muted bypass
6. Tracks peak_out + audio/silence ratios for diagnostic
The closure owns scratch_stereo across callbacks for stable
allocation. Same memory model as Linux/SDL.
Removed:
* tokio::spawn producer task
* rtrb dep + RingBuffer<i16> + Producer/Consumer split
* tokio::sync::oneshot shutdown channel
* producer_shutdown_tx field on IosVoiceUnit struct
* RING_BUFFER_SAMPLES / PRODUCER_TICK_MS constants
* Producer-side diagnostic counters
Diagnostic kept: cb / num_frames / frames_changes /
callbacks_with_audio / callbacks_with_silence / peak_out_i16 /
gain. Logged every 100 callbacks.
The choppy / clicks symptom is independent of the buffer
architecture \u2014 it's whatever AudioHandler is doing on iOS
that's different from Linux. Next investigation step is to
either (a) switch from VPIO to RemoteIO unit (lose Apple's
voice processing entirely), or (b) understand why AudioHandler
returns silence so often on iOS-arrival packet timing patterns.
Build counter 74 -> 75.
127 lines
6.1 KiB
TOML
127 lines
6.1 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 = ["audio"] }
|
|
# Force the reqwest TLS backend to native-tls (Security.framework on
|
|
# Apple, SChannel on Windows, system OpenSSL on Linux/BSD) instead of
|
|
# the rustls + aws-lc-rs combination tsclientlib's `default-tls`
|
|
# feature would otherwise pick. aws-lc-sys does not cross-compile
|
|
# cleanly to aarch64-apple-ios, and native-tls is the standard
|
|
# answer for "TLS that just works on every desktop+mobile platform
|
|
# without a vendored C dependency". Cargo's feature unification means
|
|
# this single direct dep applies to the transitive
|
|
# tsclientlib -> reqwest chain too.
|
|
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
|
|
|
|
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
|
|
|
[target.'cfg(target_os = "ios")'.dependencies]
|
|
# Direct CoreAudio AudioUnit access on iOS (DEC-011.x follow-up).
|
|
# cpal's iOS backend is unsuitable for VoIP: it opens
|
|
# kAudioUnitSubType_RemoteIO with a mono-only output element and no
|
|
# control over buffer size / sample rate, AND its AudioUnit stays
|
|
# bound to the route present at construction time so user-driven
|
|
# `overrideOutputAudioPort` flips do not actually move audio to the
|
|
# new transducer. Every production iOS VoIP client (Linphone, Mumble
|
|
# iOS, Signal, Jitsi, WebRTC reference) instead drives
|
|
# `kAudioUnitSubType_VoiceProcessingIO` (a.k.a. VPIO) directly. VPIO
|
|
# is Apple's recommended voice unit; it ships hardware AEC + AGC + NS
|
|
# and honours route changes natively because it IS the canonical
|
|
# voice unit on iOS. `coreaudio-rs` (RustAudio org, same maintainers
|
|
# as `cpal`, 8.6M downloads) gives us a safe wrapper around the
|
|
# AudioUnit C API. We use it on iOS only; cpal stays on macOS where
|
|
# its CoreAudio backend works well against HAL units.
|
|
#
|
|
# Default features keep `audio_toolbox` + `core_audio`, both required
|
|
# for AudioUnit construction + property access.
|
|
coreaudio-rs = "0.14"
|
|
|
|
[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 }
|