chore: restore product scaffold to rollback baseline
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -5,35 +5,28 @@
|
||||
//! and stopped before disconnect. It does not retry on device
|
||||
//! change.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
#[cfg(all(
|
||||
not(target_os = "linux"),
|
||||
not(target_os = "ios"),
|
||||
not(target_os = "macos"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
#[cfg(all(
|
||||
not(target_os = "linux"),
|
||||
not(target_os = "ios"),
|
||||
not(target_os = "macos"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
use cpal::{SampleFormat, SizedSample};
|
||||
#[cfg(all(
|
||||
not(target_os = "linux"),
|
||||
not(target_os = "ios"),
|
||||
not(target_os = "macos"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
#[cfg(all(
|
||||
not(target_os = "linux"),
|
||||
not(target_os = "ios"),
|
||||
not(target_os = "macos"),
|
||||
not(target_os = "android")
|
||||
@@ -110,7 +103,6 @@ pub struct AudioDeviceInfo {
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
not(target_os = "linux"),
|
||||
not(target_os = "ios"),
|
||||
not(target_os = "macos"),
|
||||
not(target_os = "android")
|
||||
@@ -120,7 +112,6 @@ fn desktop_device_id(device: &cpal::Device) -> Option<String> {
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
not(target_os = "linux"),
|
||||
not(target_os = "ios"),
|
||||
not(target_os = "macos"),
|
||||
not(target_os = "android")
|
||||
@@ -132,7 +123,6 @@ fn short_device_id(id: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
not(target_os = "linux"),
|
||||
not(target_os = "ios"),
|
||||
not(target_os = "macos"),
|
||||
not(target_os = "android")
|
||||
@@ -188,53 +178,29 @@ pub fn list_audio_devices() -> AudioDeviceList {
|
||||
output_devices: Vec::new(),
|
||||
};
|
||||
let host = cpal::default_host();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let default_input_name = host.default_input_device().and_then(|device| {
|
||||
device
|
||||
.description()
|
||||
.ok()
|
||||
.map(|description| description.name().trim().to_owned())
|
||||
.filter(|name| !name.is_empty())
|
||||
});
|
||||
let default_output_name = host.default_output_device().and_then(|device| {
|
||||
device
|
||||
.description()
|
||||
.ok()
|
||||
.map(|description| description.name().trim().to_owned())
|
||||
.filter(|name| !name.is_empty())
|
||||
});
|
||||
list.input_devices =
|
||||
crate::linux_pipewire_input::list_input_devices(default_input_name.as_deref());
|
||||
list.output_devices =
|
||||
crate::linux_pipewire_input::list_output_devices(default_output_name.as_deref());
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let default_in = host
|
||||
.default_input_device()
|
||||
.and_then(|device| desktop_device_id(&device));
|
||||
if let Ok(devices) = host.input_devices() {
|
||||
for d in devices {
|
||||
if let Some(mut device) = describe_device(&d) {
|
||||
device.is_default = default_in
|
||||
.as_ref()
|
||||
.is_some_and(|default_id| default_id == &device.id);
|
||||
list.input_devices.push(device);
|
||||
}
|
||||
let default_in = host
|
||||
.default_input_device()
|
||||
.and_then(|device| desktop_device_id(&device));
|
||||
let default_out = host
|
||||
.default_output_device()
|
||||
.and_then(|device| desktop_device_id(&device));
|
||||
if let Ok(devices) = host.input_devices() {
|
||||
for d in devices {
|
||||
if let Some(mut device) = describe_device(&d) {
|
||||
device.is_default = default_in
|
||||
.as_ref()
|
||||
.is_some_and(|default_id| default_id == &device.id);
|
||||
list.input_devices.push(device);
|
||||
}
|
||||
}
|
||||
let default_out = host
|
||||
.default_output_device()
|
||||
.and_then(|device| desktop_device_id(&device));
|
||||
if let Ok(devices) = host.output_devices() {
|
||||
for d in devices {
|
||||
if let Some(mut device) = describe_device(&d) {
|
||||
device.is_default = default_out
|
||||
.as_ref()
|
||||
.is_some_and(|default_id| default_id == &device.id);
|
||||
list.output_devices.push(device);
|
||||
}
|
||||
}
|
||||
if let Ok(devices) = host.output_devices() {
|
||||
for d in devices {
|
||||
if let Some(mut device) = describe_device(&d) {
|
||||
device.is_default = default_out
|
||||
.as_ref()
|
||||
.is_some_and(|default_id| default_id == &device.id);
|
||||
list.output_devices.push(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,23 +306,31 @@ pub struct AudioEngine {
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
client_volume_overrides: Arc<Mutex<HashMap<SessionAudioId, f32>>>,
|
||||
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
||||
mic_gain: f32,
|
||||
// Streams must be dropped to stop audio. Linux owns native
|
||||
// PipeWire/PulseAudio threads; Windows owns cpal streams. On iOS both
|
||||
// sides collapse into a single `IosVoiceUnit` (one VoiceProcessingIO
|
||||
// AudioUnit hosts mic + speaker).
|
||||
#[cfg(target_os = "linux")]
|
||||
_input_stream: Mutex<Option<crate::linux_pipewire_input::LinuxInput>>,
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
// Streams must be dropped to stop audio. Both are `!Send` because
|
||||
// cpal's Stream isn't Send on some backends; we keep them in an
|
||||
// Option wrapped by Mutex so stop() can move them out. On Linux
|
||||
// the output side is `crate::sdl_output::SdlOutput` instead of a
|
||||
// cpal Stream (see the SDD note inside `sdl_output.rs`); the
|
||||
// same unsafe Send/Sync impl below covers both. On iOS both
|
||||
// sides collapse into a single `IosVoiceUnit` (one
|
||||
// VoiceProcessingIO AudioUnit hosts mic + speaker) — cpal is
|
||||
// unused on iOS for the reasons documented in
|
||||
// `ios_voice_unit.rs`.
|
||||
#[cfg(all(
|
||||
not(target_os = "ios"),
|
||||
not(target_os = "macos"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
_input_stream: Mutex<Option<cpal::Stream>>,
|
||||
#[cfg(target_os = "linux")]
|
||||
_output_stream: Mutex<Option<crate::linux_pipewire_input::LinuxOutput>>,
|
||||
_output_stream: Mutex<Option<crate::sdl_output::SdlOutput>>,
|
||||
#[cfg(all(
|
||||
not(target_os = "linux"),
|
||||
not(target_os = "ios"),
|
||||
@@ -483,31 +457,6 @@ fn open_ios_voice_backend(
|
||||
Ok(IosVoiceBackend::Vpio(unit))
|
||||
}
|
||||
|
||||
fn apply_saved_client_volume(
|
||||
audio_handler: &mut AudioHandler<SessionAudioId>,
|
||||
client_volume_overrides: &Mutex<HashMap<SessionAudioId, f32>>,
|
||||
client_id: SessionAudioId,
|
||||
) {
|
||||
let override_volume = match client_volume_overrides.lock() {
|
||||
Ok(overrides) => overrides.get(&client_id).copied(),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
target: "chanora_audio",
|
||||
client_id = client_id.0,
|
||||
error = %error,
|
||||
"client volume overrides lock poisoned — saved volume not applied"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(volume) = override_volume {
|
||||
if let Some(queue) = audio_handler.get_mut_queues().get_mut(&client_id) {
|
||||
queue.volume = volume;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioEngine {
|
||||
#[cfg(target_os = "android")]
|
||||
fn spawn_android_backend_event_task(
|
||||
@@ -743,7 +692,7 @@ impl AudioEngine {
|
||||
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
|
||||
}
|
||||
|
||||
/// Non-Apple/non-Android implementation: native Linux voice I/O or cpal on Windows.
|
||||
/// Non-Apple/non-Android implementation: cpal capture + (cpal | SDL2) output.
|
||||
/// Kept as a separate function so the Apple and Android paths can
|
||||
/// short-circuit at the top of `start_with_gate` without dragging a 200-line
|
||||
/// cfg-gated block. Body is the pre-iOS-port code, unchanged
|
||||
@@ -762,16 +711,7 @@ impl AudioEngine {
|
||||
"starting audio engine: cpal host selected"
|
||||
);
|
||||
|
||||
fn device_name(device: &cpal::Device) -> Option<String> {
|
||||
device
|
||||
.description()
|
||||
.ok()
|
||||
.map(|description| description.name().trim().to_owned())
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
/// Helper: find a device by stable id, falling back to default.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn find_device<DefaultFn, AllFn, Devices>(
|
||||
host: &cpal::Host,
|
||||
default_fn: DefaultFn,
|
||||
@@ -795,30 +735,28 @@ impl AudioEngine {
|
||||
default_fn(host)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let in_dev = host.default_input_device();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let in_dev = find_device(
|
||||
&host,
|
||||
cpal::Host::default_input_device,
|
||||
cpal::Host::input_devices,
|
||||
cfg.input_device_id.as_deref(),
|
||||
);
|
||||
)
|
||||
.ok_or(AudioError::NoInputDevice)?;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let out_dev = host.default_output_device();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let out_dev = find_device(
|
||||
&host,
|
||||
cpal::Host::default_output_device,
|
||||
cpal::Host::output_devices,
|
||||
cfg.output_device_id.as_deref(),
|
||||
);
|
||||
)
|
||||
.ok_or(AudioError::NoOutputDevice)?;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let in_dev = in_dev.ok_or(AudioError::NoInputDevice)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let out_dev = out_dev.ok_or(AudioError::NoOutputDevice)?;
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
in_device = %in_dev.description().map(|d| d.name().to_owned()).unwrap_or_default(),
|
||||
out_device = %out_dev.description().map(|d| d.name().to_owned()).unwrap_or_default(),
|
||||
"starting audio engine"
|
||||
);
|
||||
|
||||
// Log the cpal-reported default configs *before* trying to
|
||||
// open streams, so a downstream stream-build failure can
|
||||
@@ -830,29 +768,6 @@ impl AudioEngine {
|
||||
// "Start audio button not work"). Promote what would
|
||||
// otherwise be silent or laconic errors into structured
|
||||
// log records the user can paste back.
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(in_dev) = in_dev.as_ref() {
|
||||
match in_dev.default_input_config() {
|
||||
Ok(c) => info!(
|
||||
target: "chanora_audio",
|
||||
channels = c.channels(),
|
||||
sample_rate = c.sample_rate(),
|
||||
sample_format = ?c.sample_format(),
|
||||
"default_input_config reported"
|
||||
),
|
||||
Err(e) => warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"default_input_config FAILED — capture will be disabled"
|
||||
),
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"cpal default_input_device unavailable; Linux PipeWire capture will be tried directly"
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
match in_dev.default_input_config() {
|
||||
Ok(c) => info!(
|
||||
target: "chanora_audio",
|
||||
@@ -867,24 +782,6 @@ impl AudioEngine {
|
||||
"default_input_config FAILED — capture will be disabled"
|
||||
),
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(out_dev) = out_dev.as_ref() {
|
||||
match out_dev.default_output_config() {
|
||||
Ok(c) => info!(
|
||||
target: "chanora_audio",
|
||||
channels = c.channels(),
|
||||
sample_rate = c.sample_rate(),
|
||||
sample_format = ?c.sample_format(),
|
||||
"default_output_config reported"
|
||||
),
|
||||
Err(e) => warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"default_output_config FAILED — output stream will fail to build"
|
||||
),
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
match out_dev.default_output_config() {
|
||||
Ok(c) => info!(
|
||||
target: "chanora_audio",
|
||||
@@ -900,7 +797,6 @@ impl AudioEngine {
|
||||
),
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let transmit_flag_for_capture = transmit_gate.flag_arc();
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
let frames_received = Arc::new(AtomicU32::new(0));
|
||||
@@ -908,7 +804,6 @@ impl AudioEngine {
|
||||
let output_muted = Arc::new(AtomicBool::new(false));
|
||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// ---------- Capture ----------
|
||||
// Capture is best-effort. If the platform default input
|
||||
@@ -916,52 +811,24 @@ impl AudioEngine {
|
||||
// headless null sources or for users who deny the mic
|
||||
// permission) we log and continue — playback alone is
|
||||
// still useful. PTT becomes a no-op in that case.
|
||||
#[cfg(target_os = "linux")]
|
||||
let capture_result = match crate::linux_pipewire_input::start_capture(
|
||||
cfg.input_device_id.as_deref(),
|
||||
in_dev.as_ref().and_then(device_name).as_deref(),
|
||||
voice_out_tx.clone(),
|
||||
transmit_gate.clone(),
|
||||
frames_sent.clone(),
|
||||
cfg.mic_gain,
|
||||
) {
|
||||
Ok((stream, device_info)) => Ok((Some(stream), true, device_info.name)),
|
||||
Err(linux_error) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %linux_error,
|
||||
"Linux native capture unavailable"
|
||||
);
|
||||
Err(linux_error)
|
||||
}
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let capture_result = try_open_capture(
|
||||
&in_dev,
|
||||
voice_out_tx,
|
||||
transmit_flag_for_capture,
|
||||
frames_sent.clone(),
|
||||
cfg.mic_gain,
|
||||
)
|
||||
.map(|stream| {
|
||||
(
|
||||
Some(stream),
|
||||
true,
|
||||
device_name(&in_dev).unwrap_or_else(|| "cpal capture".to_string()),
|
||||
)
|
||||
});
|
||||
let (input_stream, capture_active, capture_device_name) = match capture_result {
|
||||
Ok((stream, active, device_name)) => (stream, active, device_name),
|
||||
);
|
||||
let (input_stream, capture_active) = match capture_result {
|
||||
Ok(s) => (Some(s), true),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"capture stream unavailable; continuing with playback only"
|
||||
);
|
||||
(None, false, "<capture unavailable>".to_string())
|
||||
(None, false)
|
||||
}
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(s) = &input_stream {
|
||||
s.play()
|
||||
.map_err(|e| AudioError::Backend(format!("input play: {e}")))?;
|
||||
@@ -971,18 +838,21 @@ impl AudioEngine {
|
||||
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||
Arc::new(Mutex::new(AudioHandler::new()));
|
||||
|
||||
// Linux uses native PipeWire/PulseAudio playback. PipeWire is primary;
|
||||
// PulseAudio is fallback and can also be selected explicitly by id.
|
||||
// Linux uses SDL2 for output (Qint / upstream tsclientlib
|
||||
// pattern). cpal's Linux backend opens raw ALSA which routes
|
||||
// through `dmix`+`plug` and produces audible crackling /
|
||||
// popping on the 48 kHz → device-rate step. SDL2 on the same
|
||||
// box routes through PipeWire's PA bridge (or PulseAudio)
|
||||
// whose resampler is high-quality. We keep cpal on Windows
|
||||
// and macOS — both have native backends (WASAPI / CoreAudio)
|
||||
// without this problem. See `crates/chanora_audio/src/sdl_output.rs`
|
||||
// for the full rationale.
|
||||
#[cfg(target_os = "linux")]
|
||||
let (output_stream, output_device_info) = crate::linux_pipewire_input::start_output(
|
||||
cfg.output_device_id.as_deref(),
|
||||
out_dev.as_ref().and_then(device_name).as_deref(),
|
||||
let output_stream = crate::sdl_output::SdlOutput::start(
|
||||
audio_handler.clone(),
|
||||
output_gain.clone(),
|
||||
output_muted.clone(),
|
||||
)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
let output_device_name = output_device_info.name;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let output_stream = {
|
||||
@@ -1057,20 +927,10 @@ impl AudioEngine {
|
||||
.map_err(|e| AudioError::Backend(format!("output play: {e}")))?;
|
||||
stream
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let output_device_name = device_name(&out_dev).unwrap_or_default();
|
||||
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
in_device = %capture_device_name,
|
||||
out_device = %output_device_name,
|
||||
"starting audio engine"
|
||||
);
|
||||
|
||||
// ---------- Inbound forwarder ----------
|
||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let handler_for_task = audio_handler.clone();
|
||||
let client_volume_overrides_for_task = client_volume_overrides.clone();
|
||||
let frames_received_for_task = frames_received.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
@@ -1087,11 +947,6 @@ impl AudioEngine {
|
||||
if let Err(e) = h.handle_packet(id, v.packet) {
|
||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||
} else {
|
||||
apply_saved_client_volume(
|
||||
&mut h,
|
||||
&client_volume_overrides_for_task,
|
||||
id,
|
||||
);
|
||||
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -1111,10 +966,6 @@ impl AudioEngine {
|
||||
audio_processing_config,
|
||||
audio_processing_stats,
|
||||
audio_handler,
|
||||
client_volume_overrides,
|
||||
#[cfg(target_os = "linux")]
|
||||
_input_stream: Mutex::new(input_stream),
|
||||
#[cfg(target_os = "windows")]
|
||||
_input_stream: Mutex::new(input_stream),
|
||||
_output_stream: Mutex::new(Some(output_stream)),
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
@@ -1140,7 +991,6 @@ impl AudioEngine {
|
||||
let output_muted = Arc::new(AtomicBool::new(false));
|
||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||
Arc::new(Mutex::new(AudioHandler::new()));
|
||||
@@ -1287,7 +1137,6 @@ impl AudioEngine {
|
||||
|
||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let handler_for_task = audio_handler.clone();
|
||||
let client_volume_overrides_for_task = client_volume_overrides.clone();
|
||||
let frames_received_for_task = frames_received.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
@@ -1304,11 +1153,6 @@ impl AudioEngine {
|
||||
if let Err(e) = h.handle_packet(id, v.packet) {
|
||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||
} else {
|
||||
apply_saved_client_volume(
|
||||
&mut h,
|
||||
&client_volume_overrides_for_task,
|
||||
id,
|
||||
);
|
||||
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -1328,7 +1172,6 @@ impl AudioEngine {
|
||||
audio_processing_config,
|
||||
audio_processing_stats,
|
||||
audio_handler,
|
||||
client_volume_overrides,
|
||||
voice_out_tx,
|
||||
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
||||
mic_gain: cfg.mic_gain,
|
||||
@@ -1381,7 +1224,6 @@ impl AudioEngine {
|
||||
let output_muted = Arc::new(AtomicBool::new(false));
|
||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||
Arc::new(Mutex::new(AudioHandler::new()));
|
||||
@@ -1416,7 +1258,6 @@ impl AudioEngine {
|
||||
// waiting.
|
||||
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let handler_for_task = audio_handler.clone();
|
||||
let client_volume_overrides_for_task = client_volume_overrides.clone();
|
||||
let frames_received_for_task = frames_received.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
@@ -1433,11 +1274,6 @@ impl AudioEngine {
|
||||
if let Err(e) = h.handle_packet(id, v.packet) {
|
||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||
} else {
|
||||
apply_saved_client_volume(
|
||||
&mut h,
|
||||
&client_volume_overrides_for_task,
|
||||
id,
|
||||
);
|
||||
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -1457,7 +1293,6 @@ impl AudioEngine {
|
||||
audio_processing_config,
|
||||
audio_processing_stats,
|
||||
audio_handler,
|
||||
client_volume_overrides,
|
||||
voice_out_tx,
|
||||
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
||||
mic_gain: cfg.mic_gain,
|
||||
@@ -1477,8 +1312,12 @@ impl AudioEngine {
|
||||
// common contract is that dropping the wrapper stops
|
||||
// audio. iOS collapses input + output into one
|
||||
// `IosVoiceUnit` (see `ios_voice_unit.rs`); every other
|
||||
// platform has separate input + output stream owners.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
// platform has separate cpal input + cpal/SDL output.
|
||||
#[cfg(all(
|
||||
not(target_os = "ios"),
|
||||
not(target_os = "macos"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
{
|
||||
let _ = self._input_stream.lock().unwrap().take();
|
||||
let _ = self._output_stream.lock().unwrap().take();
|
||||
@@ -1774,11 +1613,11 @@ impl AudioEngine {
|
||||
}
|
||||
|
||||
/// Latest Android voice-audio diagnostics snapshot (SDD-112 item
|
||||
/// 10 / SDD-113 item 7 / SDD-116 item 3). Returns `Some(...)`
|
||||
/// 10 / SDD-113 item 7 / SDD-116 item 3). On non-Android targets
|
||||
/// this always returns `None`. On Android it returns `Some(...)`
|
||||
/// once `AndroidVoiceUnit::open()` has published a snapshot; the
|
||||
/// slot is cleared on `close()` / `Drop`. Per SDD-090 the snapshot
|
||||
/// contains only device-side technical scalars — no PII.
|
||||
#[cfg(target_os = "android")]
|
||||
/// slot is cleared on `close()` / `Drop`. Per SDD-090 the
|
||||
/// snapshot contains only device-side technical scalars — no PII.
|
||||
pub fn android_diagnostics(
|
||||
&self,
|
||||
) -> Option<crate::mobile_voice_backend::AndroidAudioDiagnostics> {
|
||||
@@ -1830,25 +1669,6 @@ impl AudioEngine {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match self.client_volume_overrides.lock() {
|
||||
Ok(mut overrides) => {
|
||||
if (clamped - 1.0).abs() <= f32::EPSILON {
|
||||
overrides.remove(&SessionAudioId(client_id));
|
||||
} else {
|
||||
overrides.insert(SessionAudioId(client_id), clamped);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "chanora_audio",
|
||||
client_id,
|
||||
volume = clamped,
|
||||
error = %e,
|
||||
"set_client_volume: client volume overrides lock poisoned"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1860,7 +1680,7 @@ impl Drop for AudioEngine {
|
||||
|
||||
// ---------- Capture pipeline ----------
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
fn try_open_capture(
|
||||
in_dev: &cpal::Device,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
@@ -1879,8 +1699,9 @@ fn try_open_capture(
|
||||
// jitter of WASAPI shared mode.
|
||||
// * macOS / iOS: CoreAudio picks a HAL-friendly default;
|
||||
// iOS RemoteIO rejects arbitrary buffer-size requests.
|
||||
// * Linux does not reach this cpal capture path in production;
|
||||
// it uses native PipeWire/PulseAudio capture instead.
|
||||
// * Linux: same SDL2-vs-cpal split as the output path; we
|
||||
// still use cpal for capture but leave Default since
|
||||
// PipeWire's ALSA shim works well there.
|
||||
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -1917,7 +1738,7 @@ fn try_open_capture(
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
pub(crate) struct CaptureState {
|
||||
struct CaptureState {
|
||||
encoder: OpusEncoder,
|
||||
in_sample_rate: u32,
|
||||
in_channels: usize,
|
||||
@@ -1956,7 +1777,7 @@ pub(crate) struct CaptureState {
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
impl CaptureState {
|
||||
pub(crate) fn new(
|
||||
fn new(
|
||||
encoder: OpusEncoder,
|
||||
in_sample_rate: u32,
|
||||
in_channels: usize,
|
||||
@@ -1989,7 +1810,7 @@ impl CaptureState {
|
||||
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
|
||||
/// 48 kHz mono frames; encode and send when `transmit_active`
|
||||
/// is true (PTT engaged).
|
||||
pub(crate) fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
||||
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
// Drain accumulator while muted so we don't pop on PTT release.
|
||||
self.pcm_accum.clear();
|
||||
@@ -2138,7 +1959,7 @@ impl CaptureState {
|
||||
|
||||
/// Per-sample format conversion to f32 in the range [-1.0, 1.0].
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
pub(crate) trait ToF32 {
|
||||
trait ToF32 {
|
||||
fn to_f32_sample(self) -> f32;
|
||||
}
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
@@ -2160,7 +1981,7 @@ impl ToF32 for u16 {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
fn build_input_stream<T>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
//! stream-format requests on bus 0 (output) and bus 1 (input).
|
||||
//!
|
||||
//! This file replaces the cpal capture + playback streams on iOS and macOS.
|
||||
//! Linux uses native PipeWire/PulseAudio voice I/O;
|
||||
//! Linux uses SDL2 for output (see `sdl_output.rs`) and cpal for capture;
|
||||
//! Windows uses cpal's WASAPI backend.
|
||||
//!
|
||||
//! ## What VPIO gives us
|
||||
@@ -53,7 +53,7 @@
|
||||
//! thread that owns the unit. We open the unit on the same thread
|
||||
//! that calls `Self::start` (the tokio worker that runs
|
||||
//! `chanora_core::ChanoraSession::start_audio`, the same pattern
|
||||
//! the desktop backends use) and never move it. The outer `AudioEngine`
|
||||
//! cpal + SDL use) and never move it. The outer `AudioEngine`
|
||||
//! already carries an `unsafe impl Send` to satisfy the same
|
||||
//! constraint for cpal's `!Send` Stream type; that impl covers
|
||||
//! VPIO too.
|
||||
@@ -589,16 +589,16 @@ impl IosVoiceUnit {
|
||||
/// captures samples, render callback fires when the hardware
|
||||
/// needs samples to play.
|
||||
///
|
||||
/// Parameters mirror the capture + playback inputs the desktop
|
||||
/// backends accept so engine.rs can swap backends with
|
||||
/// Parameters mirror the capture + playback inputs the cpal
|
||||
/// and SDL backends accept so engine.rs can swap backends with
|
||||
/// a `cfg`.
|
||||
///
|
||||
/// * `handler` — shared AudioHandler the inbound forwarder
|
||||
/// feeds Opus packets into. The output render callback pulls
|
||||
/// decoded f32 frames from it (48 kHz stereo) and downmixes
|
||||
/// to the i16 mono buffer VPIO expects.
|
||||
/// * `output_gain` / `output_muted` — same atomics the desktop
|
||||
/// output paths read on every callback so the master
|
||||
/// * `output_gain` / `output_muted` — same atomics the cpal
|
||||
/// and SDL output paths read on every callback so the master
|
||||
/// volume + local-mute UI works identically across backends.
|
||||
/// * `voice_out_tx` — channel the capture pipeline sends
|
||||
/// encoded `OutPacket`s on.
|
||||
@@ -734,7 +734,7 @@ impl IosVoiceUnit {
|
||||
// 1. Lock the AudioHandler, ask it to fill a scratch
|
||||
// f32 stereo buffer (length = 2 * num_frames). The
|
||||
// handler runs Opus decode + per-client jitter
|
||||
// buffer + mix. Same primitive desktop output
|
||||
// buffer + mix. Same primitive cpal + SDL output
|
||||
// paths use; this is the platform-neutral playback
|
||||
// contract from `tsclientlib::audio::AudioHandler`.
|
||||
// 2. Downmix to i16 mono with master gain. VPIO expects
|
||||
@@ -748,10 +748,12 @@ impl IosVoiceUnit {
|
||||
// AudioHandler in step 1 so its jitter buffer
|
||||
// doesn't grow unbounded while muted. This is the
|
||||
// contract every other backend follows (matches
|
||||
// Linux PipeWire/PulseAudio and the cpal output stream).
|
||||
// SdlOutput::callback and the cpal output stream).
|
||||
//
|
||||
// Build the playback pipeline (direct fill_buffer in
|
||||
// render callback; matches the desktop voice backends).
|
||||
// render callback; matches tsclientlib's reference SDL
|
||||
// example at
|
||||
// tsclientlib/examples/audio_utils/ts_to_audio.rs).
|
||||
//
|
||||
// The earlier ring-buffer attempt (rc.8+73..+74) decoupled
|
||||
// AudioHandler from the render callback via a 50 Hz
|
||||
@@ -764,12 +766,14 @@ impl IosVoiceUnit {
|
||||
// consumer pull being larger, the ring averaged out empty
|
||||
// — fill_buffer was returning silence 65-84% of ticks
|
||||
// because we drained it too aggressively before packets
|
||||
// arrived. Direct desktop callbacks follow the same pattern.
|
||||
// arrived. Linux/SDL's same pattern works fine because
|
||||
// SDL calls fill_buffer at exactly the device callback
|
||||
// rate.
|
||||
//
|
||||
// Revert to direct call: render callback locks
|
||||
// AudioHandler, asks for `num_frames` stereo frames, and
|
||||
// immediately downmixes to i16 mono into the output
|
||||
// buffer. Same as desktop output, just stereo-f32 -> mono-i16
|
||||
// buffer. Same as Linux/SDL, just stereo-f32 -> mono-i16
|
||||
// converted at the boundary.
|
||||
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048);
|
||||
let handler_for_render = params.handler.clone();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! ## What's wired in this Beta
|
||||
//!
|
||||
//! * Default-input capture via platform audio backends (Oboe on Android,
|
||||
//! VoiceProcessingIO on Apple platforms, PipeWire/PulseAudio on Linux, cpal on Windows)
|
||||
//! VoiceProcessingIO on Apple platforms, cpal/SDL elsewhere)
|
||||
//! * Frame-aligned 20 ms / 48 kHz mono Opus encoding via `audiopus`
|
||||
//! * Forward encoded frames to the protocol crate as `OutPacket`s
|
||||
//! * Inbound voice packets fed to `tsclientlib::audio::AudioHandler`
|
||||
@@ -32,7 +32,6 @@ pub mod audio_processing;
|
||||
pub mod debug_wav;
|
||||
mod engine;
|
||||
pub mod frame;
|
||||
#[cfg(any(target_os = "android", target_os = "ios", target_os = "macos"))]
|
||||
pub mod mobile_voice_backend;
|
||||
pub mod mode_stack;
|
||||
pub(crate) mod opus_voice;
|
||||
@@ -45,11 +44,10 @@ pub mod transmit_mode;
|
||||
pub mod transmit_selector;
|
||||
pub mod vad;
|
||||
pub mod voice_activity;
|
||||
#[cfg(any(target_os = "android", target_os = "ios", test))]
|
||||
pub(crate) mod voice_render;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_pipewire_input;
|
||||
mod sdl_output;
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
mod ios_voice_unit;
|
||||
|
||||
@@ -1,967 +0,0 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{mpsc as std_mpsc, Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
use libpulse_binding as pulse;
|
||||
use libpulse_simple_binding as psimple;
|
||||
use pipewire as pw;
|
||||
use pulse::callbacks::ListResult;
|
||||
use pulse::context::{self, Context};
|
||||
use pulse::mainloop::threaded::Mainloop;
|
||||
use pulse::proplist::Proplist;
|
||||
use pulse::sample::{Format, Spec};
|
||||
use pulse::stream::Direction;
|
||||
use pw::spa::pod::Pod;
|
||||
use pw::{properties::properties, spa};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{info, warn};
|
||||
use tsclientlib::audio::AudioHandler;
|
||||
|
||||
use crate::engine::{AudioDeviceInfo, CaptureState, SessionAudioId};
|
||||
use crate::{AudioError, AudioTransmitGate};
|
||||
use chanora_protocol::OutPacket;
|
||||
|
||||
const INPUT_ID_PREFIX: &str = "pw-input:";
|
||||
const OUTPUT_ID_PREFIX: &str = "pw-output:";
|
||||
const PULSE_INPUT_ID_PREFIX: &str = "pulse-input:";
|
||||
const PULSE_OUTPUT_ID_PREFIX: &str = "pulse-output:";
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
const CAPTURE_CHANNELS: u32 = 1;
|
||||
const PLAYBACK_CHANNELS: u32 = 2;
|
||||
const PLAYBACK_FRAMES: usize = 960;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct LinuxDevice {
|
||||
backend: LinuxBackend,
|
||||
direction: LinuxDirection,
|
||||
raw_id: String,
|
||||
label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum LinuxBackend {
|
||||
PipeWire,
|
||||
PulseAudio,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum LinuxDirection {
|
||||
Input,
|
||||
Output,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PipeWireDevice {
|
||||
node_id: u32,
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl LinuxDevice {
|
||||
fn to_audio_device_info(&self, default_label: Option<&str>) -> AudioDeviceInfo {
|
||||
let is_default = default_label.is_some_and(|label| label == self.label);
|
||||
let (backend, id) = match (self.backend, self.direction) {
|
||||
(LinuxBackend::PipeWire, LinuxDirection::Input) => (
|
||||
"PipeWire",
|
||||
input_device_id(self.raw_id.parse::<u32>().unwrap_or_default()),
|
||||
),
|
||||
(LinuxBackend::PipeWire, LinuxDirection::Output) => (
|
||||
"PipeWire",
|
||||
output_device_id(self.raw_id.parse::<u32>().unwrap_or_default()),
|
||||
),
|
||||
(LinuxBackend::PulseAudio, LinuxDirection::Input) => {
|
||||
("PulseAudio", pulse_input_device_id(&self.raw_id))
|
||||
}
|
||||
(LinuxBackend::PulseAudio, LinuxDirection::Output) => {
|
||||
("PulseAudio", pulse_output_device_id(&self.raw_id))
|
||||
}
|
||||
};
|
||||
AudioDeviceInfo {
|
||||
id,
|
||||
name: self.label.clone(),
|
||||
details: format!("{backend} · id={}", self.raw_id),
|
||||
is_default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn input_device_id(node_id: u32) -> String {
|
||||
format!("{INPUT_ID_PREFIX}{node_id}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn input_device_node_id_from_id(id: &str) -> Option<u32> {
|
||||
id.strip_prefix(INPUT_ID_PREFIX)?.parse().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn output_device_id(node_id: u32) -> String {
|
||||
format!("{OUTPUT_ID_PREFIX}{node_id}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn output_device_node_id_from_id(id: &str) -> Option<u32> {
|
||||
id.strip_prefix(OUTPUT_ID_PREFIX)?.parse().ok()
|
||||
}
|
||||
|
||||
fn pulse_input_device_id(name: &str) -> String {
|
||||
format!("{PULSE_INPUT_ID_PREFIX}{name}")
|
||||
}
|
||||
|
||||
fn pulse_output_device_id(name: &str) -> String {
|
||||
format!("{PULSE_OUTPUT_ID_PREFIX}{name}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn pulse_input_name_from_id(id: &str) -> Option<&str> {
|
||||
id.strip_prefix(PULSE_INPUT_ID_PREFIX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn pulse_output_name_from_id(id: &str) -> Option<&str> {
|
||||
id.strip_prefix(PULSE_OUTPUT_ID_PREFIX)
|
||||
}
|
||||
|
||||
pub(crate) fn list_input_devices(default_label: Option<&str>) -> Vec<AudioDeviceInfo> {
|
||||
list_devices(LinuxDirection::Input)
|
||||
.iter()
|
||||
.map(|device| device.to_audio_device_info(default_label))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn list_output_devices(default_label: Option<&str>) -> Vec<AudioDeviceInfo> {
|
||||
list_devices(LinuxDirection::Output)
|
||||
.iter()
|
||||
.map(|device| device.to_audio_device_info(default_label))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn start_capture(
|
||||
prefer: Option<&str>,
|
||||
default_label: Option<&str>,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_gate: AudioTransmitGate,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
) -> Result<(LinuxInput, AudioDeviceInfo), AudioError> {
|
||||
let devices = list_devices(LinuxDirection::Input);
|
||||
let device = select_device(&devices, prefer, default_label).ok_or_else(|| {
|
||||
AudioError::Backend("Linux audio did not expose a usable capture source".to_string())
|
||||
})?;
|
||||
let info = device.to_audio_device_info(default_label);
|
||||
let encoder = crate::opus_voice::new_voip_encoder("pipewire capture")?;
|
||||
let capture_state = Arc::new(Mutex::new(CaptureState::new(
|
||||
encoder,
|
||||
SAMPLE_RATE,
|
||||
CAPTURE_CHANNELS as usize,
|
||||
mic_gain,
|
||||
voice_out_tx,
|
||||
transmit_gate.flag_arc(),
|
||||
frames_sent,
|
||||
)));
|
||||
let stream = match device.backend {
|
||||
LinuxBackend::PipeWire => LinuxInput::PipeWire(PipeWireInput::start(
|
||||
PipeWireDevice {
|
||||
node_id: device.raw_id.parse().map_err(|_| {
|
||||
AudioError::Backend(format!("invalid PipeWire source id `{}`", device.raw_id))
|
||||
})?,
|
||||
label: device.label.clone(),
|
||||
},
|
||||
capture_state,
|
||||
)?),
|
||||
LinuxBackend::PulseAudio => LinuxInput::PulseAudio(PulseAudioInput::start(
|
||||
device.raw_id.clone(),
|
||||
capture_state,
|
||||
)?),
|
||||
};
|
||||
Ok((stream, info))
|
||||
}
|
||||
|
||||
pub(crate) fn start_output(
|
||||
prefer: Option<&str>,
|
||||
default_label: Option<&str>,
|
||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
) -> Result<(LinuxOutput, AudioDeviceInfo), AudioError> {
|
||||
let devices = list_devices(LinuxDirection::Output);
|
||||
let device = select_device(&devices, prefer, default_label).ok_or_else(|| {
|
||||
AudioError::Backend("Linux audio did not expose a usable playback sink".to_string())
|
||||
})?;
|
||||
let info = device.to_audio_device_info(default_label);
|
||||
let stream = match device.backend {
|
||||
LinuxBackend::PipeWire => LinuxOutput::PipeWire(PipeWireOutput::start(
|
||||
PipeWireDevice {
|
||||
node_id: device.raw_id.parse().map_err(|_| {
|
||||
AudioError::Backend(format!("invalid PipeWire sink id `{}`", device.raw_id))
|
||||
})?,
|
||||
label: device.label.clone(),
|
||||
},
|
||||
handler,
|
||||
output_gain,
|
||||
output_muted,
|
||||
)?),
|
||||
LinuxBackend::PulseAudio => LinuxOutput::PulseAudio(PulseAudioOutput::start(
|
||||
device.raw_id.clone(),
|
||||
handler,
|
||||
output_gain,
|
||||
output_muted,
|
||||
)?),
|
||||
};
|
||||
Ok((stream, info))
|
||||
}
|
||||
|
||||
fn select_device(
|
||||
devices: &[LinuxDevice],
|
||||
prefer: Option<&str>,
|
||||
default_label: Option<&str>,
|
||||
) -> Option<LinuxDevice> {
|
||||
if devices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(prefer) = prefer {
|
||||
if let Some(device) = devices
|
||||
.iter()
|
||||
.find(|device| device.to_audio_device_info(default_label).id == prefer)
|
||||
{
|
||||
return Some(device.clone());
|
||||
}
|
||||
}
|
||||
if let Some(default_label) = default_label {
|
||||
if let Some(device) = devices.iter().find(|device| device.label == default_label) {
|
||||
return Some(device.clone());
|
||||
}
|
||||
}
|
||||
devices.first().cloned()
|
||||
}
|
||||
|
||||
fn list_devices(direction: LinuxDirection) -> Vec<LinuxDevice> {
|
||||
match list_pipewire_nodes(direction) {
|
||||
Ok(pipewire) if !pipewire.is_empty() => return pipewire,
|
||||
Ok(_) => {
|
||||
warn!(target: "chanora_audio", "PipeWire device enumeration returned no devices; trying PulseAudio fallback")
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(target: "chanora_audio", error = %error, "PipeWire device enumeration failed")
|
||||
}
|
||||
}
|
||||
match list_pulse_devices(direction) {
|
||||
Ok(pulse) => pulse,
|
||||
Err(error) => {
|
||||
warn!(target: "chanora_audio", error = %error, "PulseAudio device enumeration failed");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list_pipewire_nodes(direction: LinuxDirection) -> Result<Vec<LinuxDevice>, String> {
|
||||
pw::init();
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
|
||||
let context = pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
|
||||
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
|
||||
let registry = core.get_registry().map_err(|err| err.to_string())?;
|
||||
|
||||
let done = Rc::new(Cell::new(false));
|
||||
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
|
||||
let class_prefix = match direction {
|
||||
LinuxDirection::Input => "Audio/Source",
|
||||
LinuxDirection::Output => "Audio/Sink",
|
||||
};
|
||||
|
||||
let devices_for_registry = devices.clone();
|
||||
let _registry_listener = registry
|
||||
.add_listener_local()
|
||||
.global(move |global| {
|
||||
if global.type_ != pw::types::ObjectType::Node {
|
||||
return;
|
||||
}
|
||||
let Some(props) = global.props else {
|
||||
return;
|
||||
};
|
||||
let Some(media_class) = props.get(*pw::keys::MEDIA_CLASS) else {
|
||||
return;
|
||||
};
|
||||
if !media_class.starts_with(class_prefix) {
|
||||
return;
|
||||
}
|
||||
let label = props
|
||||
.get(*pw::keys::NODE_DESCRIPTION)
|
||||
.or_else(|| props.get(*pw::keys::NODE_NICK))
|
||||
.or_else(|| props.get(*pw::keys::NODE_NAME))
|
||||
.unwrap_or("Unnamed PipeWire node")
|
||||
.to_string();
|
||||
devices_for_registry.borrow_mut().push(LinuxDevice {
|
||||
backend: LinuxBackend::PipeWire,
|
||||
direction,
|
||||
raw_id: global.id.to_string(),
|
||||
label,
|
||||
});
|
||||
})
|
||||
.register();
|
||||
|
||||
let pending = core.sync(0).map_err(|err| err.to_string())?;
|
||||
let done_for_core = done.clone();
|
||||
let loop_for_core = mainloop.clone();
|
||||
let _core_listener = core
|
||||
.add_listener_local()
|
||||
.done(move |id, seq| {
|
||||
if id == pw::core::PW_ID_CORE && seq == pending {
|
||||
done_for_core.set(true);
|
||||
loop_for_core.quit();
|
||||
}
|
||||
})
|
||||
.error(move |_id, _seq, _res, message| {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
message,
|
||||
"PipeWire core error during input enumeration"
|
||||
);
|
||||
})
|
||||
.register();
|
||||
|
||||
while !done.get() {
|
||||
mainloop.run();
|
||||
}
|
||||
|
||||
let mut devices = devices.borrow().clone();
|
||||
devices.sort_by(|left, right| {
|
||||
left.label
|
||||
.cmp(&right.label)
|
||||
.then_with(|| left.raw_id.cmp(&right.raw_id))
|
||||
});
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
fn list_pulse_devices(direction: LinuxDirection) -> Result<Vec<LinuxDevice>, String> {
|
||||
with_pulse_context(|context, mainloop| {
|
||||
let done = Rc::new(Cell::new(false));
|
||||
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
|
||||
let done_for_cb = done.clone();
|
||||
let devices_for_cb = devices.clone();
|
||||
let mainloop_for_cb = mainloop.clone();
|
||||
let introspector = context.borrow().introspect();
|
||||
match direction {
|
||||
LinuxDirection::Input => {
|
||||
let _operation = introspector.get_source_info_list(move |result| match result {
|
||||
ListResult::Item(info) => {
|
||||
if info.monitor_of_sink.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(name) = info.name.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let label = info
|
||||
.description
|
||||
.as_ref()
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| name.to_string());
|
||||
devices_for_cb.borrow_mut().push(LinuxDevice {
|
||||
backend: LinuxBackend::PulseAudio,
|
||||
direction,
|
||||
raw_id: name.to_string(),
|
||||
label,
|
||||
});
|
||||
}
|
||||
ListResult::End | ListResult::Error => {
|
||||
done_for_cb.set(true);
|
||||
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
|
||||
}
|
||||
});
|
||||
while !done.get() {
|
||||
mainloop.borrow_mut().wait();
|
||||
}
|
||||
}
|
||||
LinuxDirection::Output => {
|
||||
let _operation = introspector.get_sink_info_list(move |result| match result {
|
||||
ListResult::Item(info) => {
|
||||
let Some(name) = info.name.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let label = info
|
||||
.description
|
||||
.as_ref()
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| name.to_string());
|
||||
devices_for_cb.borrow_mut().push(LinuxDevice {
|
||||
backend: LinuxBackend::PulseAudio,
|
||||
direction,
|
||||
raw_id: name.to_string(),
|
||||
label,
|
||||
});
|
||||
}
|
||||
ListResult::End | ListResult::Error => {
|
||||
done_for_cb.set(true);
|
||||
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
|
||||
}
|
||||
});
|
||||
while !done.get() {
|
||||
mainloop.borrow_mut().wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut devices = devices.borrow().clone();
|
||||
devices.sort_by(|left, right| {
|
||||
left.label
|
||||
.cmp(&right.label)
|
||||
.then_with(|| left.raw_id.cmp(&right.raw_id))
|
||||
});
|
||||
Ok(devices)
|
||||
})
|
||||
}
|
||||
|
||||
fn with_pulse_context<T, F>(operation: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce(&Rc<RefCell<Context>>, &Rc<RefCell<Mainloop>>) -> Result<T, String>,
|
||||
{
|
||||
let proplist = build_pulse_proplist()?;
|
||||
let mainloop = Rc::new(RefCell::new(
|
||||
Mainloop::new().ok_or("failed to create PulseAudio mainloop")?,
|
||||
));
|
||||
let context = Rc::new(RefCell::new(
|
||||
Context::new_with_proplist(&*mainloop.borrow(), "chanora", &proplist)
|
||||
.ok_or("failed to create PulseAudio context")?,
|
||||
));
|
||||
let mainloop_for_cb = mainloop.clone();
|
||||
let context_for_cb = context.clone();
|
||||
context
|
||||
.borrow_mut()
|
||||
.set_state_callback(Some(Box::new(move || {
|
||||
let state = unsafe { (*context_for_cb.as_ptr()).get_state() };
|
||||
match state {
|
||||
context::State::Ready | context::State::Failed | context::State::Terminated => {
|
||||
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})));
|
||||
context
|
||||
.borrow_mut()
|
||||
.connect(None, context::FlagSet::NOFLAGS, None)
|
||||
.map_err(pa_error)?;
|
||||
mainloop.borrow_mut().lock();
|
||||
mainloop.borrow_mut().start().map_err(pa_error)?;
|
||||
loop {
|
||||
match context.borrow().get_state() {
|
||||
context::State::Ready => break,
|
||||
context::State::Failed | context::State::Terminated => {
|
||||
mainloop.borrow_mut().unlock();
|
||||
mainloop.borrow_mut().stop();
|
||||
return Err(format!(
|
||||
"PulseAudio context state {:?}",
|
||||
context.borrow().get_state()
|
||||
));
|
||||
}
|
||||
_ => mainloop.borrow_mut().wait(),
|
||||
}
|
||||
}
|
||||
context.borrow_mut().set_state_callback(None);
|
||||
let result = operation(&context, &mainloop);
|
||||
mainloop.borrow_mut().unlock();
|
||||
mainloop.borrow_mut().stop();
|
||||
result
|
||||
}
|
||||
|
||||
fn build_pulse_proplist() -> Result<Proplist, String> {
|
||||
let mut proplist = Proplist::new().ok_or("failed to create PulseAudio proplist")?;
|
||||
proplist
|
||||
.set_str(pulse::proplist::properties::APPLICATION_NAME, "chanora")
|
||||
.map_err(|_| "failed to set PulseAudio application name".to_string())?;
|
||||
Ok(proplist)
|
||||
}
|
||||
|
||||
fn pulse_capture_spec() -> Spec {
|
||||
Spec {
|
||||
format: Format::F32le,
|
||||
channels: CAPTURE_CHANNELS as u8,
|
||||
rate: SAMPLE_RATE,
|
||||
}
|
||||
}
|
||||
|
||||
fn pulse_playback_spec() -> Spec {
|
||||
Spec {
|
||||
format: Format::F32le,
|
||||
channels: PLAYBACK_CHANNELS as u8,
|
||||
rate: SAMPLE_RATE,
|
||||
}
|
||||
}
|
||||
|
||||
fn pa_error(err: pulse::error::PAErr) -> String {
|
||||
err.to_string()
|
||||
.unwrap_or_else(|| format!("PulseAudio error {err:?}"))
|
||||
}
|
||||
|
||||
fn wait_for_startup(
|
||||
ready_rx: std_mpsc::Receiver<Result<(), String>>,
|
||||
handle: JoinHandle<Result<(), String>>,
|
||||
label: &str,
|
||||
) -> Result<JoinHandle<Result<(), String>>, AudioError> {
|
||||
match ready_rx.recv() {
|
||||
Ok(Ok(())) => Ok(handle),
|
||||
Ok(Err(err)) => {
|
||||
let _ = handle.join();
|
||||
Err(AudioError::Backend(err))
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = handle.join();
|
||||
Err(AudioError::Backend(format!(
|
||||
"{label} startup failed: {err}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum LinuxInput {
|
||||
#[allow(dead_code)]
|
||||
PipeWire(PipeWireInput),
|
||||
#[allow(dead_code)]
|
||||
PulseAudio(PulseAudioInput),
|
||||
}
|
||||
|
||||
pub(crate) enum LinuxOutput {
|
||||
#[allow(dead_code)]
|
||||
PipeWire(PipeWireOutput),
|
||||
#[allow(dead_code)]
|
||||
PulseAudio(PulseAudioOutput),
|
||||
}
|
||||
|
||||
pub(crate) struct PipeWireInput {
|
||||
device: PipeWireDevice,
|
||||
stop: Arc<AtomicBool>,
|
||||
handle: Option<JoinHandle<Result<(), String>>>,
|
||||
}
|
||||
|
||||
impl PipeWireInput {
|
||||
fn start(
|
||||
device: PipeWireDevice,
|
||||
capture_state: Arc<Mutex<CaptureState>>,
|
||||
) -> Result<Self, AudioError> {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
|
||||
let stop_for_thread = stop.clone();
|
||||
let device_for_thread = device.clone();
|
||||
let handle = thread::spawn(move || {
|
||||
pw::init();
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
|
||||
let context =
|
||||
pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
|
||||
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
|
||||
|
||||
let mut props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||
*pw::keys::MEDIA_ROLE => "Communication",
|
||||
};
|
||||
let target = device_for_thread.node_id.to_string();
|
||||
props.insert(*pw::keys::TARGET_OBJECT, target.as_str());
|
||||
|
||||
let stream = pw::stream::StreamBox::new(&core, "chanora-pipewire-capture", props)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(capture_state)
|
||||
.process(|stream, state| {
|
||||
if let Some(mut buffer) = stream.dequeue_buffer() {
|
||||
let datas = buffer.datas_mut();
|
||||
if datas.is_empty() {
|
||||
return;
|
||||
}
|
||||
let data_ref = &mut datas[0];
|
||||
let byte_count = data_ref.chunk().size() as usize;
|
||||
let Some(bytes) = data_ref.data() else {
|
||||
return;
|
||||
};
|
||||
let byte_count = byte_count.min(bytes.len());
|
||||
if byte_count < 4 {
|
||||
return;
|
||||
}
|
||||
let mut scratch = Vec::with_capacity(byte_count / 4);
|
||||
for chunk in bytes[..byte_count].chunks_exact(4) {
|
||||
scratch
|
||||
.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||
}
|
||||
state.lock().unwrap().ingest(&scratch);
|
||||
}
|
||||
})
|
||||
.register()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let values = build_format_param()?;
|
||||
let mut params =
|
||||
[Pod::from_bytes(&values).ok_or("invalid PipeWire capture format pod")?];
|
||||
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Input,
|
||||
None,
|
||||
pw::stream::StreamFlags::AUTOCONNECT
|
||||
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||
| pw::stream::StreamFlags::RT_PROCESS,
|
||||
&mut params,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let _ = ready_tx.send(Ok(()));
|
||||
while !stop_for_thread.load(Ordering::SeqCst) {
|
||||
let _ = mainloop
|
||||
.loop_()
|
||||
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(100)));
|
||||
}
|
||||
mainloop.quit();
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let handle = wait_for_startup(ready_rx, handle, "PipeWire capture")?;
|
||||
info!(target: "chanora_audio", device = %device.label, node_id = device.node_id, "PipeWire capture backend initialised");
|
||||
Ok(Self {
|
||||
device,
|
||||
stop,
|
||||
handle: Some(handle),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PipeWireOutput {
|
||||
device: PipeWireDevice,
|
||||
stop: Arc<AtomicBool>,
|
||||
handle: Option<JoinHandle<Result<(), String>>>,
|
||||
}
|
||||
|
||||
impl PipeWireOutput {
|
||||
fn start(
|
||||
device: PipeWireDevice,
|
||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
) -> Result<Self, AudioError> {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
|
||||
let stop_for_thread = stop.clone();
|
||||
let device_for_thread = device.clone();
|
||||
let handle = thread::spawn(move || {
|
||||
pw::init();
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
|
||||
let context =
|
||||
pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
|
||||
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
|
||||
let mut props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||
*pw::keys::MEDIA_ROLE => "Communication",
|
||||
};
|
||||
let target = device_for_thread.node_id.to_string();
|
||||
props.insert(*pw::keys::TARGET_OBJECT, target.as_str());
|
||||
let stream = pw::stream::StreamBox::new(&core, "chanora-pipewire-playback", props)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data((handler, output_gain, output_muted))
|
||||
.process(|stream, state| {
|
||||
if let Some(mut buffer) = stream.dequeue_buffer() {
|
||||
let datas = buffer.datas_mut();
|
||||
if datas.is_empty() {
|
||||
return;
|
||||
}
|
||||
let data_ref = &mut datas[0];
|
||||
let byte_len = {
|
||||
let Some(bytes) = data_ref.data() else {
|
||||
return;
|
||||
};
|
||||
let samples = bytes.len() / std::mem::size_of::<f32>();
|
||||
let mut scratch = vec![0.0_f32; samples];
|
||||
if let Ok(mut handler) = state.0.lock() {
|
||||
handler.fill_buffer(&mut scratch);
|
||||
}
|
||||
apply_output_controls(&mut scratch, &state.1, &state.2);
|
||||
for (chunk, sample) in
|
||||
bytes.chunks_exact_mut(4).zip(scratch.into_iter())
|
||||
{
|
||||
chunk.copy_from_slice(&sample.to_le_bytes());
|
||||
}
|
||||
bytes.len()
|
||||
};
|
||||
let chunk = data_ref.chunk_mut();
|
||||
*chunk.offset_mut() = 0;
|
||||
*chunk.stride_mut() =
|
||||
(PLAYBACK_CHANNELS * std::mem::size_of::<f32>() as u32) as i32;
|
||||
*chunk.size_mut() = byte_len as u32;
|
||||
}
|
||||
})
|
||||
.register()
|
||||
.map_err(|err| err.to_string())?;
|
||||
let values = build_playback_format_param()?;
|
||||
let mut params =
|
||||
[Pod::from_bytes(&values).ok_or("invalid PipeWire playback format pod")?];
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Output,
|
||||
None,
|
||||
pw::stream::StreamFlags::AUTOCONNECT
|
||||
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||
| pw::stream::StreamFlags::RT_PROCESS,
|
||||
&mut params,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let _ = ready_tx.send(Ok(()));
|
||||
while !stop_for_thread.load(Ordering::SeqCst) {
|
||||
let _ = mainloop
|
||||
.loop_()
|
||||
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(100)));
|
||||
}
|
||||
mainloop.quit();
|
||||
Ok(())
|
||||
});
|
||||
let handle = wait_for_startup(ready_rx, handle, "PipeWire playback")?;
|
||||
info!(target: "chanora_audio", device = %device.label, node_id = device.node_id, "PipeWire playback backend initialised");
|
||||
Ok(Self {
|
||||
device,
|
||||
stop,
|
||||
handle: Some(handle),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_output_controls(samples: &mut [f32], output_gain: &AtomicU32, output_muted: &AtomicBool) {
|
||||
if output_muted.load(Ordering::Relaxed) {
|
||||
samples.fill(0.0);
|
||||
return;
|
||||
}
|
||||
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
||||
if (gain - 1.0).abs() > f32::EPSILON {
|
||||
for sample in samples.iter_mut() {
|
||||
*sample *= gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PulseAudioInput {
|
||||
device_name: String,
|
||||
stop: Arc<AtomicBool>,
|
||||
handle: Option<JoinHandle<Result<(), String>>>,
|
||||
}
|
||||
|
||||
impl PulseAudioInput {
|
||||
fn start(
|
||||
device_name: String,
|
||||
capture_state: Arc<Mutex<CaptureState>>,
|
||||
) -> Result<Self, AudioError> {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
|
||||
let stop_for_thread = stop.clone();
|
||||
let device_for_thread = device_name.clone();
|
||||
let handle = thread::spawn(move || {
|
||||
let spec = pulse_capture_spec();
|
||||
let stream = psimple::Simple::new(
|
||||
None,
|
||||
"chanora",
|
||||
Direction::Record,
|
||||
Some(device_for_thread.as_str()),
|
||||
"chanora-record",
|
||||
&spec,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(pa_error)?;
|
||||
let _ = ready_tx.send(Ok(()));
|
||||
let mut bytes =
|
||||
vec![
|
||||
0_u8;
|
||||
PLAYBACK_FRAMES * CAPTURE_CHANNELS as usize * std::mem::size_of::<f32>()
|
||||
];
|
||||
while !stop_for_thread.load(Ordering::SeqCst) {
|
||||
stream.read(&mut bytes).map_err(pa_error)?;
|
||||
let mut scratch = Vec::with_capacity(bytes.len() / 4);
|
||||
for chunk in bytes.chunks_exact(4) {
|
||||
scratch.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||
}
|
||||
capture_state.lock().unwrap().ingest(&scratch);
|
||||
}
|
||||
stream.flush().map_err(pa_error)?;
|
||||
Ok(())
|
||||
});
|
||||
let handle = wait_for_startup(ready_rx, handle, "PulseAudio capture")?;
|
||||
info!(target: "chanora_audio", device = %device_name, "PulseAudio capture backend initialised");
|
||||
Ok(Self {
|
||||
device_name,
|
||||
stop,
|
||||
handle: Some(handle),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PulseAudioOutput {
|
||||
device_name: String,
|
||||
stop: Arc<AtomicBool>,
|
||||
handle: Option<JoinHandle<Result<(), String>>>,
|
||||
}
|
||||
|
||||
impl PulseAudioOutput {
|
||||
fn start(
|
||||
device_name: String,
|
||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
) -> Result<Self, AudioError> {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
|
||||
let stop_for_thread = stop.clone();
|
||||
let device_for_thread = device_name.clone();
|
||||
let handle = thread::spawn(move || {
|
||||
let spec = pulse_playback_spec();
|
||||
let stream = psimple::Simple::new(
|
||||
None,
|
||||
"chanora",
|
||||
Direction::Playback,
|
||||
Some(device_for_thread.as_str()),
|
||||
"chanora-playback",
|
||||
&spec,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(pa_error)?;
|
||||
let _ = ready_tx.send(Ok(()));
|
||||
let mut samples = vec![0.0_f32; PLAYBACK_FRAMES * PLAYBACK_CHANNELS as usize];
|
||||
let mut bytes = vec![0_u8; samples.len() * std::mem::size_of::<f32>()];
|
||||
while !stop_for_thread.load(Ordering::SeqCst) {
|
||||
samples.fill(0.0);
|
||||
if let Ok(mut handler) = handler.lock() {
|
||||
handler.fill_buffer(&mut samples);
|
||||
}
|
||||
apply_output_controls(&mut samples, &output_gain, &output_muted);
|
||||
for (chunk, sample) in bytes.chunks_exact_mut(4).zip(samples.iter().copied()) {
|
||||
chunk.copy_from_slice(&sample.to_le_bytes());
|
||||
}
|
||||
stream.write(&bytes).map_err(pa_error)?;
|
||||
}
|
||||
stream.drain().map_err(pa_error)?;
|
||||
Ok(())
|
||||
});
|
||||
let handle = wait_for_startup(ready_rx, handle, "PulseAudio playback")?;
|
||||
info!(target: "chanora_audio", device = %device_name, "PulseAudio playback backend initialised");
|
||||
Ok(Self {
|
||||
device_name,
|
||||
stop,
|
||||
handle: Some(handle),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PipeWireInput {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(handle) = self.handle.take() {
|
||||
if let Err(_panic) = handle.join() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
device = %self.device.label,
|
||||
node_id = self.device.node_id,
|
||||
"PipeWire capture thread panicked during shutdown"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PipeWireOutput {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(handle) = self.handle.take() {
|
||||
if handle.join().is_err() {
|
||||
warn!(target: "chanora_audio", device = %self.device.label, node_id = self.device.node_id, "PipeWire playback thread panicked during shutdown");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PulseAudioInput {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(handle) = self.handle.take() {
|
||||
if handle.join().is_err() {
|
||||
warn!(target: "chanora_audio", device = %self.device_name, "PulseAudio capture thread panicked during shutdown");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PulseAudioOutput {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(handle) = self.handle.take() {
|
||||
if handle.join().is_err() {
|
||||
warn!(target: "chanora_audio", device = %self.device_name, "PulseAudio playback thread panicked during shutdown");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_format_param() -> Result<Vec<u8>, String> {
|
||||
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
|
||||
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
|
||||
audio_info.set_rate(SAMPLE_RATE);
|
||||
audio_info.set_channels(CAPTURE_CHANNELS);
|
||||
|
||||
build_format_param_for_audio_info(audio_info)
|
||||
}
|
||||
|
||||
fn build_playback_format_param() -> Result<Vec<u8>, String> {
|
||||
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
|
||||
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
|
||||
audio_info.set_rate(SAMPLE_RATE);
|
||||
audio_info.set_channels(PLAYBACK_CHANNELS);
|
||||
|
||||
build_format_param_for_audio_info(audio_info)
|
||||
}
|
||||
|
||||
fn build_format_param_for_audio_info(
|
||||
audio_info: spa::param::audio::AudioInfoRaw,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let object = pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
||||
properties: audio_info.into(),
|
||||
};
|
||||
|
||||
pw::spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&pw::spa::pod::Value::Object(object),
|
||||
)
|
||||
.map_err(|err| err.to_string())
|
||||
.map(|serializer| serializer.0.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
input_device_id, input_device_node_id_from_id, output_device_id,
|
||||
output_device_node_id_from_id, pulse_input_device_id, pulse_input_name_from_id,
|
||||
pulse_output_device_id, pulse_output_name_from_id,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn input_device_id_round_trip() {
|
||||
let id = input_device_id(43);
|
||||
assert_eq!(input_device_node_id_from_id(&id), Some(43));
|
||||
assert_eq!(input_device_node_id_from_id("legacy-id"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_device_id_round_trip() {
|
||||
let id = output_device_id(44);
|
||||
assert_eq!(output_device_node_id_from_id(&id), Some(44));
|
||||
assert_eq!(output_device_node_id_from_id("legacy-id"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulse_device_id_round_trip() {
|
||||
let input = pulse_input_device_id("alsa_input.pci-0000_00_1f.3");
|
||||
let output = pulse_output_device_id("alsa_output.pci-0000_00_1f.3");
|
||||
assert_eq!(
|
||||
pulse_input_name_from_id(&input),
|
||||
Some("alsa_input.pci-0000_00_1f.3")
|
||||
);
|
||||
assert_eq!(
|
||||
pulse_output_name_from_id(&output),
|
||||
Some("alsa_output.pci-0000_00_1f.3")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,11 @@
|
||||
//! * `try_select` runs a synchronous portal-version probe on
|
||||
//! the blocking proxy. Failure → `None` → Focused fallback.
|
||||
//! * `start(gate, binding)` spawns a worker tokio task that
|
||||
//! owns an async `zbus::Connection` and calls `CreateSession`.
|
||||
//! If a binding was already persisted, it also calls
|
||||
//! `BindShortcuts` (sentinel id `"chanora-ptt"`). Otherwise
|
||||
//! `BindShortcuts` waits for the user's explicit Configure
|
||||
//! action, which is when the portal opens its system dialog.
|
||||
//! While the dialog is open the engine continues at L0Focused —
|
||||
//! the audio path is not blocked.
|
||||
//! owns an async `zbus::Connection`, calls `CreateSession`,
|
||||
//! then `BindShortcuts` (sentinel id `"chanora-ptt"`). The
|
||||
//! portal opens its own system dialog asking the user to
|
||||
//! pick a key; while the dialog is open the engine continues
|
||||
//! at L0Focused — the audio path is not blocked.
|
||||
//! * Once the user accepts, the task subscribes to
|
||||
//! `Activated`/`Deactivated` signals scoped to the session
|
||||
//! handle and calls `gate.set(true/false)` accordingly.
|
||||
@@ -107,7 +105,7 @@ fn is_gnome_on_wayland() -> bool {
|
||||
trait BlockingGlobalShortcuts {
|
||||
/// Version property (read on the blocking proxy as a fast
|
||||
/// reachability probe).
|
||||
#[zbus(property, name = "version")]
|
||||
#[zbus(property)]
|
||||
fn version(&self) -> zbus::Result<u32>;
|
||||
}
|
||||
|
||||
@@ -221,7 +219,7 @@ struct BackendInner {
|
||||
|
||||
enum WorkerCmd {
|
||||
/// Rebind: re-issue `BindShortcuts` on the same session.
|
||||
Bind,
|
||||
Rebind,
|
||||
/// Stop: close the session and exit.
|
||||
Stop,
|
||||
}
|
||||
@@ -298,10 +296,9 @@ impl DesktopPttBackend for LinuxGnomeWaylandBackend {
|
||||
// the bridge's tokio runtime, so this is satisfied.
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
let desc_tx = self.desc_tx.clone();
|
||||
let bind_on_start = binding.input_class != PttInputClass::None;
|
||||
let class_hint = binding.input_class;
|
||||
let worker = tokio::spawn(async move {
|
||||
run_worker(gate, cmd_rx, desc_tx, class_hint, bind_on_start).await;
|
||||
run_worker(gate, cmd_rx, desc_tx, class_hint).await;
|
||||
});
|
||||
// Stash command + worker handles. `try_lock` is fine: the
|
||||
// backend isn't yet shared, and `start` is called once at
|
||||
@@ -353,7 +350,7 @@ impl DesktopPttBackend for LinuxGnomeWaylandBackend {
|
||||
// only — the portal decides the actual binding.
|
||||
if let Ok(inner) = self.inner.try_lock() {
|
||||
if let Some(tx) = inner.cmd_tx.as_ref() {
|
||||
let _ = tx.send(WorkerCmd::Bind);
|
||||
let _ = tx.send(WorkerCmd::Rebind);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -376,7 +373,6 @@ async fn run_worker(
|
||||
mut cmd_rx: mpsc::UnboundedReceiver<WorkerCmd>,
|
||||
desc_tx: watch::Sender<PttBackendDescriptor>,
|
||||
initial_class_hint: PttInputClass,
|
||||
bind_on_start: bool,
|
||||
) {
|
||||
// Open an async D-Bus session connection. If this fails we
|
||||
// emit a warning and exit; the descriptor stays at L0Focused
|
||||
@@ -421,13 +417,26 @@ async fn run_worker(
|
||||
"linux ptt: portal session created"
|
||||
);
|
||||
|
||||
if bind_on_start {
|
||||
run_bind_shortcut(&proxy, &conn, &session_handle, &desc_tx, initial_class_hint).await;
|
||||
} else {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
"linux ptt: portal session ready; waiting for explicit BindShortcuts request"
|
||||
);
|
||||
// Bind the initial shortcut. The portal opens its own dialog.
|
||||
match bind_shortcut(&proxy, &conn, &session_handle).await {
|
||||
Ok(class) => publish_bound(&desc_tx, class.unwrap_or(initial_class_hint)),
|
||||
Err(BindError::Cancelled) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
bind_status = "cancelled",
|
||||
"linux ptt: user cancelled BindShortcuts; descriptor stays at L0Focused"
|
||||
);
|
||||
publish_l0(&desc_tx);
|
||||
}
|
||||
Err(BindError::Failed(e)) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
bind_status = "failed",
|
||||
error = %e,
|
||||
"linux ptt: BindShortcuts failed; descriptor stays at L0Focused"
|
||||
);
|
||||
publish_l0(&desc_tx);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to Activated / Deactivated signals scoped to the
|
||||
@@ -459,8 +468,12 @@ async fn run_worker(
|
||||
tokio::select! {
|
||||
cmd = cmd_rx.recv() => {
|
||||
match cmd {
|
||||
Some(WorkerCmd::Bind) => {
|
||||
run_bind_shortcut(&proxy, &conn, &session_handle, &desc_tx, initial_class_hint).await;
|
||||
Some(WorkerCmd::Rebind) => {
|
||||
match bind_shortcut(&proxy, &conn, &session_handle).await {
|
||||
Ok(class) => publish_bound(&desc_tx, class.unwrap_or(initial_class_hint)),
|
||||
Err(BindError::Cancelled) => publish_l0(&desc_tx),
|
||||
Err(BindError::Failed(_)) => publish_l0(&desc_tx),
|
||||
}
|
||||
}
|
||||
Some(WorkerCmd::Stop) | None => {
|
||||
// Close the portal session via the
|
||||
@@ -573,35 +586,6 @@ async fn bind_shortcut(
|
||||
Ok(class)
|
||||
}
|
||||
|
||||
async fn run_bind_shortcut(
|
||||
proxy: &GlobalShortcutsProxy<'_>,
|
||||
conn: &AsyncConnection,
|
||||
session_handle: &zbus::zvariant::OwnedObjectPath,
|
||||
desc_tx: &watch::Sender<PttBackendDescriptor>,
|
||||
class_hint: PttInputClass,
|
||||
) {
|
||||
match bind_shortcut(proxy, conn, session_handle).await {
|
||||
Ok(class) => publish_bound(desc_tx, class.unwrap_or(class_hint)),
|
||||
Err(BindError::Cancelled) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
bind_status = "cancelled",
|
||||
"linux ptt: user cancelled BindShortcuts; descriptor stays at L0Focused"
|
||||
);
|
||||
publish_l0(desc_tx);
|
||||
}
|
||||
Err(BindError::Failed(e)) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
bind_status = "failed",
|
||||
error = %e,
|
||||
"linux ptt: BindShortcuts failed; descriptor stays at L0Focused"
|
||||
);
|
||||
publish_l0(desc_tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the `Response` signal on `request_path`. Returns the
|
||||
/// `results` dict on success (response code 0) or an Error
|
||||
/// otherwise.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Linux output stream via SDL2 (Qint / upstream `tsclientlib`
|
||||
//! `ts_to_audio` pattern).
|
||||
//!
|
||||
//! ## Why SDL2 and not cpal on Linux
|
||||
//!
|
||||
//! cpal's Linux backend opens raw ALSA's `default` PCM. On most
|
||||
//! modern distributions (Arch with `pipewire-alsa`, Fedora 38+,
|
||||
//! Debian/Ubuntu with PipeWire) that virtual device still goes
|
||||
//! through ALSA's `dmix` + `plug` layers when bypassing the
|
||||
//! PulseAudio/PipeWire client. The `plug` layer's default
|
||||
//! resampler is **nearest-neighbour**, which causes pronounced
|
||||
//! aliasing on the 48 kHz → 44.1 kHz step that the
|
||||
//! `tsclientlib::AudioHandler` output requires. Users perceive
|
||||
//! this as constant crackling and popping. cpal also picks a
|
||||
//! small default period size (≈256 frames), which leaves no
|
||||
//! headroom for kernel scheduler jitter and causes additional
|
||||
//! xruns.
|
||||
//!
|
||||
//! SDL2 on the same systems opens the audio device through the
|
||||
//! SDL audio driver — which prefers PulseAudio when available
|
||||
//! and falls back to ALSA otherwise. On a PipeWire box the SDL
|
||||
//! PulseAudio driver lands inside PipeWire's PulseAudio
|
||||
//! compatibility layer, whose resampler is high quality. SDL
|
||||
//! also defaults to a buffer ≈ samples-requested, so we get
|
||||
//! exactly one Opus frame (20 ms) per callback.
|
||||
//!
|
||||
//! This file replaces the cpal output path on Linux only. The
|
||||
//! capture path stays on cpal until we have a reason to swap it
|
||||
//! (the user reports outbound is currently fine). Windows continues
|
||||
//! to use cpal's WASAPI backend; Apple platforms use direct
|
||||
//! VoiceProcessingIO AudioUnits for the voice path.
|
||||
//!
|
||||
//! ## Threading & lifecycle
|
||||
//!
|
||||
//! `sdl2::AudioDevice<CB>` is `!Send + !Sync` — the SDL audio
|
||||
//! lock is associated with the calling thread. We open the device
|
||||
//! on the same thread that calls `Self::start_with_gate` (the
|
||||
//! tokio worker that runs `chanora_core::ChanoraSession::start_audio`)
|
||||
//! and never move it. The outer `AudioEngine` already carries an
|
||||
//! `unsafe impl Send` to bypass cpal's identical constraint; we
|
||||
//! reuse that and stash the SDL device behind a `Mutex<Option<…>>`
|
||||
//! the same way cpal does.
|
||||
//!
|
||||
//! Dropping the `SdlOutput` closes the SDL device cleanly and
|
||||
//! drops the playback callback — that releases the
|
||||
//! `Arc<Mutex<AudioHandler>>` clone the callback held.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use sdl2::audio::{AudioCallback, AudioDevice, AudioSpecDesired};
|
||||
use sdl2::AudioSubsystem;
|
||||
use tracing::{info, warn};
|
||||
use tsclientlib::audio::AudioHandler;
|
||||
|
||||
use crate::engine::SessionAudioId;
|
||||
use crate::AudioError;
|
||||
|
||||
/// 20 ms at 48 kHz mono == one Opus frame's worth of samples.
|
||||
/// Stereo doubles the byte count but the frame-count stays the
|
||||
/// same. Aligning the callback size to the Opus frame keeps the
|
||||
/// jitter-buffer / playback handshake tight (no fractional frame
|
||||
/// reads inside fill_buffer).
|
||||
const FRAME_SAMPLES: u16 = 960;
|
||||
|
||||
/// SDL output device wrapper. Holds the live `AudioDevice` so its
|
||||
/// callback keeps firing for the engine's lifetime, plus a
|
||||
/// reference to the same `AudioHandler` the inbound forwarder
|
||||
/// pushes into.
|
||||
pub struct SdlOutput {
|
||||
// Drop order: device first (stops the callback), then any
|
||||
// remaining references release naturally. We keep `_subsystem`
|
||||
// alive because dropping the AudioSubsystem before the device
|
||||
// would invalidate SDL's internal state.
|
||||
device: AudioDevice<TsPlaybackCallback>,
|
||||
_subsystem: AudioSubsystem,
|
||||
}
|
||||
|
||||
impl SdlOutput {
|
||||
/// Open the default SDL playback device at the AudioHandler's
|
||||
/// native format (48 kHz stereo f32) and start the device
|
||||
/// playing immediately. The callback drains the AudioHandler
|
||||
/// directly — no user-side resampling.
|
||||
///
|
||||
/// `output_gain` is read on every callback to apply the
|
||||
/// master-volume slider; `output_muted` zeroes the output (but
|
||||
/// still drains AudioHandler so its jitter buffer doesn't grow
|
||||
/// unbounded while muted). These two atomics share the same
|
||||
/// definitions the cpal path uses, so the same FFI surface
|
||||
/// (`set_output_gain` / `set_output_muted`) drives both.
|
||||
pub fn start(
|
||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
) -> Result<Self, AudioError> {
|
||||
let sdl = sdl2::init().map_err(|e| AudioError::Backend(format!("sdl init: {e}")))?;
|
||||
let subsystem = sdl
|
||||
.audio()
|
||||
.map_err(|e| AudioError::Backend(format!("sdl audio subsystem: {e}")))?;
|
||||
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
driver = subsystem.current_audio_driver(),
|
||||
"sdl audio subsystem initialised"
|
||||
);
|
||||
|
||||
let desired = AudioSpecDesired {
|
||||
freq: Some(48_000),
|
||||
channels: Some(2),
|
||||
samples: Some(FRAME_SAMPLES),
|
||||
};
|
||||
|
||||
let device = AudioDevice::open_playback(&subsystem, None, &desired, |spec| {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
freq = spec.freq,
|
||||
channels = spec.channels,
|
||||
samples = spec.samples,
|
||||
"sdl playback spec accepted"
|
||||
);
|
||||
TsPlaybackCallback {
|
||||
handler,
|
||||
output_gain,
|
||||
output_muted,
|
||||
}
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("sdl open_playback: {e}")))?;
|
||||
|
||||
// Begin pumping audio frames out. SDL's device starts paused;
|
||||
// resume() flips it into the playing state. The callback
|
||||
// will fire repeatedly at ~50 Hz (every 20 ms) thereafter.
|
||||
device.resume();
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
_subsystem: subsystem,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SdlOutput {
|
||||
fn drop(&mut self) {
|
||||
// AudioDevice::drop closes the device which stops the
|
||||
// callback. We log so the chanora.log timeline matches
|
||||
// engine shutdown.
|
||||
warn!(target: "chanora_audio", "sdl playback device closing");
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback callback invoked by SDL's audio thread. The shape
|
||||
/// mirrors the upstream `tsclientlib` example's `SdlCallback`:
|
||||
/// zero the output buffer (so silent regions emit silence rather
|
||||
/// than stale memory), then ask the `AudioHandler` to fill in
|
||||
/// whatever decoded frames it has buffered.
|
||||
struct TsPlaybackCallback {
|
||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AudioCallback for TsPlaybackCallback {
|
||||
type Channel = f32;
|
||||
|
||||
fn callback(&mut self, buffer: &mut [f32]) {
|
||||
// The buffer is interleaved stereo at 48 kHz from SDL.
|
||||
// Length is FRAME_SAMPLES * 2 (= 1920 f32) per the spec
|
||||
// we requested.
|
||||
for sample in buffer.iter_mut() {
|
||||
*sample = 0.0;
|
||||
}
|
||||
// Lock window kept as tight as the upstream example —
|
||||
// fill_buffer does the actual Opus decode + jitter logic.
|
||||
// Contention with the inbound forwarder is the same as
|
||||
// in the cpal path, but the upstream design has shipped
|
||||
// this way for years.
|
||||
{
|
||||
let mut data = self.handler.lock().unwrap();
|
||||
let _removed_ids = data.fill_buffer(buffer);
|
||||
// `_removed_ids` is the list of clients whose stream the
|
||||
// handler just finished draining. We could publish that
|
||||
// upward as a "stopped speaking" hint, but the existing
|
||||
// BridgeEvent::VoiceState already covers that case via
|
||||
// the bridge layer; ignoring matches upstream behaviour.
|
||||
}
|
||||
|
||||
// Apply local mute + master gain after fill so the jitter
|
||||
// buffer still drains while muted (matching the cpal path's
|
||||
// contract). `output_gain` is encoded as f32 bits inside an
|
||||
// AtomicU32 — same encoding the cpal path uses.
|
||||
if self.output_muted.load(Ordering::Relaxed) {
|
||||
for sample in buffer.iter_mut() {
|
||||
*sample = 0.0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
||||
if (gain - 1.0).abs() > f32::EPSILON {
|
||||
for sample in buffer.iter_mut() {
|
||||
*sample *= gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,16 +33,12 @@
|
||||
//! ## Fallback
|
||||
//!
|
||||
//! `try_new` returns `None` when the model file is missing, the ONNX
|
||||
//! Runtime is unavailable, or the platform cannot load ONNX Runtime. The caller
|
||||
//! Runtime is unavailable, or the platform is not iOS/macOS. The caller
|
||||
//! falls back to `WebRtcFallbackVad`.
|
||||
|
||||
use super::{VadOutput, VoiceActivityDetector};
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::sync::OnceLock;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
/// 16 kHz frame size for Silero VAD v6 (32 ms).
|
||||
@@ -92,7 +88,7 @@ impl SileroOnnxVad {
|
||||
/// Attempt to load the Silero v6 ONNX model from `model_path`.
|
||||
///
|
||||
/// Returns `None` when the model file is missing, the ONNX Runtime
|
||||
/// is unavailable, or the platform cannot initialize ONNX Runtime.
|
||||
/// is unavailable, or the platform does not support ONNX.
|
||||
pub fn try_new(model_path: &str) -> Option<Self> {
|
||||
Self::try_new_onnx(model_path)
|
||||
}
|
||||
@@ -109,17 +105,6 @@ impl SileroOnnxVad {
|
||||
return None;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Err(err) = ensure_linux_onnxruntime_loaded() {
|
||||
error!(
|
||||
target: "chanora_audio",
|
||||
path = model_path,
|
||||
error = %err,
|
||||
"SileroOnnxVad: ONNX Runtime is unavailable on Linux; falling back to WebRtcFallbackVad"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let session_result = std::panic::catch_unwind(|| {
|
||||
ort::session::Session::builder().and_then(|mut b| b.commit_from_file(model_path))
|
||||
});
|
||||
@@ -316,111 +301,6 @@ impl VoiceActivityDetector for SileroOnnxVad {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ensure_linux_onnxruntime_loaded() -> Result<(), String> {
|
||||
static ORT_INIT: OnceLock<Result<(), String>> = OnceLock::new();
|
||||
|
||||
ORT_INIT
|
||||
.get_or_init(|| {
|
||||
let dylib_path = linux_onnxruntime_dylib_path();
|
||||
match dylib_path.as_ref() {
|
||||
Some(path) => {
|
||||
tracing::info!(
|
||||
target: "chanora_audio",
|
||||
path = %path.display(),
|
||||
"SileroOnnxVad: loading Linux ONNX Runtime from discovered shared library"
|
||||
);
|
||||
ort::init_from(path)
|
||||
.map_err(|e| format!("init_from({}): {e}", path.display()))?
|
||||
.with_name("chanora_audio")
|
||||
.commit()
|
||||
.then_some(())
|
||||
.ok_or_else(|| {
|
||||
format!("commit(init_from {}): environment already initialized", path.display())
|
||||
})
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "chanora_audio",
|
||||
"SileroOnnxVad: no explicit Linux ONNX Runtime shared library path found; trying loader default"
|
||||
);
|
||||
ort::init()
|
||||
.with_name("chanora_audio")
|
||||
.commit()
|
||||
.then_some(())
|
||||
.ok_or_else(|| "commit(init): environment already initialized".to_string())
|
||||
}
|
||||
}
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_onnxruntime_dylib_path() -> Option<PathBuf> {
|
||||
if let Some(path) = existing_env_file("ORT_DYLIB_PATH") {
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe.parent() {
|
||||
candidates.push(exe_dir.join("lib").join("libonnxruntime.so"));
|
||||
candidates.push(exe_dir.join("libonnxruntime.so"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
candidates.push(cwd.join("libonnxruntime.so"));
|
||||
}
|
||||
|
||||
candidates.extend([
|
||||
PathBuf::from("/usr/lib/libonnxruntime.so"),
|
||||
PathBuf::from("/usr/lib64/libonnxruntime.so"),
|
||||
PathBuf::from("/usr/local/lib/libonnxruntime.so"),
|
||||
PathBuf::from("/lib/x86_64-linux-gnu/libonnxruntime.so"),
|
||||
PathBuf::from("/usr/lib/x86_64-linux-gnu/libonnxruntime.so"),
|
||||
PathBuf::from("/lib/aarch64-linux-gnu/libonnxruntime.so"),
|
||||
PathBuf::from("/usr/lib/aarch64-linux-gnu/libonnxruntime.so"),
|
||||
]);
|
||||
|
||||
for candidate in candidates {
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
first_matching_dir_entry("/usr/lib", "libonnxruntime.so")
|
||||
.or_else(|| first_matching_dir_entry("/usr/lib64", "libonnxruntime.so"))
|
||||
.or_else(|| first_matching_dir_entry("/usr/local/lib", "libonnxruntime.so"))
|
||||
.or_else(|| first_matching_dir_entry("/usr/lib/x86_64-linux-gnu", "libonnxruntime.so"))
|
||||
.or_else(|| first_matching_dir_entry("/usr/lib/aarch64-linux-gnu", "libonnxruntime.so"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn existing_env_file(name: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os(name).map(PathBuf::from)?;
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn first_matching_dir_entry(dir: &str, prefix: &str) -> Option<PathBuf> {
|
||||
let mut matches: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||
.ok()?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.is_file()
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with(prefix))
|
||||
})
|
||||
.collect();
|
||||
matches.sort();
|
||||
matches.into_iter().next()
|
||||
}
|
||||
|
||||
// SAFETY: ONNX Runtime sessions are thread-safe for inference.
|
||||
// State arrays are owned by this struct and accessed only from
|
||||
// the single capture callback thread.
|
||||
@@ -521,8 +401,6 @@ impl Drop for SileroOnnxVadWorker {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn make_stub_vad() -> SileroOnnxVad {
|
||||
SileroOnnxVad {
|
||||
@@ -618,17 +496,4 @@ mod tests {
|
||||
&completed_frame[SILERO_FRAME_16K - SILERO_CONTEXT_16K..]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn existing_env_file_ignores_missing_paths() {
|
||||
let var_name = format!("CHANORA_TEST_ORT_{}", std::process::id());
|
||||
std::env::remove_var(&var_name);
|
||||
assert!(existing_env_file(&var_name).is_none());
|
||||
|
||||
let missing = PathBuf::from("/definitely/missing/libonnxruntime.so");
|
||||
std::env::set_var(&var_name, &missing);
|
||||
assert!(existing_env_file(&var_name).is_none());
|
||||
std::env::remove_var(&var_name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +2,8 @@
|
||||
name = "chanora_resolver"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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
|
||||
```
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = []
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,4 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
chanora_protocol = { path = "../chanora_protocol" }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user