chore: restore product scaffold to rollback baseline

This commit is contained in:
Edison Jwa
2026-05-29 14:02:04 +09:00
parent 2896f14ec9
commit fe6e07353e
434 changed files with 27278 additions and 63230 deletions
+36 -28
View File
@@ -9,10 +9,6 @@ license.workspace = true
repository.workspace = true
publish.workspace = true
[features]
default = []
bench-harness = ["dep:criterion", "dep:dhat", "dep:serde_json"]
[dependencies]
chanora_protocol = { path = "../chanora_protocol" }
thiserror.workspace = true
@@ -33,13 +29,10 @@ audiopus = "0.3.0-rc.0"
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
rustfft = "6.2.0"
criterion = { version = "0.5", optional = true }
dhat = { version = "0.3", optional = true }
serde_json = { version = "1", optional = true }
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
# Desktop audio I/O for Windows capture/playback and Linux fallback metadata.
# Linux voice I/O uses PipeWire first and PulseAudio as fallback; Apple platforms use direct VoiceProcessingIO
# Desktop audio I/O for Windows capture/playback and Linux capture.
# Linux playback uses SDL2; Apple platforms use direct VoiceProcessingIO
# AudioUnits via `coreaudio-rs` for the voice path.
cpal = "0.17.3"
@@ -84,7 +77,7 @@ ndk-context = "0.1"
# - get_raw_session_id() for JNI hardware effect binding
# - deduplicated macro impls
# - PowerSavingOffloaded PerformanceMode variant
oboe = { path = "../../../oboe-rs" }
oboe = { git = "https://github.com/EdisonJwa/oboe-rs", rev = "a14f9b83ecea8c93f5a692f2ee7808445b938c35" }
[target.'cfg(target_os = "windows")'.dependencies]
# Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices
@@ -108,33 +101,34 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time", "test-util"
# Cross-platform recording Layer for the SDD-090 / DEC-027 privacy
# invariant integration test (`tests/ptt_privacy.rs`).
tracing-subscriber = { version = "0.3", features = ["registry"] }
# SDD-120 §3 — criterion bench harness (realtime_capture / opus_codec /
# resampler). `harness = false` per bench entry below disables the
# default libtest harness so criterion can install its own.
criterion = "0.5"
# SDD-120 §3 item 1 — dhat is used as the global allocator inside
# `benches/realtime_capture.rs` to count post-warmup heap allocations
# on the realtime capture path. Dev-dep only — does NOT affect
# production builds.
dhat = "0.3"
# SDD-120 §5 / §8 — JSON serialization for `emit_baseline` /
# `compare_baseline` binaries that consume criterion's per-bench
# `estimates.json` outputs and emit the SRS-217 baseline schema.
serde_json = "1"
[[bench]]
name = "realtime_capture"
harness = false
path = "benches/realtime_capture.rs"
required-features = ["bench-harness"]
[[bench]]
name = "opus_codec"
harness = false
path = "benches/opus_codec.rs"
required-features = ["bench-harness"]
[[bench]]
name = "resampler"
harness = false
path = "benches/resampler.rs"
required-features = ["bench-harness"]
[[example]]
name = "compare_baseline"
path = "examples/compare_baseline.rs"
required-features = ["bench-harness"]
[[example]]
name = "emit_baseline"
path = "examples/emit_baseline.rs"
required-features = ["bench-harness"]
[target.'cfg(target_os = "linux")'.dependencies]
# GNOME-on-Wayland Global Push-to-Talk uses the freedesktop
@@ -154,8 +148,22 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
# options. The portal recommends fresh tokens to scope its own
# object paths per call.
rand = "0.8"
# Linux-native voice I/O. PipeWire is primary on modern desktops;
# PulseAudio is retained as fallback and for older environments.
pipewire = { version = "0.10", features = ["v0_3_44"] }
libpulse-binding = "2.30"
libpulse-simple-binding = "2.29"
# SDL2 audio for Linux. Replaces the cpal playback path on Linux only;
# cpal stays in use for Linux capture and Windows capture/playback.
# Apple platforms use direct VoiceProcessingIO AudioUnits. Rationale: the
# cpal Linux backend opens raw ALSA `default`, which on most Arch
# / Fedora / Debian installs routes through `dmix` + `plug` with
# nearest-neighbour resampling and very small period sizes — the
# combination produces audible crackling/popping. SDL2 on the same
# systems routes through PipeWire's PulseAudio compat bridge (or
# real PulseAudio), both of which carry a high-quality resampler
# and a sensible default period. The upstream tsclientlib audio
# example (`tsclientlib/examples/audio_utils/ts_to_audio.rs`) and
# the official Qint client both use SDL2 in exactly this shape;
# this dep brings Chanora in line with that pattern.
#
# `bundled` is OFF deliberately — we link against the system
# libSDL2.so. Arch ships `sdl2-compat`; Debian/Ubuntu ship
# `libsdl2-2.0-0`; Fedora ships `SDL2`. The chanora-flutter Linux
# build documentation lists this as a runtime dependency.
sdl2 = { version = "0.37", default-features = false }
+82 -261
View File
@@ -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,
+15 -11
View File
@@ -25,7 +25,7 @@
//! stream-format requests on bus 0 (output) and bus 1 (input).
//!
//! This file replaces the cpal capture + playback streams on iOS and macOS.
//! Linux uses 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();
+2 -4
View File
@@ -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")
);
}
}
+35 -51
View File
@@ -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.
+203
View File
@@ -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;
}
}
}
}
+2 -137
View File
@@ -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);
}
}
+117 -13
View File
@@ -355,8 +355,6 @@ pub struct BridgeChannel {
pub struct BridgeClient {
/// Stable client id.
pub id: u64,
/// Stable TeamSpeak unique identifier.
pub uid: String,
/// Channel id the client is currently in.
pub channel: u64,
/// Nickname.
@@ -375,6 +373,61 @@ pub struct BridgeClient {
pub talk_power_granted: bool,
}
/// Rich profile and live connection details for one online client.
#[derive(Debug, Clone)]
pub struct BridgeClientProfile {
/// Stable online client id.
pub id: u64,
/// Channel the client is currently in.
pub channel: u64,
/// Nickname.
pub name: String,
/// TeamSpeak unique id.
pub unique_id: String,
/// Stable TeamSpeak database id, when visible.
pub database_id: Option<u64>,
/// ISO country code, when visible.
pub country_code: String,
/// User description, when visible.
pub description: String,
/// Client version string.
pub version: String,
/// Client platform string.
pub platform: String,
/// Account creation time as Unix seconds.
pub created_unix_seconds: Option<i64>,
/// Last connection time as Unix seconds.
pub last_connected_unix_seconds: Option<i64>,
/// Total historical connections.
pub connections_total: Option<u64>,
/// Current online duration in seconds.
pub online_seconds: Option<i64>,
/// Current idle time in milliseconds.
pub idle_milliseconds: Option<i64>,
/// Current ping in milliseconds.
pub ping_milliseconds: Option<i64>,
/// Client address. Empty when permission-gated.
pub client_address: String,
/// Resolved server group names.
pub server_groups: Vec<String>,
/// Resolved channel group name.
pub channel_group: String,
/// TeamSpeak avatar file path suffix.
pub avatar_path: String,
/// Downloaded bytes this month.
pub bytes_downloaded_month: Option<u64>,
/// Uploaded bytes this month.
pub bytes_uploaded_month: Option<u64>,
/// Downloaded bytes across all time.
pub bytes_downloaded_total: Option<u64>,
/// Uploaded bytes across all time.
pub bytes_uploaded_total: Option<u64>,
/// Client-to-server total packet loss ratio.
pub packet_loss_client_to_server_total: Option<f32>,
/// Server-to-client total packet loss ratio.
pub packet_loss_server_to_client_total: Option<f32>,
}
/// Server snapshot as seen by Dart.
#[derive(Debug, Clone)]
pub struct BridgeSnapshot {
@@ -396,6 +449,38 @@ pub struct BridgeSnapshot {
pub own_client_id: u64,
}
impl From<chanora_core::ClientProfile> for BridgeClientProfile {
fn from(profile: chanora_core::ClientProfile) -> Self {
Self {
id: profile.id.0,
channel: profile.channel.0,
name: profile.name,
unique_id: profile.unique_id,
database_id: profile.database_id,
country_code: profile.country_code,
description: profile.description,
version: profile.version,
platform: profile.platform,
created_unix_seconds: profile.created_unix_seconds,
last_connected_unix_seconds: profile.last_connected_unix_seconds,
connections_total: profile.connections_total,
online_seconds: profile.online_seconds,
idle_milliseconds: profile.idle_milliseconds,
ping_milliseconds: profile.ping_milliseconds,
client_address: profile.client_address,
server_groups: profile.server_groups,
channel_group: profile.channel_group,
avatar_path: profile.avatar_path,
bytes_downloaded_month: profile.bytes_downloaded_month,
bytes_uploaded_month: profile.bytes_uploaded_month,
bytes_downloaded_total: profile.bytes_downloaded_total,
bytes_uploaded_total: profile.bytes_uploaded_total,
packet_loss_client_to_server_total: profile.packet_loss_client_to_server_total,
packet_loss_server_to_client_total: profile.packet_loss_server_to_client_total,
}
}
}
impl From<chanora_core::ServerSnapshot> for BridgeSnapshot {
fn from(s: chanora_core::ServerSnapshot) -> Self {
Self {
@@ -420,7 +505,6 @@ impl From<chanora_core::ServerSnapshot> for BridgeSnapshot {
.into_iter()
.map(|c| BridgeClient {
id: c.id.0,
uid: c.uid,
channel: c.channel.0,
name: c.name,
input_muted: c.input_muted,
@@ -459,6 +543,7 @@ pub async fn connect(
},
identity: None,
ready_timeout: Duration::from_secs(15),
resolved_address: None,
};
let snap = match runtime()
.spawn(async move { session().connect(cfg).await })
@@ -475,6 +560,17 @@ pub async fn connect(
Ok(snap.into())
}
/// Warm server address resolution for the active host field. This is
/// intentionally fire-and-forget from the UI perspective: it schedules
/// Rust-side prefetch work and never opens a TS3 session.
pub async fn prefetch_server(host: String) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().prefetch_server(host).await })
.await
.map_err(|e| task_join_error("prefetch_server", e))??;
Ok(())
}
/// Re-fetch a fresh snapshot from the active connection.
pub async fn snapshot() -> Result<BridgeSnapshot, BridgeError> {
let snap = runtime()
@@ -484,6 +580,15 @@ pub async fn snapshot() -> Result<BridgeSnapshot, BridgeError> {
Ok(snap.into())
}
/// Fetch richer profile and live connection details for one online client.
pub async fn client_profile(client_id: u64) -> Result<BridgeClientProfile, BridgeError> {
let profile = runtime()
.spawn(async move { session().client_profile(client_id).await })
.await
.map_err(|e| task_join_error("client_profile", e))??;
Ok(profile.into())
}
/// Disconnect from the server. No-op if not connected.
pub async fn disconnect() -> Result<(), BridgeError> {
runtime()
@@ -816,9 +921,10 @@ pub async fn set_input_muted(muted: bool) -> Result<(), BridgeError> {
Ok(())
}
/// Toggle self output-mute (speaker). Mutes locally, informs the
/// server, and clamps local microphone transmit while speaker mute
/// is active so the user cannot continue talking while deafened.
/// Toggle self output-mute (speaker). Mutes locally *and* informs
/// the server. The server uses this for the channel icon next to
/// the client name; the local mute kicks in immediately even
/// before the server acknowledges.
pub async fn set_output_muted(muted: bool) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_self_muted(None, Some(muted)).await })
@@ -1229,7 +1335,8 @@ impl From<chanora_core::AudioProcessingStats> for BridgeAudioProcessingStats {
/// On non-Android targets or before a voice session opens the
/// section is omitted. Per SDD-090 every field in that section is
/// a device-side technical scalar — no PII admitted.
pub async fn export_diagnostics() -> String {
#[frb(sync)]
pub fn export_diagnostics() -> String {
let metadata = vec![
(
"crate_version".to_string(),
@@ -1245,15 +1352,12 @@ pub async fn export_diagnostics() -> String {
// diagnostics snapshot from the process-global slot published
// by AndroidVoiceUnit::open(). Returns None on non-Android and
// before any voice session has opened.
#[cfg(target_os = "android")]
let android_audio_yaml =
chanora_audio::mobile_voice_backend::current_android_audio_diagnostics()
.map(|d| d.to_yaml_fragment());
#[cfg(not(target_os = "android"))]
let android_audio_yaml: Option<String> = None;
let audio_health = session().audio_processing_stats().await.ok();
let network_info = session().network_diagnostics_summary().await;
let protocol_events = session().drain_protocol_events().await;
let audio_health = session().audio_processing_stats_if_ready();
let network_info = runtime().block_on(async { session().network_diagnostics_summary().await });
let protocol_events = runtime().block_on(async { session().protocol_events_snapshot().await });
let android_audio_yaml = android_audio_yaml.map(|mut yaml| {
if let Some(stats) = audio_health {
yaml.push_str(&format!(
+329 -62
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -560177922;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 281698435;
// Section: executor
@@ -186,6 +186,42 @@ fn wire__crate__api__bridge_init_impl(
},
)
}
fn wire__crate__api__client_profile_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "client_profile",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_client_id = <u64>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::client_profile(api_client_id).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__connect_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -370,16 +406,15 @@ fn wire__crate__api__events_stream_impl(
)
}
fn wire__crate__api__export_diagnostics_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "export_diagnostics",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
@@ -392,15 +427,10 @@ fn wire__crate__api__export_diagnostics_impl(
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::export_diagnostics().await;
Ok(output_ok)
})()
.await,
)
}
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok(crate::api::export_diagnostics())?;
Ok(output_ok)
})())
},
)
}
@@ -880,6 +910,42 @@ fn wire__crate__api__move_to_channel_impl(
},
)
}
fn wire__crate__api__prefetch_server_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "prefetch_server",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_host = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::prefetch_server(api_host).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__ptt_descriptor_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1946,7 +2012,6 @@ impl SseDecode for crate::api::BridgeClient {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_id = <u64>::sse_decode(deserializer);
let mut var_uid = <String>::sse_decode(deserializer);
let mut var_channel = <u64>::sse_decode(deserializer);
let mut var_name = <String>::sse_decode(deserializer);
let mut var_inputMuted = <bool>::sse_decode(deserializer);
@@ -1957,7 +2022,6 @@ impl SseDecode for crate::api::BridgeClient {
let mut var_talkPowerGranted = <bool>::sse_decode(deserializer);
return crate::api::BridgeClient {
id: var_id,
uid: var_uid,
channel: var_channel,
name: var_name,
input_muted: var_inputMuted,
@@ -1970,6 +2034,64 @@ impl SseDecode for crate::api::BridgeClient {
}
}
impl SseDecode for crate::api::BridgeClientProfile {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_id = <u64>::sse_decode(deserializer);
let mut var_channel = <u64>::sse_decode(deserializer);
let mut var_name = <String>::sse_decode(deserializer);
let mut var_uniqueId = <String>::sse_decode(deserializer);
let mut var_databaseId = <Option<u64>>::sse_decode(deserializer);
let mut var_countryCode = <String>::sse_decode(deserializer);
let mut var_description = <String>::sse_decode(deserializer);
let mut var_version = <String>::sse_decode(deserializer);
let mut var_platform = <String>::sse_decode(deserializer);
let mut var_createdUnixSeconds = <Option<i64>>::sse_decode(deserializer);
let mut var_lastConnectedUnixSeconds = <Option<i64>>::sse_decode(deserializer);
let mut var_connectionsTotal = <Option<u64>>::sse_decode(deserializer);
let mut var_onlineSeconds = <Option<i64>>::sse_decode(deserializer);
let mut var_idleMilliseconds = <Option<i64>>::sse_decode(deserializer);
let mut var_pingMilliseconds = <Option<i64>>::sse_decode(deserializer);
let mut var_clientAddress = <String>::sse_decode(deserializer);
let mut var_serverGroups = <Vec<String>>::sse_decode(deserializer);
let mut var_channelGroup = <String>::sse_decode(deserializer);
let mut var_avatarPath = <String>::sse_decode(deserializer);
let mut var_bytesDownloadedMonth = <Option<u64>>::sse_decode(deserializer);
let mut var_bytesUploadedMonth = <Option<u64>>::sse_decode(deserializer);
let mut var_bytesDownloadedTotal = <Option<u64>>::sse_decode(deserializer);
let mut var_bytesUploadedTotal = <Option<u64>>::sse_decode(deserializer);
let mut var_packetLossClientToServerTotal = <Option<f32>>::sse_decode(deserializer);
let mut var_packetLossServerToClientTotal = <Option<f32>>::sse_decode(deserializer);
return crate::api::BridgeClientProfile {
id: var_id,
channel: var_channel,
name: var_name,
unique_id: var_uniqueId,
database_id: var_databaseId,
country_code: var_countryCode,
description: var_description,
version: var_version,
platform: var_platform,
created_unix_seconds: var_createdUnixSeconds,
last_connected_unix_seconds: var_lastConnectedUnixSeconds,
connections_total: var_connectionsTotal,
online_seconds: var_onlineSeconds,
idle_milliseconds: var_idleMilliseconds,
ping_milliseconds: var_pingMilliseconds,
client_address: var_clientAddress,
server_groups: var_serverGroups,
channel_group: var_channelGroup,
avatar_path: var_avatarPath,
bytes_downloaded_month: var_bytesDownloadedMonth,
bytes_uploaded_month: var_bytesUploadedMonth,
bytes_downloaded_total: var_bytesDownloadedTotal,
bytes_uploaded_total: var_bytesUploadedTotal,
packet_loss_client_to_server_total: var_packetLossClientToServerTotal,
packet_loss_server_to_client_total: var_packetLossServerToClientTotal,
};
}
}
impl SseDecode for crate::api::BridgeEffectOwner {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2351,6 +2473,18 @@ impl SseDecode for i64 {
}
}
impl SseDecode for Vec<String> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut len_ = <i32>::sse_decode(deserializer);
let mut ans_ = Vec::with_capacity(len_ as usize);
for idx_ in 0..len_ {
ans_.push(<String>::sse_decode(deserializer));
}
return ans_;
}
}
impl SseDecode for Vec<crate::api::BridgeAudioDevice> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2435,6 +2569,17 @@ impl SseDecode for Option<crate::api::BridgeVoiceJoinErrorCode> {
}
}
impl SseDecode for Option<f32> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
if (<bool>::sse_decode(deserializer)) {
return Some(<f32>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for Option<i32> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2446,6 +2591,17 @@ impl SseDecode for Option<i32> {
}
}
impl SseDecode for Option<i64> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
if (<bool>::sse_decode(deserializer)) {
return Some(<i64>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for Option<u64> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2510,43 +2666,44 @@ fn pde_ffi_dispatcher_primary_impl(
2 => wire__crate__api__audio_processing_stats_impl(port, ptr, rust_vec_len, data_len),
3 => wire__crate__api__audio_stats_impl(port, ptr, rust_vec_len, data_len),
4 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len),
5 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
7 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
8 => wire__crate__api__enable_audio_debug_wav_dump_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__export_diagnostics_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__api__get_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
34 => {
5 => wire__crate__api__client_profile_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
7 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
8 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__api__enable_audio_debug_wav_dump_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__get_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
36 => {
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
}
36 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
49 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2559,18 +2716,19 @@ fn pde_ffi_dispatcher_sync_impl(
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id {
15 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
16 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
17 => wire__crate__api__handle_media_services_reset_with_route_impl(
11 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
16 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
17 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
18 => wire__crate__api__handle_media_services_reset_with_route_impl(
ptr,
rust_vec_len,
data_len,
),
18 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
23 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
26 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
28 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
24 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
28 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2804,7 +2962,6 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeClient {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.id.into_into_dart().into_dart(),
self.uid.into_into_dart().into_dart(),
self.channel.into_into_dart().into_dart(),
self.name.into_into_dart().into_dart(),
self.input_muted.into_into_dart().into_dart(),
@@ -2824,6 +2981,56 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeClient> for crate::api:
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeClientProfile {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.id.into_into_dart().into_dart(),
self.channel.into_into_dart().into_dart(),
self.name.into_into_dart().into_dart(),
self.unique_id.into_into_dart().into_dart(),
self.database_id.into_into_dart().into_dart(),
self.country_code.into_into_dart().into_dart(),
self.description.into_into_dart().into_dart(),
self.version.into_into_dart().into_dart(),
self.platform.into_into_dart().into_dart(),
self.created_unix_seconds.into_into_dart().into_dart(),
self.last_connected_unix_seconds
.into_into_dart()
.into_dart(),
self.connections_total.into_into_dart().into_dart(),
self.online_seconds.into_into_dart().into_dart(),
self.idle_milliseconds.into_into_dart().into_dart(),
self.ping_milliseconds.into_into_dart().into_dart(),
self.client_address.into_into_dart().into_dart(),
self.server_groups.into_into_dart().into_dart(),
self.channel_group.into_into_dart().into_dart(),
self.avatar_path.into_into_dart().into_dart(),
self.bytes_downloaded_month.into_into_dart().into_dart(),
self.bytes_uploaded_month.into_into_dart().into_dart(),
self.bytes_downloaded_total.into_into_dart().into_dart(),
self.bytes_uploaded_total.into_into_dart().into_dart(),
self.packet_loss_client_to_server_total
.into_into_dart()
.into_dart(),
self.packet_loss_server_to_client_total
.into_into_dart()
.into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgeClientProfile
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeClientProfile>
for crate::api::BridgeClientProfile
{
fn into_into_dart(self) -> crate::api::BridgeClientProfile {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeEffectOwner {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
@@ -3443,7 +3650,6 @@ impl SseEncode for crate::api::BridgeClient {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<u64>::sse_encode(self.id, serializer);
<String>::sse_encode(self.uid, serializer);
<u64>::sse_encode(self.channel, serializer);
<String>::sse_encode(self.name, serializer);
<bool>::sse_encode(self.input_muted, serializer);
@@ -3455,6 +3661,37 @@ impl SseEncode for crate::api::BridgeClient {
}
}
impl SseEncode for crate::api::BridgeClientProfile {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<u64>::sse_encode(self.id, serializer);
<u64>::sse_encode(self.channel, serializer);
<String>::sse_encode(self.name, serializer);
<String>::sse_encode(self.unique_id, serializer);
<Option<u64>>::sse_encode(self.database_id, serializer);
<String>::sse_encode(self.country_code, serializer);
<String>::sse_encode(self.description, serializer);
<String>::sse_encode(self.version, serializer);
<String>::sse_encode(self.platform, serializer);
<Option<i64>>::sse_encode(self.created_unix_seconds, serializer);
<Option<i64>>::sse_encode(self.last_connected_unix_seconds, serializer);
<Option<u64>>::sse_encode(self.connections_total, serializer);
<Option<i64>>::sse_encode(self.online_seconds, serializer);
<Option<i64>>::sse_encode(self.idle_milliseconds, serializer);
<Option<i64>>::sse_encode(self.ping_milliseconds, serializer);
<String>::sse_encode(self.client_address, serializer);
<Vec<String>>::sse_encode(self.server_groups, serializer);
<String>::sse_encode(self.channel_group, serializer);
<String>::sse_encode(self.avatar_path, serializer);
<Option<u64>>::sse_encode(self.bytes_downloaded_month, serializer);
<Option<u64>>::sse_encode(self.bytes_uploaded_month, serializer);
<Option<u64>>::sse_encode(self.bytes_downloaded_total, serializer);
<Option<u64>>::sse_encode(self.bytes_uploaded_total, serializer);
<Option<f32>>::sse_encode(self.packet_loss_client_to_server_total, serializer);
<Option<f32>>::sse_encode(self.packet_loss_server_to_client_total, serializer);
}
}
impl SseEncode for crate::api::BridgeEffectOwner {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3828,6 +4065,16 @@ impl SseEncode for i64 {
}
}
impl SseEncode for Vec<String> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i32>::sse_encode(self.len() as _, serializer);
for item in self {
<String>::sse_encode(item, serializer);
}
}
}
impl SseEncode for Vec<crate::api::BridgeAudioDevice> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3898,6 +4145,16 @@ impl SseEncode for Option<crate::api::BridgeVoiceJoinErrorCode> {
}
}
impl SseEncode for Option<f32> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<f32>::sse_encode(value, serializer);
}
}
}
impl SseEncode for Option<i32> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3908,6 +4165,16 @@ impl SseEncode for Option<i32> {
}
}
impl SseEncode for Option<i64> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<i64>::sse_encode(value, serializer);
}
}
}
impl SseEncode for Option<u64> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
+18
View File
@@ -774,6 +774,11 @@ impl ProtocolEventRecorder {
pub fn drain(&mut self) -> Vec<String> {
std::mem::take(&mut self.events)
}
/// Snapshot all recorded events without clearing the buffer.
pub fn snapshot(&self) -> Vec<String> {
self.events.clone()
}
}
impl Default for ProtocolEventRecorder {
@@ -1080,4 +1085,17 @@ mod tests {
"explicit with_android_audio(None) must also omit the section"
);
}
#[test]
fn protocol_event_snapshot_does_not_drain_buffer() {
let mut recorder = ProtocolEventRecorder::new(4);
recorder.record_connected("Server");
let first = recorder.snapshot();
let second = recorder.snapshot();
let drained = recorder.drain();
assert_eq!(first, second);
assert_eq!(drained, first);
}
}
+8 -9
View File
@@ -13,8 +13,9 @@ publish.workspace = true
thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
base64 = "0.22"
chanora_resolver = { path = "../chanora_resolver" }
base64 = "0.22"
time = "0.3"
# tsclientlib is git-only and not on crates.io. The "audio" feature
# pulls in `audiopus` only — `sdl2` is a dev-dep used by upstream
@@ -32,14 +33,12 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"
futures = "0.3"
async-trait = "0.1"
[target.'cfg(any(target_os = "ios", target_os = "macos", target_os = "windows"))'.dependencies]
# Apple + Windows keep the platform TLS backend (Security.framework /
# SChannel). iOS in particular avoids the rustls/aws-lc path here.
[target.'cfg(not(target_os = "android"))'.dependencies]
# Desktop/iOS: native TLS maps to the platform TLS backend (Security.framework
# on Apple, SChannel on Windows, system OpenSSL on Linux/BSD).
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
[target.'cfg(any(target_os = "android", target_os = "linux"))'.dependencies]
# Android + Linux avoid a system OpenSSL dependency and use rustls instead.
# This keeps Linux CI / dev hosts buildable without `openssl-devel`, while
# preserving the native-tls path for Apple targets where rustls/aws-lc has
# been problematic.
[target.'cfg(target_os = "android")'.dependencies]
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
# native-tls for Apple targets where aws-lc/rustls is problematic for iOS.
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] }
+439 -98
View File
@@ -28,21 +28,44 @@ use tokio::sync::{mpsc, oneshot};
use tracing::{info, warn};
use tsclientlib::data::{self, Channel, Client};
use tsclientlib::messages::s2c::{InClientDbInfoPart, InMessage};
use tsclientlib::prelude::*;
use tsclientlib::{
ChannelId as TsChannelId, Connection, DisconnectOptions, Identity, MessageHandle,
OutCommandExt, StreamItem, Version,
ChannelId as TsChannelId, ClientId as TsClientId, Connection, DisconnectOptions, Identity,
MessageHandle, OutCommandExt, StreamItem, Version,
};
use tsproto_packets::packets::{InAudioBuf, OutPacket};
use tsproto_packets::packets::{Direction, Flags, InAudioBuf, OutCommand, OutPacket, PacketType};
use tsproto_types::ClientType;
use crate::dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerActivity,
ServerSnapshot,
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
ServerActivity, ServerSnapshot,
};
use crate::ProtocolError;
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40);
#[derive(Debug, PartialEq, Eq)]
enum SendTimeoutError<T> {
Timeout(T),
Closed(T),
}
async fn send_with_timeout<T: Send>(
tx: &mpsc::Sender<T>,
value: T,
timeout_duration: Duration,
) -> Result<(), SendTimeoutError<T>> {
match tokio::time::timeout(timeout_duration, tx.reserve()).await {
Ok(Ok(permit)) => {
permit.send(value);
Ok(())
}
Ok(Err(_)) => Err(SendTimeoutError::Closed(value)),
Err(_) => Err(SendTimeoutError::Timeout(value)),
}
}
/// Pick the TeamSpeak `client_version`/platform/signature triple
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
@@ -116,6 +139,11 @@ pub struct ConnectConfig {
/// How long to wait for the initial state snapshot before
/// returning `ProtocolError::Timeout`.
pub ready_timeout: Duration,
/// Optional already-resolved socket address from core's invisible
/// prefetch cache. When present, the protocol layer skips address
/// resolution but still opens a normal TS3 connection only after
/// the user requested Connect.
pub resolved_address: Option<std::net::SocketAddr>,
}
impl Default for ConnectConfig {
@@ -126,6 +154,7 @@ impl Default for ConnectConfig {
password: None,
identity: None,
ready_timeout: Duration::from_secs(10),
resolved_address: None,
}
}
}
@@ -159,6 +188,11 @@ enum Request {
/// Reply channel for outcome.
reply: oneshot::Sender<Result<(), ProtocolError>>,
},
/// Fetch richer profile/connection details for an online client.
FetchClientProfile {
client_id: u64,
reply: oneshot::Sender<Result<ClientProfile, ProtocolError>>,
},
}
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
@@ -297,6 +331,20 @@ impl ProtocolClient {
.map_err(|_| ProtocolError::Lost("snapshot reply dropped".to_string()))?
}
/// Fetch richer profile and live connection details for one online client.
pub async fn client_profile(&self, client_id: u64) -> Result<ClientProfile, ProtocolError> {
let (tx, rx) = oneshot::channel();
self.tx
.send(Request::FetchClientProfile {
client_id,
reply: tx,
})
.await
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
rx.await
.map_err(|_| ProtocolError::Lost("client_profile reply dropped".to_string()))?
}
/// Disconnect cleanly. Blocks until the task exits.
pub async fn disconnect(self) {
let (tx, rx) = oneshot::channel();
@@ -493,16 +541,30 @@ async fn connection_task(
}};
}
let resolved = match resolve_server_socket(&cfg.address).await {
Ok(addr) => addr,
Err(err) => fail_ready!(err),
let resolved = match server_socket_from_config(&cfg) {
Some(addr) => {
info!(
target: "chanora_protocol",
input = %cfg.address,
resolved = %addr,
"using prefetched server address"
);
addr
}
None => {
let addr = match resolve_server_socket(&cfg.address).await {
Ok(addr) => addr,
Err(err) => fail_ready!(err),
};
info!(
target: "chanora_protocol",
input = %cfg.address,
resolved = %addr,
"server address resolved"
);
addr
}
};
info!(
target: "chanora_protocol",
input = %cfg.address,
resolved = %resolved,
"server address resolved"
);
// Pass the resolved SocketAddr directly to tsclientlib so it
// skips its own resolver entirely (tsclientlib accepts
@@ -619,14 +681,6 @@ async fn connection_task(
std::time::Instant,
),
> = HashMap::new();
let mut pending_text_messages: HashMap<
MessageHandle,
(
MessageTarget,
oneshot::Sender<Result<(), ProtocolError>>,
std::time::Instant,
),
> = HashMap::new();
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
// Main loop: pump events, service requests, forward voice.
@@ -650,14 +704,33 @@ async fn connection_task(
let from = packet_sender_id(&buf);
if let Some(from) = from {
voice_activity.insert(from, Instant::now());
if voice_in_tx
.try_send(InboundVoice {
from_client: from,
packet: buf,
})
.is_err()
let inbound = InboundVoice {
from_client: from,
packet: buf,
};
match send_with_timeout(
&voice_in_tx,
inbound,
INBOUND_VOICE_SEND_TIMEOUT,
)
.await
{
// Subscriber is too slow or absent; drop.
Ok(()) => {}
Err(SendTimeoutError::Timeout(_)) => {
warn!(
target: "chanora_protocol",
from_client = from,
timeout_ms = INBOUND_VOICE_SEND_TIMEOUT.as_millis() as u64,
"inbound voice queue stayed full; dropping packet"
);
}
Err(SendTimeoutError::Closed(_)) => {
warn!(
target: "chanora_protocol",
from_client = from,
"inbound voice consumer closed; dropping packet"
);
}
}
}
}
@@ -735,7 +808,28 @@ async fn connection_task(
if let Some((_target_channel, reply, _deadline)) =
pending_moves.remove(&handle)
{
let mapped = map_command_result(result, "client_move");
let mapped = match result {
Ok(()) => Ok(()),
Err(cmd_err) => {
// tsclientlib's CommandError carries a
// typed `TsError` (the canonical TS3
// error code) plus an optional missing
// permission. We convert to our typed
// ProtocolError::ServerRejected so the
// upper layers can render a localised
// explanation by code instead of a
// generic backend string.
let code = cmd_err.error as u32;
let message = cmd_err.error.to_string();
info!(
target: "chanora_protocol",
code,
message = %message,
"server rejected client_move"
);
Err(ProtocolError::ServerRejected { code, message })
}
};
if let Some(reply) = reply {
let _ = reply.send(mapped);
} else if let Err(err) = mapped {
@@ -745,11 +839,6 @@ async fn connection_task(
"client_move completed in background with error"
);
}
} else if let Some((_target, reply, _deadline)) =
pending_text_messages.remove(&handle)
{
let mapped = map_command_result(result, "text_message");
let _ = reply.send(mapped);
}
}
_ => { /* book / message / other events: ignore */ }
@@ -794,24 +883,6 @@ async fn connection_task(
}
}
}
if !pending_text_messages.is_empty() {
let now = std::time::Instant::now();
let expired: Vec<MessageHandle> = pending_text_messages
.iter()
.filter_map(|(handle, (_, _, deadline))| {
if now >= *deadline {
Some(*handle)
} else {
None
}
})
.collect();
for handle in expired {
if let Some((_target, reply, _)) = pending_text_messages.remove(&handle) {
let _ = reply.send(Err(ProtocolError::Timeout));
}
}
}
// 3. Service at most one control request (non-blocking).
match rx.try_recv() {
@@ -861,15 +932,14 @@ async fn connection_task(
message,
target,
reply,
}) => match send_text_message(&mut con, &message, target) {
Ok(handle) => {
let deadline = std::time::Instant::now() + Duration::from_secs(3);
pending_text_messages.insert(handle, (target, reply, deadline));
}
Err(e) => {
let _ = reply.send(Err(e));
}
},
}) => {
let r = send_text_message(&mut con, &message, target);
let _ = reply.send(r);
}
Ok(Request::FetchClientProfile { client_id, reply }) => {
let r = fetch_client_profile(&mut con, client_id).await;
let _ = reply.send(r);
}
Ok(Request::Disconnect(reply)) => {
let _ = con.disconnect(DisconnectOptions::new());
con.events().for_each(|_| future::ready(())).await;
@@ -908,6 +978,10 @@ async fn resolve_server_socket(address: &str) -> Result<SocketAddr, ProtocolErro
})
}
fn server_socket_from_config(cfg: &ConnectConfig) -> Option<SocketAddr> {
cfg.resolved_address
}
/// Move our own client into `channel_id` with an optional password.
/// Looks up our `own_client` in the current state and dispatches the
/// generated `client_move` command via `send_with_result`. The
@@ -973,11 +1047,11 @@ fn send_text_message(
con: &mut Connection,
message: &str,
target: MessageTarget,
) -> Result<MessageHandle, ProtocolError> {
) -> Result<(), ProtocolError> {
use tsproto_types::TextMessageTargetMode;
match target {
MessageTarget::Server => {
send_text_to_mode(con, message, TextMessageTargetMode::Server, "server")
send_text_to_mode(con, message, TextMessageTargetMode::Server, "server")?;
}
MessageTarget::Channel => {
// Fix: previously channel messages were sent via
@@ -985,33 +1059,31 @@ fn send_text_message(
// TextMessageTargetMode::Server. Now correctly uses
// TextMessageTargetMode::Channel so the message is
// scoped to the current channel, not server-wide.
send_text_to_mode(con, message, TextMessageTargetMode::Channel, "channel")
send_text_to_mode(con, message, TextMessageTargetMode::Channel, "channel")?;
}
MessageTarget::Client(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = find_client_by_id(state.clients.values(), client_id)?;
let handle = client
client
.send_textmessage(message)
.send_with_result(con)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(client): {e}")))?;
info!(target: "chanora_protocol", len = message.len(), ?target, "text message queued");
Ok(handle)
}
MessageTarget::Poke(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = find_client_by_id(state.clients.values(), client_id)?;
let handle = client
client
.poke(message)
.send_with_result(con)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("poke: {e}")))?;
info!(target: "chanora_protocol", len = message.len(), ?target, "text message queued");
Ok(handle)
}
}
info!(target: "chanora_protocol", len = message.len(), ?target, "text message sent");
Ok(())
}
fn send_text_to_mode(
@@ -1019,7 +1091,7 @@ fn send_text_to_mode(
message: &str,
target: tsproto_types::TextMessageTargetMode,
label: &str,
) -> Result<MessageHandle, ProtocolError> {
) -> Result<(), ProtocolError> {
use ts_bookkeeping::messages::c2s;
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(c2s::OutSendTextMessagePart {
@@ -1027,31 +1099,242 @@ fn send_text_to_mode(
target_client_id: None,
message: message.into(),
}))
.send_with_result(con)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage({label}): {e}")))
}
fn map_command_result(
result: Result<(), tsclientlib::CommandError>,
action: &str,
) -> Result<(), ProtocolError> {
match result {
Ok(()) => Ok(()),
Err(cmd_err) => {
let code = cmd_err.error as u32;
let message = cmd_err.error.to_string();
info!(
target: "chanora_protocol",
action,
code,
message = %message,
"server rejected command"
);
Err(ProtocolError::ServerRejected { code, message })
async fn fetch_client_profile(
con: &mut Connection,
client_id: u64,
) -> Result<ClientProfile, ProtocolError> {
let target_id = TsClientId(client_id as u16);
let (database_id, uid_b64) = {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = find_client_by_id(state.clients.values(), client_id)?;
(
client.database_id,
client.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
)
};
let _ = request_messages(con, build_command("servergrouplist", &[], &[])).await;
let _ = request_messages(con, build_command("channelgrouplist", &[], &[])).await;
let _ = request_messages(
con,
build_command(
"clientgetvariables",
&[("clid", client_id.to_string())],
&[],
),
)
.await;
let _ = request_messages(
con,
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
)
.await;
let db_info = request_client_db_info(con, database_id).await.ok();
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = state
.clients
.get(&target_id)
.ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?;
let optional = client.optional_data.as_ref();
let connection = client.connection_data.as_ref();
let server_group_names: HashMap<_, _> = state
.server_groups
.iter()
.map(|(id, group)| (*id, group.name.clone()))
.collect();
let channel_group_names: HashMap<_, _> = state
.channel_groups
.iter()
.map(|(id, group)| (*id, group.name.clone()))
.collect();
let unique_id = db_info
.as_ref()
.map(|info| uid_to_b64(info.uid.as_ref()))
.or(uid_b64)
.unwrap_or_default();
let avatar_path = if client.avatar_hash.is_empty() || unique_id.is_empty() {
String::new()
} else {
format!("/avatar_{}", uid_to_avatar_path(&unique_id))
};
Ok(ClientProfile {
id: ClientId(client.id.0 as u64),
channel: ChannelId(client.channel.0),
name: db_info
.as_ref()
.map(|info| sanitize(&info.name))
.unwrap_or_else(|| sanitize(&client.name)),
unique_id,
database_id: Some(client.database_id.0),
country_code: client.country_code.clone(),
description: db_info
.as_ref()
.map(|info| sanitize(&info.description))
.unwrap_or_else(|| sanitize(&client.description)),
version: optional
.map(|info| info.version.clone())
.unwrap_or_default(),
platform: optional
.map(|info| info.platform.clone())
.unwrap_or_default(),
created_unix_seconds: optional
.map(|info| info.created.unix_timestamp())
.or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())),
last_connected_unix_seconds: optional
.map(|info| info.last_connected.unix_timestamp())
.or_else(|| {
db_info
.as_ref()
.map(|info| info.last_connected.unix_timestamp())
}),
connections_total: optional
.map(|info| u64::from(info.connections_total))
.or_else(|| {
db_info
.as_ref()
.map(|info| u64::from(info.connections_total))
}),
online_seconds: connection
.and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())),
idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)),
ping_milliseconds: connection.and_then(|info| info.ping.map(duration_millis)),
client_address: connection
.and_then(|info| info.client_address.map(|address| address.to_string()))
.unwrap_or_default(),
server_groups: format_server_groups(client.server_groups.iter(), &server_group_names),
channel_group: channel_group_names
.get(&client.channel_group)
.cloned()
.unwrap_or_else(|| format!("Unknown ({})", client.channel_group.0)),
avatar_path,
bytes_downloaded_month: optional
.map(|info| info.bytes_downloaded_month)
.or_else(|| db_info.as_ref().map(|info| info.bytes_downloaded_month)),
bytes_uploaded_month: optional
.map(|info| info.bytes_uploaded_month)
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_month)),
bytes_downloaded_total: optional
.map(|info| info.bytes_downloaded_total)
.or_else(|| db_info.as_ref().map(|info| info.bytes_downloaded_total)),
bytes_uploaded_total: optional
.map(|info| info.bytes_uploaded_total)
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)),
packet_loss_client_to_server_total: connection
.map(|info| info.client_to_server_packetloss_total),
packet_loss_server_to_client_total: connection
.and_then(|info| info.server_to_client_packetloss_total),
})
}
fn build_command(name: &str, args: &[(&str, String)], flags: &[&str]) -> OutCommand {
let mut command = OutCommand::new(Direction::C2S, Flags::empty(), PacketType::Command, name);
for flag in flags {
command.write_arg(flag, &"");
}
for (key, value) in args {
command.write_arg(key, value);
}
command
}
async fn request_messages(
con: &mut Connection,
command: OutCommand,
) -> Result<Vec<InMessage>, ProtocolError> {
let handle = command
.send_with_result(con)
.map_err(|e| ProtocolError::Backend(format!("send command: {e}")))?;
let mut messages = Vec::new();
loop {
let item = con
.events()
.next()
.await
.ok_or_else(|| ProtocolError::Lost("event stream ended".to_string()))?
.map_err(|e| ProtocolError::Lost(e.to_string()))?;
match item {
StreamItem::MessageEvent(message) => messages.push(message),
StreamItem::MessageResult(reply, status) if reply == handle => {
status.map_err(|error| ProtocolError::ServerRejected {
code: error.error as u32,
message: error.error.to_string(),
})?;
return Ok(messages);
}
_ => {}
}
}
}
async fn request_client_db_info(
con: &mut Connection,
dbid: tsclientlib::ClientDbId,
) -> Result<InClientDbInfoPart, ProtocolError> {
let messages = request_messages(
con,
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
)
.await?;
for message in messages {
if let InMessage::ClientDbInfo(info) = message {
if let Some(row) = info.iter().next() {
return Ok(row.clone());
}
}
}
Err(ProtocolError::Backend(
"clientdbinfo returned no row".to_string(),
))
}
fn format_server_groups<'a>(
groups: impl IntoIterator<Item = &'a tsclientlib::ServerGroupId>,
names: &HashMap<tsclientlib::ServerGroupId, String>,
) -> Vec<String> {
let mut rendered = groups
.into_iter()
.map(|group| {
names
.get(group)
.cloned()
.unwrap_or_else(|| format!("Unknown ({})", group.0))
})
.collect::<Vec<_>>();
rendered.sort();
rendered
}
fn duration_millis(duration: time::Duration) -> i64 {
duration
.whole_milliseconds()
.clamp(i64::MIN as i128, i64::MAX as i128) as i64
}
fn uid_to_b64(uid: &tsclientlib::Uid) -> String {
BASE64_STANDARD.encode(&uid.0)
}
fn uid_to_avatar_path(uid_b64: &str) -> String {
let decoded = BASE64_STANDARD.decode(uid_b64).unwrap_or_default();
let mut rendered = String::with_capacity(decoded.len() * 2);
for byte in decoded {
rendered.push((b'a' + (byte >> 4)) as char);
rendered.push((b'a' + (byte & 0x0f)) as char);
}
rendered
}
fn find_client_by_id<'a>(
clients: impl IntoIterator<Item = &'a Client>,
client_id: u64,
@@ -1219,11 +1502,6 @@ fn build_snapshot(
.iter()
.map(|c| ClientInfo {
id: ClientId(c.id.0 as u64),
uid: c
.uid
.as_ref()
.map(|uid| BASE64_STANDARD.encode(&uid.0))
.unwrap_or_default(),
channel: ChannelId(c.channel.0),
name: sanitize(&c.name),
input_muted: c.input_muted,
@@ -1441,7 +1719,12 @@ const _: () = {
#[cfg(test)]
mod tests {
use super::{is_server_query_client_type, sort_channels_tree_by};
use super::{
is_server_query_client_type, send_with_timeout, server_socket_from_config,
sort_channels_tree_by, ConnectConfig, SendTimeoutError,
};
use std::time::Duration;
use tokio::sync::mpsc;
use tsproto_types::ClientType;
/// Lightweight fixture mirroring just the (id, parent, order)
@@ -1470,6 +1753,36 @@ mod tests {
}));
}
#[test]
fn connect_config_can_carry_prefetched_socket_address() {
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
let cfg = ConnectConfig {
address: "example.com".to_string(),
nickname: "Tester".to_string(),
password: None,
identity: None,
ready_timeout: Duration::from_secs(1),
resolved_address: Some(addr),
};
assert_eq!(cfg.resolved_address, Some(addr));
}
#[test]
fn server_socket_from_config_prefers_prefetched_address() {
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
let cfg = ConnectConfig {
address: "example.com".to_string(),
resolved_address: Some(addr),
nickname: "Tester".to_string(),
password: None,
identity: None,
ready_timeout: Duration::from_secs(1),
};
assert_eq!(server_socket_from_config(&cfg), Some(addr));
}
#[test]
fn channel_sort_linked_list_under_one_parent() {
// Server emits four root-level channels in arbitrary HashMap
@@ -1589,4 +1902,32 @@ mod tests {
assert!(ids.contains(&1));
assert!(ids.contains(&2));
}
#[tokio::test]
async fn send_with_timeout_waits_for_capacity_instead_of_dropping() {
let (tx, mut rx) = mpsc::channel(1);
tx.send(1_u8).await.expect("seed first item");
let drain = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
assert_eq!(rx.recv().await, Some(1));
rx.recv().await
});
send_with_timeout(&tx, 2_u8, Duration::from_millis(100))
.await
.expect("second item should enqueue once capacity frees");
assert_eq!(drain.await.expect("drain task should succeed"), Some(2));
}
#[tokio::test]
async fn send_with_timeout_times_out_when_capacity_stays_full() {
let (tx, _rx) = mpsc::channel(1);
tx.send(1_u8).await.expect("seed first item");
let result = send_with_timeout(&tx, 2_u8, Duration::from_millis(10)).await;
assert_eq!(result, Err(SendTimeoutError::Timeout(2)));
}
}
+61 -7
View File
@@ -13,7 +13,7 @@ pub struct ChannelId(pub u64);
pub struct ClientId(pub u64);
/// One channel in the server's tree.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelInfo {
/// Stable channel id.
pub id: ChannelId,
@@ -44,7 +44,7 @@ pub enum MessageTarget {
}
/// An in-channel text message from a specific client.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatMessage {
/// The client id of the sender.
pub sender_id: ClientId,
@@ -57,19 +57,17 @@ pub struct ChatMessage {
}
/// A server-activity notification derived from TeamSpeak bookkeeping events.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerActivity {
/// Human-readable activity line.
pub message: String,
}
/// One connected client on the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientInfo {
/// Stable client id.
pub id: ClientId,
/// Stable TeamSpeak unique identifier for cross-session preference keys.
pub uid: String,
/// Channel the client is currently in.
pub channel: ChannelId,
/// Nickname, preserved verbatim per ADR-008.
@@ -88,8 +86,64 @@ pub struct ClientInfo {
pub talk_power_granted: bool,
}
/// Best-effort profile and live connection details for one online
/// client, fetched through the normal TeamSpeak client protocol.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClientProfile {
/// Stable online client id.
pub id: ClientId,
/// Channel the client is currently in.
pub channel: ChannelId,
/// Nickname, preserved verbatim per ADR-008.
pub name: String,
/// TeamSpeak unique id encoded in the canonical base64 form.
pub unique_id: String,
/// Stable TeamSpeak database id, when visible.
pub database_id: Option<u64>,
/// ISO country code, when the server exposes one.
pub country_code: String,
/// User description, when visible.
pub description: String,
/// Client version string, populated by `clientgetvariables`.
pub version: String,
/// Client platform string, populated by `clientgetvariables`.
pub platform: String,
/// Account creation time as Unix seconds.
pub created_unix_seconds: Option<i64>,
/// Last connection time as Unix seconds.
pub last_connected_unix_seconds: Option<i64>,
/// Total historical connections, when visible.
pub connections_total: Option<u64>,
/// Current online duration in seconds, when visible.
pub online_seconds: Option<i64>,
/// Current idle time in milliseconds, when visible.
pub idle_milliseconds: Option<i64>,
/// Current ping in milliseconds, when visible.
pub ping_milliseconds: Option<i64>,
/// Client address. Empty when permission-gated.
pub client_address: String,
/// Resolved server group names for the online client.
pub server_groups: Vec<String>,
/// Resolved channel group name for the online client.
pub channel_group: String,
/// TeamSpeak avatar file path suffix, when an avatar hash is present.
pub avatar_path: String,
/// Downloaded bytes this month.
pub bytes_downloaded_month: Option<u64>,
/// Uploaded bytes this month.
pub bytes_uploaded_month: Option<u64>,
/// Downloaded bytes across all time.
pub bytes_downloaded_total: Option<u64>,
/// Uploaded bytes across all time.
pub bytes_uploaded_total: Option<u64>,
/// Client-to-server total packet loss ratio.
pub packet_loss_client_to_server_total: Option<f32>,
/// Server-to-client total packet loss ratio.
pub packet_loss_server_to_client_total: Option<f32>,
}
/// Snapshot of the server's published state at a moment in time.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerSnapshot {
/// Server name.
pub server_name: String,
+4 -3
View File
@@ -8,7 +8,8 @@
//!
//! * [`ConnectConfig`] — typed connection parameters.
//! * [`ProtocolClient`] — async handle owning the connection task.
//! * [`ServerSnapshot`], [`ChannelInfo`], [`ClientInfo`] — opaque
//! * [`ServerSnapshot`], [`ChannelInfo`], [`ClientInfo`],
//! [`ClientProfile`] — opaque
//! DTOs containing only `String`s and primitives.
//! * [`ProtocolError`] — typed error catalogue.
//!
@@ -37,8 +38,8 @@ mod dto;
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
pub use dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerActivity,
ServerSnapshot,
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
ServerActivity, ServerSnapshot,
};
// Re-export the upstream voice types so chanora_audio can build outbound
+2
View File
@@ -2,6 +2,8 @@
name = "chanora_resolver"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
publish = false
build = "build.rs"
[dependencies]
+6 -5
View File
@@ -9,10 +9,11 @@ Use `ChanoraResolver::resolve_client_request` or `resolve_client_address` for ap
1. Normalize raw client input, including `ts3server://host?port=...`.
2. For dotless names such as `6666` or `wwb`, query the myTeamSpeak server-name endpoint first. If it returns a hostname, restart normal resolution with that hostname. If it returns `ip:port`, use that final address.
3. Start plain A/AAAA DNS as the final fallback address.
4. Prefer `_ts3._udp.<host>` SRV. Its target host and port override the user input port, then the target is resolved to a final IP.
5. Try `_tsdns._tcp.<candidate>` SRV on candidate parent/full hosts and query the returned TSDNS server over TCP.
6. Try direct TSDNS TCP on candidate parent/full hosts at port `41144`.
7. Fall back to the A/AAAA result with the user-supplied port, or default TeamSpeak port `9987`.
4. If the user supplied an explicit port and DNS resolved, return that direct target immediately.
5. Prefer `_ts3._udp.<host>` SRV. Its target host and port override the default port, then the target is resolved to a final IP.
6. Try `_tsdns._tcp.<candidate>` SRV on candidate parent/full hosts and query the returned TSDNS server over TCP.
7. Try direct TSDNS TCP on candidate parent/full hosts at port `41144`.
8. Fall back to the A/AAAA result with the default TeamSpeak port `9987`. When DNS fallback is already available, TSDNS discovery is capped so missing or filtered TSDNS cannot add multi-second join delays.
The older explicit API (`Args { host, service, protocol }`) is still available for diagnostic example use.
@@ -101,4 +102,4 @@ cargo run --example cli -- -host voice.teamspeak.com -service ts3
cargo fmt --all -- --check
cargo test
cargo clippy --all-targets --all-features -- -D warnings
```
```
+168 -50
View File
@@ -18,6 +18,8 @@ const NICK_RESOLVE_URL: &str = "https://named.myteamspeak.com/lookup";
const TSDNS_PORT: u16 = 41144;
const TSDNS_TERMINATOR: &[u8] = b"\n\r\r\r\n";
const TSDNS_TIMEOUT: Duration = Duration::from_secs(3);
const TS3_SRV_FALLBACK_DISCOVERY_BUDGET: Duration = Duration::from_millis(900);
const TSDNS_FALLBACK_DISCOVERY_BUDGET: Duration = Duration::from_millis(900);
pub const DEFAULT_TEAMSPEAK_PORT: u16 = 9987;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -262,7 +264,7 @@ impl ChanoraResolver {
));
}
let direct = if is_bare_numeric_name(&host) {
let mut direct = if is_bare_numeric_name(&host) {
errors.push("dns: skipped bare numeric server name".to_string());
None
} else {
@@ -275,53 +277,80 @@ impl ChanoraResolver {
}
};
match self.resolve_ts3(&host, "").await {
Ok(resolution) => {
let selected = match &resolution {
Resolution::Srv { selected, .. } => selected.clone(),
_ => unreachable!("ts3 resolution must be an srv result"),
};
return self
.client_srv_resolution(
input,
ClientResolutionMethod::Ts3Srv,
&selected,
resolution,
)
.await;
if should_return_direct_dns_before_discovery(port) {
if let Some(resolution) = direct.take() {
return Ok(direct_client_resolution(
input,
from_server_name,
fallback_port,
resolution,
));
}
Err(err) => errors.push(format!("ts3 srv: {err}")),
}
match self
.resolve_tsdns_srv_candidates(input, &host, fallback_port)
.await
{
Ok(resolution) => return Ok(resolution),
Err(err) => errors.push(format!("tsdns srv: {err}")),
let ts3 = self.resolve_ts3(&host, "");
match ts3_srv_discovery_budget(direct.is_some()) {
Some(budget) => match timeout(budget, ts3).await {
Ok(Ok(resolution)) => {
let selected = match &resolution {
Resolution::Srv { selected, .. } => selected.clone(),
_ => unreachable!("ts3 resolution must be an srv result"),
};
return self
.client_srv_resolution(
input,
ClientResolutionMethod::Ts3Srv,
&selected,
resolution,
)
.await;
}
Ok(Err(err)) => errors.push(format!("ts3 srv: {err}")),
Err(_) => errors.push(format!(
"ts3 srv: discovery timed out after {}ms",
budget.as_millis()
)),
},
None => match ts3.await {
Ok(resolution) => {
let selected = match &resolution {
Resolution::Srv { selected, .. } => selected.clone(),
_ => unreachable!("ts3 resolution must be an srv result"),
};
return self
.client_srv_resolution(
input,
ClientResolutionMethod::Ts3Srv,
&selected,
resolution,
)
.await;
}
Err(err) => errors.push(format!("ts3 srv: {err}")),
},
}
match self
.resolve_tsdns_tcp_candidates(input, &host, fallback_port)
.await
{
Ok(resolution) => return Ok(resolution),
Err(err) => errors.push(format!("tsdns tcp: {err}")),
let tsdns = self.resolve_tsdns_candidates(input, &host, fallback_port);
match tsdns_discovery_budget(direct.is_some()) {
Some(budget) => match timeout(budget, tsdns).await {
Ok(Ok(resolution)) => return Ok(resolution),
Ok(Err(err)) => errors.push(format!("tsdns: {err}")),
Err(_) => errors.push(format!(
"tsdns: discovery timed out after {}ms",
budget.as_millis()
)),
},
None => match tsdns.await {
Ok(resolution) => return Ok(resolution),
Err(err) => errors.push(format!("tsdns: {err}")),
},
}
if let Some(resolution) = direct {
let selected = match &resolution {
Resolution::Dns { selected, .. } => *selected,
_ => unreachable!("direct fallback must be a dns resolution"),
};
return Ok(client_resolution_with_address(
return Ok(direct_client_resolution(
input,
if from_server_name {
ClientResolutionMethod::Nick
} else {
ClientResolutionMethod::Dns
},
format_host_port(&selected.to_string(), fallback_port),
from_server_name,
fallback_port,
resolution,
));
}
@@ -332,6 +361,32 @@ impl ChanoraResolver {
)
}
async fn resolve_tsdns_candidates(
&self,
input: &str,
query_host: &str,
fallback_port: u16,
) -> Result<ClientResolution> {
let mut errors = Vec::new();
match self
.resolve_tsdns_srv_candidates(input, query_host, fallback_port)
.await
{
Ok(resolution) => return Ok(resolution),
Err(err) => errors.push(format!("tsdns srv: {err}")),
}
match self
.resolve_tsdns_tcp_candidates(input, query_host, fallback_port)
.await
{
Ok(resolution) => return Ok(resolution),
Err(err) => errors.push(format!("tsdns tcp: {err}")),
}
bail!("{}", errors.join("; "))
}
async fn client_srv_resolution(
&self,
input: &str,
@@ -716,17 +771,6 @@ impl ChanoraResolver {
}
}
#[cfg(target_os = "android")]
fn srv_resolver_builder() -> Result<hickory_resolver::ResolverBuilder<TokioRuntimeProvider>> {
use hickory_resolver::config::{ResolverConfig, CLOUDFLARE};
Ok(TokioResolver::builder_with_config(
ResolverConfig::udp_and_tcp(&CLOUDFLARE),
TokioRuntimeProvider::default(),
))
}
#[cfg(not(target_os = "android"))]
fn srv_resolver_builder() -> Result<hickory_resolver::ResolverBuilder<TokioRuntimeProvider>> {
TokioResolver::builder_tokio().context("failed to initialize DNS resolver")
}
@@ -801,6 +845,40 @@ fn format_host_port(host: &str, port: u16) -> String {
}
}
fn should_return_direct_dns_before_discovery(port: Option<u16>) -> bool {
port.is_some()
}
fn tsdns_discovery_budget(has_dns_fallback: bool) -> Option<Duration> {
has_dns_fallback.then_some(TSDNS_FALLBACK_DISCOVERY_BUDGET)
}
fn ts3_srv_discovery_budget(has_dns_fallback: bool) -> Option<Duration> {
has_dns_fallback.then_some(TS3_SRV_FALLBACK_DISCOVERY_BUDGET)
}
fn direct_client_resolution(
input: &str,
from_server_name: bool,
fallback_port: u16,
resolution: Resolution,
) -> ClientResolution {
let selected = match &resolution {
Resolution::Dns { selected, .. } => *selected,
_ => unreachable!("direct fallback must be a dns resolution"),
};
client_resolution_with_address(
input,
if from_server_name {
ClientResolutionMethod::Nick
} else {
ClientResolutionMethod::Dns
},
format_host_port(&selected.to_string(), fallback_port),
resolution,
)
}
fn client_resolution_with_address(
input: &str,
method: ClientResolutionMethod,
@@ -1313,4 +1391,44 @@ mod tests {
vec!["teamspeak.app"]
);
}
#[test]
fn explicit_ports_use_direct_dns_fast_path() {
assert!(should_return_direct_dns_before_discovery(Some(9987)));
assert!(!should_return_direct_dns_before_discovery(None));
}
#[tokio::test]
async fn explicit_port_client_request_uses_dns_result() {
let resolver = ChanoraResolver {
resolver: None,
http: Client::new(),
};
let resolved = resolver
.resolve_client_request("localhost:10075")
.await
.unwrap();
assert_eq!(resolved.method, ClientResolutionMethod::Dns);
assert!(resolved.address.ends_with(":10075"));
}
#[test]
fn tsdns_discovery_is_bounded_when_dns_fallback_exists() {
assert_eq!(
tsdns_discovery_budget(true),
Some(TSDNS_FALLBACK_DISCOVERY_BUDGET)
);
assert_eq!(tsdns_discovery_budget(false), None);
}
#[test]
fn ts3_srv_discovery_is_bounded_when_dns_fallback_exists() {
assert_eq!(
ts3_srv_discovery_budget(true),
Some(TS3_SRV_FALLBACK_DISCOVERY_BUDGET)
);
assert_eq!(ts3_srv_discovery_budget(false), None);
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "chanora_server_prefetch"
description = "Chanora — server-address prefetch cache and policy built on chanora_resolver."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
[dependencies]
chanora_resolver = { path = "../chanora_resolver" }
thiserror.workspace = true
tracing.workspace = true
tokio = { version = "1", features = ["sync", "rt", "macros"] }
[features]
test-support = []
+312
View File
@@ -0,0 +1,312 @@
//! Server-address prefetch cache and policy for Chanora.
//!
//! This crate owns speculative server-resolution warming. It does not
//! decide whether a connection should use a prefetched address; callers
//! must still apply their own trust boundary before dialing.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use thiserror::Error;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
const SERVER_PREFETCH_TTL: Duration = Duration::from_secs(120);
#[derive(Debug, Error)]
pub enum ServerPrefetchError {
#[error("resolver initialization failed: {0}")]
ResolverInit(String),
#[error("resolution failed: {0}")]
Resolution(String),
#[error("resolver returned invalid socket address '{resolved}': {reason}")]
InvalidSocketAddress { resolved: String, reason: String },
}
#[derive(Debug, Clone)]
struct ServerPrefetchEntry {
normalized_host: String,
resolved_address: SocketAddr,
completed_at: Instant,
}
#[derive(Debug, Default)]
struct ServerPrefetchCache {
latest_generation: u64,
entry: Option<ServerPrefetchEntry>,
}
impl ServerPrefetchCache {
fn begin(&mut self, host: &str) -> u64 {
if normalize_host(host).is_empty() {
return self.latest_generation;
}
self.latest_generation = self.latest_generation.saturating_add(1);
self.latest_generation
}
fn store_success(
&mut self,
generation: u64,
host: &str,
resolved_address: SocketAddr,
completed_at: Instant,
) {
if generation != self.latest_generation {
return;
}
self.entry = Some(ServerPrefetchEntry {
normalized_host: normalize_host(host),
resolved_address,
completed_at,
});
}
fn fresh_match(&self, host: &str, now: Instant) -> Option<SocketAddr> {
let normalized = normalize_host(host);
let entry = self.entry.as_ref()?;
if entry.normalized_host != normalized {
return None;
}
if now.duration_since(entry.completed_at) > SERVER_PREFETCH_TTL {
return None;
}
// Generation is not checked here: a fresh entry remains usable while
// a newer prefetch is in flight. Stale async completions are still
// rejected in store_success via the generation guard.
Some(entry.resolved_address)
}
}
#[derive(Debug, Clone, Default)]
pub struct ServerPrefetcher {
cache: Arc<Mutex<ServerPrefetchCache>>,
#[cfg(any(test, feature = "test-support"))]
setup_error_for_test: Arc<Mutex<Option<String>>>,
}
impl ServerPrefetcher {
pub fn new() -> Self {
Self::default()
}
/// Schedules a fire-and-forget prefetch for `host`.
///
/// The result only reports synchronous setup failures before scheduling,
/// such as resolver initialization. DNS/resolution failures after the task
/// is spawned are logged and do not complete this returned `Result`.
pub async fn prefetch(&self, host: String) -> Result<(), ServerPrefetchError> {
let normalized = normalize_host(&host);
if normalized.is_empty() {
return Ok(());
}
#[cfg(any(test, feature = "test-support"))]
if let Some(err) = self.setup_error_for_test.lock().await.take() {
return Err(ServerPrefetchError::ResolverInit(err));
}
let resolver = chanora_resolver::ChanoraResolver::new()
.map_err(|err| ServerPrefetchError::ResolverInit(err.to_string()))?;
let generation = {
let mut cache = self.cache.lock().await;
cache.begin(&normalized)
};
let cache = self.cache.clone();
tokio::spawn(async move {
info!(target: "chanora_server_prefetch", host = %normalized, "resolution prefetch started");
let result = resolve_socket(resolver, &normalized).await;
match result {
Ok(addr) => {
info!(
target: "chanora_server_prefetch",
host = %normalized,
resolved = %addr,
"resolution prefetch result"
);
let mut guard = cache.lock().await;
guard.store_success(generation, &normalized, addr, Instant::now());
}
Err(err) => {
warn!(
target: "chanora_server_prefetch",
host = %normalized,
error = %err,
"resolution prefetch failed"
);
}
}
});
Ok(())
}
pub async fn fresh_match(&self, host: &str) -> Option<SocketAddr> {
let resolved = {
let cache = self.cache.lock().await;
cache.fresh_match(host, Instant::now())
};
match resolved {
Some(addr) => {
info!(
target: "chanora_server_prefetch",
host = %host,
resolved = %addr,
"connect using prefetched resolution"
);
Some(addr)
}
None => {
debug!(target: "chanora_server_prefetch", host = %host, "connect prefetch miss or stale");
None
}
}
}
#[cfg(any(test, feature = "test-support"))]
pub async fn begin_for_test(&self, host: &str) -> u64 {
let mut cache = self.cache.lock().await;
cache.begin(host)
}
#[cfg(any(test, feature = "test-support"))]
pub async fn store_success_for_test(
&self,
generation: u64,
host: &str,
resolved_address: SocketAddr,
completed_at: Instant,
) {
let mut cache = self.cache.lock().await;
cache.store_success(generation, host, resolved_address, completed_at);
}
#[cfg(any(test, feature = "test-support"))]
pub async fn latest_generation_for_test(&self) -> u64 {
let cache = self.cache.lock().await;
cache.latest_generation
}
#[cfg(any(test, feature = "test-support"))]
pub async fn fail_next_prefetch_setup_for_test(&self, error: impl Into<String>) {
*self.setup_error_for_test.lock().await = Some(error.into());
}
}
fn normalize_host(host: &str) -> String {
host.trim().to_lowercase()
}
async fn resolve_socket(
resolver: chanora_resolver::ChanoraResolver,
host: &str,
) -> Result<SocketAddr, ServerPrefetchError> {
let resolved = resolver
.resolve_client_address(host)
.await
.map_err(|err| ServerPrefetchError::Resolution(err.to_string()))?;
resolved
.parse::<SocketAddr>()
.map_err(|err| ServerPrefetchError::InvalidSocketAddress {
resolved,
reason: err.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn fresh_exact_match_returns_socket_address() {
let prefetcher = ServerPrefetcher::new();
let generation = prefetcher.begin_for_test(" Example.COM ").await;
let addr = "127.0.0.1:9987".parse().unwrap();
prefetcher
.store_success_for_test(generation, "example.com", addr, Instant::now())
.await;
assert_eq!(prefetcher.fresh_match("example.com").await, Some(addr));
assert_eq!(prefetcher.fresh_match(" EXAMPLE.com ").await, Some(addr));
}
#[tokio::test]
async fn stale_entries_are_ignored() {
let prefetcher = ServerPrefetcher::new();
let generation = prefetcher.begin_for_test("example.com").await;
let addr = "127.0.0.1:9987".parse().unwrap();
prefetcher
.store_success_for_test(
generation,
"example.com",
addr,
Instant::now() - SERVER_PREFETCH_TTL - Duration::from_secs(1),
)
.await;
assert_eq!(prefetcher.fresh_match("example.com").await, None);
}
#[tokio::test]
async fn different_hosts_are_ignored() {
let prefetcher = ServerPrefetcher::new();
let generation = prefetcher.begin_for_test("example.com").await;
let addr = "127.0.0.1:9987".parse().unwrap();
prefetcher
.store_success_for_test(generation, "example.com", addr, Instant::now())
.await;
assert_eq!(prefetcher.fresh_match("other.example.com").await, None);
}
#[tokio::test]
async fn stale_generation_completions_are_ignored() {
let prefetcher = ServerPrefetcher::new();
let old_generation = prefetcher.begin_for_test("old.example.com").await;
let _new_generation = prefetcher.begin_for_test("new.example.com").await;
let old_addr = "127.0.0.1:9987".parse().unwrap();
prefetcher
.store_success_for_test(old_generation, "old.example.com", old_addr, Instant::now())
.await;
assert_eq!(prefetcher.fresh_match("old.example.com").await, None);
}
#[tokio::test]
async fn fresh_entry_usable_during_warming() {
let prefetcher = ServerPrefetcher::new();
let first_generation = prefetcher.begin_for_test("example.com").await;
let cached_addr = "127.0.0.1:9987".parse().unwrap();
prefetcher
.store_success_for_test(first_generation, "example.com", cached_addr, Instant::now())
.await;
let _pending_generation = prefetcher.begin_for_test("example.com").await;
assert_eq!(
prefetcher.fresh_match("example.com").await,
Some(cached_addr)
);
let stale_addr = "127.0.0.2:9987".parse().unwrap();
prefetcher
.store_success_for_test(first_generation, "example.com", stale_addr, Instant::now())
.await;
assert_eq!(
prefetcher.fresh_match("example.com").await,
Some(cached_addr)
);
}
#[tokio::test]
async fn blank_host_skips_prefetch() {
let prefetcher = ServerPrefetcher::new();
let before = prefetcher.latest_generation_for_test().await;
prefetcher.prefetch(" ".to_string()).await.unwrap();
assert_eq!(prefetcher.latest_generation_for_test().await, before);
}
}
+1
View File
@@ -11,3 +11,4 @@ publish.workspace = true
[dependencies]
thiserror.workspace = true
chanora_protocol = { path = "../chanora_protocol" }
+772 -7
View File
@@ -4,17 +4,26 @@
//! channel tree, client list, our own connection state. Owns the
//! reducers that fold protocol events into a snapshot, and the
//! deltas that the bridge publishes to Flutter view-models
//! (per SAD §7.2 and SDD §5).
//! (per SAD 7.2 and SDD 5).
//!
//! ## Status
//! ## Reducer contract
//!
//! Scaffold only.
//! Reducers mutate the caller-owned state passed by `&mut` and return a
//! `Reduction` containing only the emitted `Delta` values. The caller
//! (typically `chanora_core`) owns state storage, publishes deltas to the
//! bridge, and executes any side effects.
//!
//! This design satisfies SRS-056 (deterministic deltas), SRS-057
//! (per-connection ordering), and SRS-058 (reducer functions).
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod channel_join;
use std::collections::HashMap;
use chanora_protocol::{ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ServerSnapshot};
use thiserror::Error;
/// Errors raised while reducing protocol events into state or
@@ -24,8 +33,7 @@ pub enum StateError {
/// Reducer received an event referencing an unknown entity.
#[error("unknown entity: {0}")]
Unknown(&'static str),
/// A reducer invariant was violated (e.g. two clients claiming
/// the same id).
/// A reducer invariant was violated (e.g. two clients claiming the same id).
#[error("state invariant: {0}")]
Invariant(&'static str),
}
@@ -39,16 +47,773 @@ pub enum ConnectionState {
Connecting,
/// Initial state snapshot has been received; ready for use.
Ready,
/// Connection lost; reconnecting.
Reconnecting,
/// Connection lost; will not auto-reconnect at this stage.
Lost,
}
/// Authoritative mirror of a connected server's published state.
///
/// Constructed from a [`ServerSnapshot`] and updated through delta
/// events. Owns the channel tree, client list, and server metadata.
/// Satisfies SRS-054.
#[derive(Debug, Clone)]
pub struct ServerState {
/// Retained connection lifecycle state.
pub connection_state: ConnectionState,
/// Server name from the most recent snapshot.
pub server_name: String,
/// Welcome message / banner (server-provided markup).
pub welcome_message: String,
/// Server platform string.
pub platform: String,
/// Server version string.
pub version: String,
channels: HashMap<u64, ChannelInfo>,
clients: HashMap<u64, ClientInfo>,
channel_order: Vec<u64>,
client_order: Vec<u64>,
/// Our own client id, as the server reported it.
pub own_client_id: u64,
}
impl ServerState {
/// Build state from the initial full snapshot. Satisfies SRS-055.
pub fn from_snapshot(snapshot: ServerSnapshot) -> Self {
let snapshot = normalize_snapshot(snapshot);
let channels = snapshot
.channels
.iter()
.map(|c| (c.id.0, c.clone()))
.collect();
let clients = snapshot
.clients
.iter()
.map(|c| (c.id.0, c.clone()))
.collect();
let channel_order: Vec<u64> = snapshot.channels.iter().map(|c| c.id.0).collect();
let mut client_order: Vec<u64> = snapshot.clients.iter().map(|c| c.id.0).collect();
client_order.sort_unstable();
client_order.dedup();
Self {
connection_state: ConnectionState::Ready,
server_name: snapshot.server_name,
welcome_message: snapshot.welcome_message,
platform: snapshot.platform,
version: snapshot.version,
channels,
clients,
channel_order,
client_order,
own_client_id: snapshot.own_client_id,
}
}
/// Replace state with a fresh snapshot (post-reconnect). Satisfies SRS-059.
pub fn replace_from_snapshot(&mut self, snapshot: ServerSnapshot) {
*self = Self::from_snapshot(snapshot);
}
/// Look up a channel by id.
pub fn channel(&self, id: ChannelId) -> Option<&ChannelInfo> {
self.channels.get(&id.0)
}
/// Look up a client by id.
pub fn client(&self, id: ClientId) -> Option<&ClientInfo> {
self.clients.get(&id.0)
}
/// All channels in protocol snapshot order. Live inserts are appended
/// until the next full snapshot re-normalizes order.
pub fn channels(&self) -> impl Iterator<Item = &ChannelInfo> {
self.channel_order
.iter()
.filter_map(|id| self.channels.get(id))
}
/// All clients in stable reducer order.
///
/// Full snapshots normalize clients by id to avoid inheriting upstream
/// HashMap iteration order. Live inserts are appended in arrival order
/// until the next full snapshot re-normalizes the list.
pub fn clients(&self) -> impl Iterator<Item = &ClientInfo> {
self.client_order
.iter()
.filter_map(|id| self.clients.get(id))
}
/// Number of channels.
pub fn channel_count(&self) -> usize {
self.channels.len()
}
/// Number of clients.
pub fn client_count(&self) -> usize {
self.clients.len()
}
/// The channel our own client is currently in.
pub fn own_channel(&self) -> Option<&ChannelInfo> {
self.client(ClientId(self.own_client_id))
.and_then(|c| self.channel(c.channel))
}
/// Clients in a given channel.
pub fn clients_in_channel(&self, channel_id: ChannelId) -> impl Iterator<Item = &ClientInfo> {
self.client_order
.iter()
.filter_map(|id| self.clients.get(id))
.filter(move |c| c.channel == channel_id)
}
}
fn normalize_snapshot(snapshot: ServerSnapshot) -> ServerSnapshot {
let mut channels = HashMap::new();
let mut channel_order = Vec::new();
for channel in snapshot.channels {
if !channels.contains_key(&channel.id.0) {
channel_order.push(channel.id.0);
}
channels.insert(channel.id.0, channel);
}
let mut clients = HashMap::new();
for client in snapshot.clients {
clients.insert(client.id.0, client);
}
let mut client_order: Vec<u64> = clients.keys().copied().collect();
client_order.sort_unstable();
ServerSnapshot {
server_name: snapshot.server_name,
welcome_message: snapshot.welcome_message,
platform: snapshot.platform,
version: snapshot.version,
channels: channel_order
.into_iter()
.filter_map(|id| channels.remove(&id))
.collect(),
clients: client_order
.into_iter()
.filter_map(|id| clients.remove(&id))
.collect(),
own_client_id: snapshot.own_client_id,
}
}
/// A change to the server state that the bridge should publish to
/// Flutter. Deltas are cheap to construct and carry only the
/// information that changed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Delta {
/// A full snapshot was applied; the entire UI should refresh.
SnapshotApplied(ServerSnapshot),
/// A channel was added or updated.
ChannelUpserted(ChannelInfo),
/// A channel was removed.
ChannelRemoved(ChannelId),
/// A client joined or was updated (includes moves).
ClientUpserted(ClientInfo),
/// A client left.
ClientRemoved(ClientId),
/// A chat message arrived.
ChatMessage(ChatMessage),
/// A client's speaking state changed.
VoiceActivityChanged {
/// Client whose voice activity changed.
client_id: ClientId,
/// Whether voice activity is currently observed.
is_speaking: bool,
},
/// The connection state changed.
ConnectionStateChanged(ConnectionState),
}
/// Events that flow from the protocol layer into the state reducer.
/// Each variant carries all data the reducer needs; no external
/// lookups are required.
#[derive(Debug, Clone)]
pub enum StateEvent {
/// Initial or post-reconnect snapshot arrived.
Snapshot(ServerSnapshot),
/// A new channel appeared or an existing channel was updated.
ChannelChanged(ChannelInfo),
/// A channel was deleted.
ChannelDeleted(ChannelId),
/// A client joined or was updated (including channel moves and
/// mute/speaking state changes).
ClientChanged(ClientInfo),
/// A client left the server.
ClientLeft(ClientId),
/// A text message was received.
ChatReceived(ChatMessage),
/// A client's speaking state changed.
VoiceActivityChanged {
/// Client whose voice activity changed.
client_id: ClientId,
/// Whether voice activity is currently observed.
is_speaking: bool,
},
/// Connection lifecycle transition.
ConnectionChanged(ConnectionState),
/// Reconnect started; stale state must be discarded.
ReconnectStarted,
}
/// Result of applying a single event through the reducer.
#[derive(Debug, Clone)]
pub struct Reduction {
/// Deltas describing what changed, in application order.
pub deltas: Vec<Delta>,
}
/// Apply a [`StateEvent`] to optional [`ServerState`], returning
/// the resulting [`Reduction`].
///
/// The state is taken by `&mut` so the caller retains ownership.
/// When `state` is `None` (no active connection), most events are
/// ignored except `ConnectionChanged` and `Snapshot` (which creates
/// the state).
///
/// Deterministic: the same `(state, event)` pair always produces the
/// same `Reduction`. Satisfies SRS-056.
pub fn reduce(state: &mut Option<ServerState>, event: StateEvent) -> Reduction {
match event {
StateEvent::ConnectionChanged(cs) => {
if let Some(s) = state {
s.connection_state = cs;
}
Reduction {
deltas: vec![Delta::ConnectionStateChanged(cs)],
}
}
StateEvent::ReconnectStarted => {
*state = None;
Reduction {
deltas: vec![Delta::ConnectionStateChanged(ConnectionState::Reconnecting)],
}
}
StateEvent::Snapshot(snap) => {
let normalized = normalize_snapshot(snap);
*state = Some(ServerState::from_snapshot(normalized.clone()));
Reduction {
deltas: vec![
Delta::ConnectionStateChanged(ConnectionState::Ready),
Delta::SnapshotApplied(normalized),
],
}
}
StateEvent::ChannelChanged(info) => match state {
Some(s) if s.connection_state == ConnectionState::Ready => {
if !s.channels.contains_key(&info.id.0) {
s.channel_order.push(info.id.0);
}
s.channels.insert(info.id.0, info.clone());
Reduction {
deltas: vec![Delta::ChannelUpserted(info)],
}
}
_ => Reduction { deltas: vec![] },
},
StateEvent::ChannelDeleted(id) => match state {
Some(s) if s.connection_state == ConnectionState::Ready => {
let removed_clients: Vec<u64> = s
.client_order
.iter()
.copied()
.filter(|client_id| {
s.clients
.get(client_id)
.is_some_and(|client| client.channel == id)
})
.collect();
for client_id in &removed_clients {
s.clients.remove(client_id);
}
s.client_order
.retain(|existing| !removed_clients.contains(existing));
let removed_channel = s.channels.remove(&id.0).is_some();
s.channel_order.retain(|existing| *existing != id.0);
let mut deltas: Vec<Delta> = removed_clients
.into_iter()
.map(|client_id| Delta::ClientRemoved(ClientId(client_id)))
.collect();
if removed_channel {
deltas.push(Delta::ChannelRemoved(id));
}
Reduction {
deltas,
}
}
_ => Reduction { deltas: vec![] },
},
StateEvent::ClientChanged(info) => match state {
Some(s) if s.connection_state == ConnectionState::Ready => {
if !s.clients.contains_key(&info.id.0) {
s.client_order.push(info.id.0);
}
s.clients.insert(info.id.0, info.clone());
Reduction {
deltas: vec![Delta::ClientUpserted(info)],
}
}
_ => Reduction { deltas: vec![] },
},
StateEvent::ClientLeft(id) => match state {
Some(s) if s.connection_state == ConnectionState::Ready => {
s.clients.remove(&id.0);
s.client_order.retain(|existing| *existing != id.0);
Reduction {
deltas: vec![Delta::ClientRemoved(id)],
}
}
_ => Reduction { deltas: vec![] },
},
StateEvent::ChatReceived(msg) => match state {
Some(s) if s.connection_state == ConnectionState::Ready => Reduction {
deltas: vec![Delta::ChatMessage(msg)],
},
_ => Reduction { deltas: vec![] },
},
StateEvent::VoiceActivityChanged {
client_id,
is_speaking,
} => match state {
Some(s) if s.connection_state == ConnectionState::Ready => {
if let Some(client) = s.clients.get_mut(&client_id.0) {
client.is_speaking = is_speaking;
Reduction {
deltas: vec![Delta::VoiceActivityChanged {
client_id,
is_speaking,
}],
}
} else {
Reduction { deltas: vec![] }
}
}
_ => Reduction { deltas: vec![] },
},
}
}
/// Replace server state with a fresh snapshot after a reconnect.
/// Discards all previous state unconditionally (SRS-059).
pub fn reduce_reconnect_snapshot(
state: &mut Option<ServerState>,
snap: ServerSnapshot,
) -> Reduction {
let normalized = normalize_snapshot(snap);
*state = Some(ServerState::from_snapshot(normalized.clone()));
Reduction {
deltas: vec![
Delta::ConnectionStateChanged(ConnectionState::Ready),
Delta::SnapshotApplied(normalized),
],
}
}
#[cfg(test)]
mod tests {
use super::*;
use chanora_protocol::MessageTarget;
fn sample_channel(id: u64) -> ChannelInfo {
ChannelInfo {
id: ChannelId(id),
parent: ChannelId(0),
name: format!("channel-{id}"),
order: 0,
has_password: false,
needed_talk_power: None,
}
}
fn sample_client(id: u64, channel: u64) -> ClientInfo {
ClientInfo {
id: ClientId(id),
channel: ChannelId(channel),
name: format!("client-{id}"),
input_muted: false,
output_muted: false,
is_speaking: false,
is_server_query: false,
talk_power: 0,
talk_power_granted: false,
}
}
fn sample_snapshot() -> ServerSnapshot {
ServerSnapshot {
server_name: "Test Server".into(),
welcome_message: String::new(),
platform: "Linux".into(),
version: "3.13".into(),
channels: vec![sample_channel(1), sample_channel(2)],
clients: vec![sample_client(10, 1)],
own_client_id: 10,
}
}
#[test]
fn state_transitions_compile() {
let _ = ConnectionState::Idle;
fn snapshot_creates_state() {
let mut state = None;
let snapshot = sample_snapshot();
let reduction = reduce(&mut state, StateEvent::Snapshot(snapshot.clone()));
assert!(state.is_some());
let s = state.as_ref().unwrap();
assert_eq!(s.connection_state, ConnectionState::Ready);
assert_eq!(s.channel_count(), 2);
assert_eq!(s.client_count(), 1);
assert_eq!(s.own_client_id, 10);
assert_eq!(
reduction.deltas,
vec![
Delta::ConnectionStateChanged(ConnectionState::Ready),
Delta::SnapshotApplied(snapshot),
]
);
}
#[test]
fn channel_upsert_adds_and_updates() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let ch = ChannelInfo {
id: ChannelId(3),
parent: ChannelId(1),
name: "new-channel".into(),
order: 1,
has_password: true,
needed_talk_power: Some(50),
};
let reduction = reduce(&mut state, StateEvent::ChannelChanged(ch.clone()));
let s = state.as_ref().unwrap();
assert_eq!(s.channel_count(), 3);
assert!(s.channel(ChannelId(3)).is_some());
assert!(matches!(&reduction.deltas[..], [Delta::ChannelUpserted(_)]));
let updated = ChannelInfo {
name: "renamed".into(),
..ch
};
reduce(&mut state, StateEvent::ChannelChanged(updated));
assert_eq!(
state.as_ref().unwrap().channel(ChannelId(3)).unwrap().name,
"renamed"
);
assert_eq!(state.as_ref().unwrap().channel_count(), 3);
}
#[test]
fn channel_delete_removes() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(2)));
let s = state.as_ref().unwrap();
assert_eq!(s.channel_count(), 1);
assert!(s.channel(ChannelId(2)).is_none());
assert!(matches!(&reduction.deltas[..], [Delta::ChannelRemoved(_)]));
}
#[test]
fn channel_delete_removes_clients_in_deleted_channel() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
reduce(&mut state, StateEvent::ClientChanged(sample_client(20, 2)));
reduce(&mut state, StateEvent::ClientChanged(sample_client(30, 2)));
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(2)));
let s = state.as_ref().unwrap();
assert!(s.channel(ChannelId(2)).is_none());
assert!(s.client(ClientId(20)).is_none());
assert!(s.client(ClientId(30)).is_none());
assert_eq!(s.client_count(), 1);
assert_eq!(s.clients_in_channel(ChannelId(2)).count(), 0);
assert_eq!(
reduction.deltas,
vec![
Delta::ClientRemoved(ClientId(20)),
Delta::ClientRemoved(ClientId(30)),
Delta::ChannelRemoved(ChannelId(2)),
]
);
}
#[test]
fn client_upsert_adds_and_moves() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let new_client = sample_client(20, 2);
let reduction = reduce(&mut state, StateEvent::ClientChanged(new_client));
let s = state.as_ref().unwrap();
assert_eq!(s.client_count(), 2);
assert!(matches!(&reduction.deltas[..], [Delta::ClientUpserted(_)]));
let moved = ClientInfo {
channel: ChannelId(2),
..sample_client(10, 2)
};
reduce(&mut state, StateEvent::ClientChanged(moved));
assert_eq!(
state
.as_ref()
.unwrap()
.client(ClientId(10))
.unwrap()
.channel,
ChannelId(2)
);
}
#[test]
fn client_leave_removes() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(&mut state, StateEvent::ClientLeft(ClientId(10)));
assert_eq!(state.as_ref().unwrap().client_count(), 0);
assert!(matches!(&reduction.deltas[..], [Delta::ClientRemoved(_)]));
}
#[test]
fn reconnect_discards_stale_state() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
assert_eq!(state.as_ref().unwrap().channel_count(), 2);
let reduction = reduce(&mut state, StateEvent::ReconnectStarted);
assert!(state.is_none());
assert!(matches!(
&reduction.deltas[..],
[Delta::ConnectionStateChanged(ConnectionState::Reconnecting)]
));
let snap2 = ServerSnapshot {
server_name: "New Server".into(),
channels: vec![sample_channel(100)],
clients: vec![sample_client(200, 100)],
..sample_snapshot()
};
reduce_reconnect_snapshot(&mut state, snap2);
let s = state.as_ref().unwrap();
assert_eq!(s.server_name, "New Server");
assert_eq!(s.channel_count(), 1);
assert_eq!(s.client_count(), 1);
}
#[test]
fn deltas_ignored_when_disconnected() {
let mut state: Option<ServerState> = None;
let r1 = reduce(&mut state, StateEvent::ChannelChanged(sample_channel(1)));
assert!(r1.deltas.is_empty());
let r2 = reduce(&mut state, StateEvent::ClientChanged(sample_client(1, 1)));
assert!(r2.deltas.is_empty());
}
#[test]
fn chat_message_requires_connected_state() {
let mut state: Option<ServerState> = None;
let msg = ChatMessage {
sender_id: ClientId(10),
sender_name: "Alice".into(),
message: "hello".into(),
target: MessageTarget::Channel,
};
let disconnected = reduce(&mut state, StateEvent::ChatReceived(msg.clone()));
assert!(disconnected.deltas.is_empty());
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
reduce(
&mut state,
StateEvent::ConnectionChanged(ConnectionState::Lost),
);
let lost = reduce(&mut state, StateEvent::ChatReceived(msg.clone()));
assert!(lost.deltas.is_empty());
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(&mut state, StateEvent::ChatReceived(msg));
assert!(matches!(&reduction.deltas[..], [Delta::ChatMessage(_)]));
}
#[test]
fn connection_state_change_produces_delta() {
let mut state: Option<ServerState> = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(
&mut state,
StateEvent::ConnectionChanged(ConnectionState::Connecting),
);
assert_eq!(
state.as_ref().unwrap().connection_state,
ConnectionState::Connecting
);
assert!(matches!(
&reduction.deltas[..],
[Delta::ConnectionStateChanged(ConnectionState::Connecting)]
));
}
#[test]
fn snapshot_order_is_deterministic_and_live_inserts_append() {
let mut state = None;
let snapshot = ServerSnapshot {
channels: vec![sample_channel(20), sample_channel(10)],
clients: vec![sample_client(30, 20), sample_client(10, 10)],
..sample_snapshot()
};
reduce(&mut state, StateEvent::Snapshot(snapshot));
reduce(&mut state, StateEvent::ChannelChanged(sample_channel(15)));
reduce(&mut state, StateEvent::ClientChanged(sample_client(20, 2)));
let s = state.as_ref().unwrap();
let channel_ids: Vec<u64> = s.channels().map(|c| c.id.0).collect();
let client_ids: Vec<u64> = s.clients().map(|c| c.id.0).collect();
assert_eq!(channel_ids, vec![20, 10, 15]);
assert_eq!(client_ids, vec![10, 30, 20]);
}
#[test]
fn malformed_snapshot_duplicate_ids_are_deduplicated_in_order_vectors() {
let mut snapshot = sample_snapshot();
snapshot.channels.push(ChannelInfo {
name: "duplicate".into(),
..sample_channel(1)
});
snapshot.clients.push(ClientInfo {
name: "duplicate".into(),
..sample_client(10, 2)
});
let mut state = None;
let reduction = reduce(&mut state, StateEvent::Snapshot(snapshot));
let s = state.as_ref().unwrap();
assert_eq!(s.channel_count(), 2);
assert_eq!(s.channels().count(), 2);
assert_eq!(s.client_count(), 1);
assert_eq!(s.clients().count(), 1);
assert_eq!(s.channel(ChannelId(1)).unwrap().name, "duplicate");
assert_eq!(s.client(ClientId(10)).unwrap().channel, ChannelId(2));
let Delta::SnapshotApplied(emitted) = &reduction.deltas[1] else {
panic!("expected snapshot delta");
};
assert_eq!(emitted.channels.len(), 2);
assert_eq!(emitted.clients.len(), 1);
}
#[test]
fn voice_activity_updates_existing_client_and_emits_delta() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(
&mut state,
StateEvent::VoiceActivityChanged {
client_id: ClientId(10),
is_speaking: true,
},
);
assert!(
state
.as_ref()
.unwrap()
.client(ClientId(10))
.unwrap()
.is_speaking
);
assert_eq!(
reduction.deltas,
vec![Delta::VoiceActivityChanged {
client_id: ClientId(10),
is_speaking: true,
}]
);
}
#[test]
fn voice_activity_for_unknown_client_is_ignored() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(
&mut state,
StateEvent::VoiceActivityChanged {
client_id: ClientId(999),
is_speaking: true,
},
);
assert!(reduction.deltas.is_empty());
}
#[test]
fn own_channel_returns_correct_channel() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let own = state.as_ref().unwrap().own_channel();
assert!(own.is_some());
assert_eq!(own.unwrap().id, ChannelId(1));
}
#[test]
fn clients_in_channel_filters_correctly() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
reduce(&mut state, StateEvent::ClientChanged(sample_client(20, 1)));
reduce(&mut state, StateEvent::ClientChanged(sample_client(30, 2)));
let s = state.as_ref().unwrap();
assert_eq!(s.clients_in_channel(ChannelId(1)).count(), 2);
assert_eq!(s.clients_in_channel(ChannelId(2)).count(), 1);
}
#[test]
fn malformed_delete_is_tolerated() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(999)));
assert!(reduction.deltas.is_empty());
assert_eq!(state.as_ref().unwrap().channel_count(), 2);
}
#[test]
fn same_event_sequence_produces_same_state() {
let mut a: Option<ServerState> = None;
let mut b: Option<ServerState> = None;
let events: Vec<StateEvent> = vec![
StateEvent::Snapshot(sample_snapshot()),
StateEvent::ClientChanged(sample_client(20, 2)),
StateEvent::ChannelChanged(ChannelInfo {
id: ChannelId(3),
parent: ChannelId(1),
name: "x".into(),
order: 0,
has_password: false,
needed_talk_power: None,
}),
StateEvent::ClientLeft(ClientId(20)),
];
let mut deltas_a = Vec::new();
let mut deltas_b = Vec::new();
for e in &events {
deltas_a.extend(reduce(&mut a, e.clone()).deltas);
}
for e in &events {
deltas_b.extend(reduce(&mut b, e.clone()).deltas);
}
assert_eq!(
a.as_ref().unwrap().channel_count(),
b.as_ref().unwrap().channel_count()
);
assert_eq!(
a.as_ref().unwrap().client_count(),
b.as_ref().unwrap().client_count()
);
assert_eq!(
a.as_ref().unwrap().own_client_id,
b.as_ref().unwrap().own_client_id
);
assert_eq!(deltas_a, deltas_b);
}
}