Add native self-hosted instance connection to fluxer_desktop

Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 +
tools/ci) with a "Connect to a Different Server" menu item and popout
that lets the desktop app switch to any self-hosted Fluxer instance,
plus fixes for well-known discovery on single-domain self-hosted
deployments and a false-positive ERR_ABORTED on same-origin client
redirects during the switch. Defaults to chat.fluxr.chat and uses an
isolated userData directory from the official build.
This commit is contained in:
2026-07-01 18:22:43 -04:00
commit 682afacd30
1763 changed files with 613720 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "autotools"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf"
dependencies = [
"cc",
]
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex 1.3.0",
"syn",
]
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "cc"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"shlex 2.0.1",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fluxer_audio_apm"
version = "0.0.0"
dependencies = [
"webrtc-audio-processing",
"webrtc-audio-processing-config",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "log"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "webrtc-audio-processing"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "190c57ced88d6307ba91778c245265a99f99d900a9239c87a1b15142330c9f7e"
dependencies = [
"webrtc-audio-processing-config",
"webrtc-audio-processing-sys",
]
[[package]]
name = "webrtc-audio-processing-config"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142cbc598e3dcf1a71e90109b6169ca9ce28ec1910bf74eef30f3c420ce850a4"
[[package]]
name = "webrtc-audio-processing-sys"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8f6c2b4b20a03b165378172f0dbe23c49b2630b70091026589afbe04e79cd3e"
dependencies = [
"anyhow",
"autotools",
"bindgen",
"cc",
"fs_extra",
"pkg-config",
"regex",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
@@ -0,0 +1,26 @@
[package]
name = "fluxer_audio_apm"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["rlib"]
[features]
default = []
real-apm = [
"dep:webrtc-audio-processing",
"dep:webrtc-audio-processing-config",
"webrtc-audio-processing/bundled",
]
[dependencies]
webrtc-audio-processing = {version = "2.1", optional = true}
webrtc-audio-processing-config = {version = "2.1", optional = true}
[dev-dependencies]
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CRATE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
require_pkg_config() {
if ! command -v pkg-config >/dev/null 2>&1; then
echo "error: pkg-config is required but was not found in PATH." >&2
echo " Install via: brew install pkg-config (macOS)" >&2
echo " apt-get install pkg-config (Debian/Ubuntu)" >&2
exit 1
fi
}
assert_webrtc_pkgconfig() {
if ! pkg-config --exists webrtc-audio-processing-2; then
echo "error: pkg-config cannot locate webrtc-audio-processing-2." >&2
echo " See fluxer_desktop/native/audio-apm/README.md for install steps." >&2
exit 1
fi
}
assert_absl_pkgconfig() {
if ! pkg-config --exists absl_base; then
echo "error: pkg-config cannot locate absl_base." >&2
echo " Install via: brew install abseil (macOS)" >&2
echo " apt-get install libabsl-dev (Debian/Ubuntu)" >&2
exit 1
fi
}
inject_absl_include_path() {
local absl_includes
absl_includes="$(pkg-config --cflags-only-I absl_base)"
if [[ -z "${absl_includes}" ]]; then
echo "error: pkg-config returned no include path for absl_base." >&2
exit 1
fi
local prev_cxxflags="${CXXFLAGS-}"
local prev_bindgen="${BINDGEN_EXTRA_CLANG_ARGS-}"
export CXXFLAGS="${absl_includes} ${prev_cxxflags}"
export BINDGEN_EXTRA_CLANG_ARGS="${absl_includes} ${prev_bindgen}"
}
main() {
cd "${CRATE_DIR}"
local cargo_subcmd="build"
if [[ $# -gt 0 && "$1" != -* && "$1" != "--" ]]; then
cargo_subcmd="$1"
shift
fi
require_pkg_config
assert_webrtc_pkgconfig
assert_absl_pkgconfig
inject_absl_include_path
exec cargo "${cargo_subcmd}" --features real-apm "$@"
}
main "$@"
+972
View File
@@ -0,0 +1,972 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![deny(clippy::too_many_lines)]
#![deny(clippy::unwrap_used)]
pub const APM_FRAME_MS: u32 = 10;
pub const APM_MAX_SAMPLE_RATE: u32 = 48_000;
pub const APM_MAX_CHANNELS: u16 = 2;
pub const APM_MAX_FRAME_SAMPLES: usize =
(APM_FRAME_MS as usize) * (APM_MAX_SAMPLE_RATE as usize) / 1000;
pub const APM_MIN_SAMPLE_RATE: u32 = 8_000;
pub const APM_MIN_CHANNELS: u16 = 1;
const _: () = assert!(APM_MAX_FRAME_SAMPLES == 480);
const _: () = assert!(APM_MIN_SAMPLE_RATE <= APM_MAX_SAMPLE_RATE);
const _: () = assert!(APM_MIN_CHANNELS <= APM_MAX_CHANNELS);
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum ApmError {
SampleRateOutOfRange {
sample_rate_hz: u32,
},
SampleRateMismatch {
expected_hz: u32,
observed_hz: u32,
},
ChannelsOutOfRange {
channels: u16,
},
ChannelsMismatch {
expected: u16,
observed: u16,
},
FrameLengthMismatch {
expected_samples: usize,
observed_samples: usize,
},
NotInitialized,
BackendUnavailable,
}
impl core::fmt::Display for ApmError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
ApmError::SampleRateOutOfRange { sample_rate_hz } => write!(
f,
"sample rate {sample_rate_hz} hz outside [{APM_MIN_SAMPLE_RATE}, {APM_MAX_SAMPLE_RATE}]",
),
ApmError::SampleRateMismatch {
expected_hz,
observed_hz,
} => write!(
f,
"sample rate mismatch: expected={expected_hz} observed={observed_hz}",
),
ApmError::ChannelsOutOfRange { channels } => write!(
f,
"channel count {channels} outside [{APM_MIN_CHANNELS}, {APM_MAX_CHANNELS}]",
),
ApmError::ChannelsMismatch { expected, observed } => write!(
f,
"channels mismatch: expected={expected} observed={observed}",
),
ApmError::FrameLengthMismatch {
expected_samples,
observed_samples,
} => write!(
f,
"frame length mismatch: expected={expected_samples} observed={observed_samples}",
),
ApmError::NotInitialized => write!(f, "audio processor not initialized"),
ApmError::BackendUnavailable => write!(f, "real APM backend unavailable"),
}
}
}
impl std::error::Error for ApmError {}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AecMetrics {
pub echo_return_loss_db: f32,
pub echo_return_loss_enhancement_db: f32,
pub delay_ms: i32,
}
impl AecMetrics {
pub const NEUTRAL: AecMetrics = AecMetrics {
echo_return_loss_db: 0.0,
echo_return_loss_enhancement_db: 0.0,
delay_ms: 0,
};
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ApmReport {
pub aec_metrics: AecMetrics,
pub voice_detected: bool,
pub level_dbfs: f32,
}
impl ApmReport {
pub const NEUTRAL: ApmReport = ApmReport {
aec_metrics: AecMetrics::NEUTRAL,
voice_detected: false,
level_dbfs: -120.0,
};
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ApmConfig {
pub aec_enabled: bool,
pub ns_enabled: bool,
pub agc_enabled: bool,
pub aec_mobile_mode: bool,
pub target_level_dbfs: i32,
}
impl Default for ApmConfig {
fn default() -> Self {
ApmConfig {
aec_enabled: true,
ns_enabled: true,
agc_enabled: true,
aec_mobile_mode: false,
target_level_dbfs: -3,
}
}
}
pub struct ApmConfigBuilder {
config: ApmConfig,
}
impl ApmConfigBuilder {
pub fn new() -> Self {
ApmConfigBuilder {
config: ApmConfig::default(),
}
}
pub fn aec(mut self, enabled: bool) -> Self {
self.config.aec_enabled = enabled;
self
}
pub fn ns(mut self, enabled: bool) -> Self {
self.config.ns_enabled = enabled;
self
}
pub fn agc(mut self, enabled: bool) -> Self {
self.config.agc_enabled = enabled;
self
}
pub fn aec_mobile_mode(mut self, enabled: bool) -> Self {
self.config.aec_mobile_mode = enabled;
self
}
pub fn target_level_dbfs(mut self, target: i32) -> Self {
self.config.target_level_dbfs = target;
self
}
pub fn build(self) -> ApmConfig {
assert!(self.config.target_level_dbfs <= 0);
assert!(self.config.target_level_dbfs >= -60);
self.config
}
}
impl Default for ApmConfigBuilder {
fn default() -> Self {
ApmConfigBuilder::new()
}
}
pub trait AudioProcessor: Send {
fn process_capture_frame(
&mut self,
samples: &mut [i16],
sample_rate_hz: u32,
channels: u16,
) -> Result<ApmReport, ApmError>;
fn process_render_frame(
&mut self,
samples: &[i16],
sample_rate_hz: u32,
channels: u16,
) -> Result<(), ApmError>;
fn reset(&mut self) -> Result<(), ApmError>;
}
pub fn expected_frame_samples(sample_rate_hz: u32, channels: u16) -> usize {
assert!(sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(sample_rate_hz <= APM_MAX_SAMPLE_RATE);
assert!(channels >= APM_MIN_CHANNELS);
assert!(channels <= APM_MAX_CHANNELS);
let per_channel = (APM_FRAME_MS as usize) * (sample_rate_hz as usize) / 1000;
per_channel * (channels as usize)
}
pub(crate) fn validate_frame_shape(
samples_len: usize,
sample_rate_hz: u32,
channels: u16,
expected_sample_rate_hz: u32,
expected_channels: u16,
) -> Result<(), ApmError> {
assert!(expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(expected_sample_rate_hz <= APM_MAX_SAMPLE_RATE);
if !(APM_MIN_SAMPLE_RATE..=APM_MAX_SAMPLE_RATE).contains(&sample_rate_hz) {
return Err(ApmError::SampleRateOutOfRange { sample_rate_hz });
}
if !(APM_MIN_CHANNELS..=APM_MAX_CHANNELS).contains(&channels) {
return Err(ApmError::ChannelsOutOfRange { channels });
}
if sample_rate_hz != expected_sample_rate_hz {
return Err(ApmError::SampleRateMismatch {
expected_hz: expected_sample_rate_hz,
observed_hz: sample_rate_hz,
});
}
if channels != expected_channels {
return Err(ApmError::ChannelsMismatch {
expected: expected_channels,
observed: channels,
});
}
let expected_samples = expected_frame_samples(sample_rate_hz, channels);
if samples_len != expected_samples {
return Err(ApmError::FrameLengthMismatch {
expected_samples,
observed_samples: samples_len,
});
}
Ok(())
}
#[derive(Debug)]
pub struct StubAudioProcessor {
config: ApmConfig,
expected_sample_rate_hz: u32,
expected_channels: u16,
capture_frames_processed: u64,
render_frames_processed: u64,
}
impl StubAudioProcessor {
pub fn new(config: ApmConfig, sample_rate_hz: u32, channels: u16) -> Result<Self, ApmError> {
if !(APM_MIN_SAMPLE_RATE..=APM_MAX_SAMPLE_RATE).contains(&sample_rate_hz) {
return Err(ApmError::SampleRateOutOfRange { sample_rate_hz });
}
if !(APM_MIN_CHANNELS..=APM_MAX_CHANNELS).contains(&channels) {
return Err(ApmError::ChannelsOutOfRange { channels });
}
assert!(sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(channels >= APM_MIN_CHANNELS);
Ok(StubAudioProcessor {
config,
expected_sample_rate_hz: sample_rate_hz,
expected_channels: channels,
capture_frames_processed: 0,
render_frames_processed: 0,
})
}
pub fn config(&self) -> ApmConfig {
self.config
}
pub fn capture_frames_processed(&self) -> u64 {
self.capture_frames_processed
}
pub fn render_frames_processed(&self) -> u64 {
self.render_frames_processed
}
}
impl AudioProcessor for StubAudioProcessor {
fn process_capture_frame(
&mut self,
samples: &mut [i16],
sample_rate_hz: u32,
channels: u16,
) -> Result<ApmReport, ApmError> {
assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(self.expected_channels >= APM_MIN_CHANNELS);
validate_frame_shape(
samples.len(),
sample_rate_hz,
channels,
self.expected_sample_rate_hz,
self.expected_channels,
)?;
assert!(self.capture_frames_processed < u64::MAX);
self.capture_frames_processed = self.capture_frames_processed.saturating_add(1);
Ok(ApmReport::NEUTRAL)
}
fn process_render_frame(
&mut self,
samples: &[i16],
sample_rate_hz: u32,
channels: u16,
) -> Result<(), ApmError> {
assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(self.expected_channels >= APM_MIN_CHANNELS);
validate_frame_shape(
samples.len(),
sample_rate_hz,
channels,
self.expected_sample_rate_hz,
self.expected_channels,
)?;
assert!(self.render_frames_processed < u64::MAX);
self.render_frames_processed = self.render_frames_processed.saturating_add(1);
Ok(())
}
fn reset(&mut self) -> Result<(), ApmError> {
assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(self.expected_channels >= APM_MIN_CHANNELS);
self.capture_frames_processed = 0;
self.render_frames_processed = 0;
Ok(())
}
}
#[cfg(feature = "real-apm")]
mod real {
use super::{
APM_MAX_CHANNELS, APM_MAX_FRAME_SAMPLES, APM_MAX_SAMPLE_RATE, APM_MIN_CHANNELS,
APM_MIN_SAMPLE_RATE, AecMetrics, ApmConfig, ApmError, ApmReport, AudioProcessor,
validate_frame_shape,
};
use webrtc_audio_processing::{Processor, Stats};
use webrtc_audio_processing_config::{
Config, EchoCanceller, GainController, GainController1, GainControllerMode, HighPassFilter,
NoiseSuppression, NoiseSuppressionLevel,
};
const I16_TO_F32_SCALE: f32 = 1.0 / 32768.0;
const F32_TO_I16_SCALE: f32 = 32767.0;
#[derive(Debug)]
pub struct WebRtcAudioProcessor {
inner: Processor,
config: ApmConfig,
expected_sample_rate_hz: u32,
expected_channels: u16,
samples_per_channel: usize,
capture_channels: [[f32; APM_MAX_FRAME_SAMPLES]; APM_MAX_CHANNELS as usize],
render_channels: [[f32; APM_MAX_FRAME_SAMPLES]; APM_MAX_CHANNELS as usize],
capture_frames_processed: u64,
render_frames_processed: u64,
}
impl WebRtcAudioProcessor {
pub fn new(
config: ApmConfig,
sample_rate_hz: u32,
channels: u16,
) -> Result<Self, ApmError> {
if !(APM_MIN_SAMPLE_RATE..=APM_MAX_SAMPLE_RATE).contains(&sample_rate_hz) {
return Err(ApmError::SampleRateOutOfRange { sample_rate_hz });
}
if !(APM_MIN_CHANNELS..=APM_MAX_CHANNELS).contains(&channels) {
return Err(ApmError::ChannelsOutOfRange { channels });
}
assert!(sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(channels >= APM_MIN_CHANNELS);
let inner = Processor::new(sample_rate_hz).map_err(|_| ApmError::BackendUnavailable)?;
inner.set_config(build_webrtc_config(config));
let samples_per_channel = inner.num_samples_per_frame();
assert!(samples_per_channel <= APM_MAX_FRAME_SAMPLES);
assert!(samples_per_channel > 0);
Ok(WebRtcAudioProcessor {
inner,
config,
expected_sample_rate_hz: sample_rate_hz,
expected_channels: channels,
samples_per_channel,
capture_channels: [[0.0; APM_MAX_FRAME_SAMPLES]; APM_MAX_CHANNELS as usize],
render_channels: [[0.0; APM_MAX_FRAME_SAMPLES]; APM_MAX_CHANNELS as usize],
capture_frames_processed: 0,
render_frames_processed: 0,
})
}
pub fn config(&self) -> ApmConfig {
self.config
}
pub fn capture_frames_processed(&self) -> u64 {
self.capture_frames_processed
}
pub fn render_frames_processed(&self) -> u64 {
self.render_frames_processed
}
pub fn samples_per_channel(&self) -> usize {
self.samples_per_channel
}
}
fn build_webrtc_config(config: ApmConfig) -> Config {
let echo_canceller = if !config.aec_enabled {
None
} else if config.aec_mobile_mode {
Some(EchoCanceller::Mobile { stream_delay_ms: 0 })
} else {
Some(EchoCanceller::Full {
stream_delay_ms: None,
})
};
let noise_suppression = if config.ns_enabled {
Some(NoiseSuppression {
level: NoiseSuppressionLevel::Moderate,
analyze_linear_aec_output: false,
})
} else {
None
};
let gain_controller = if config.agc_enabled {
Some(gain_controller_from(config.target_level_dbfs))
} else {
None
};
Config {
pipeline: Default::default(),
capture_amplifier: None,
high_pass_filter: Some(HighPassFilter {
apply_in_full_band: true,
}),
echo_canceller,
noise_suppression,
gain_controller,
}
}
fn gain_controller_from(target_level_dbfs: i32) -> GainController {
assert!(target_level_dbfs <= 0);
assert!(target_level_dbfs >= -60);
let target_clamped = (-target_level_dbfs).clamp(0, 31) as u8;
GainController::GainController1(GainController1 {
mode: GainControllerMode::AdaptiveDigital,
target_level_dbfs: target_clamped,
compression_gain_db: 9,
enable_limiter: true,
analog_gain_controller: None,
})
}
fn deinterleave_i16(
samples: &[i16],
channels: usize,
samples_per_channel: usize,
out: &mut [[f32; APM_MAX_FRAME_SAMPLES]; APM_MAX_CHANNELS as usize],
) {
assert!(channels >= 1);
assert!(channels <= APM_MAX_CHANNELS as usize);
assert!(samples_per_channel <= APM_MAX_FRAME_SAMPLES);
assert!(samples.len() == channels * samples_per_channel);
for (sample_index, frame) in samples.chunks_exact(channels).enumerate() {
for (channel_index, &value) in frame.iter().enumerate() {
out[channel_index][sample_index] = (value as f32) * I16_TO_F32_SCALE;
}
}
}
fn interleave_to_i16(
channels_data: &[[f32; APM_MAX_FRAME_SAMPLES]; APM_MAX_CHANNELS as usize],
channels: usize,
samples_per_channel: usize,
out: &mut [i16],
) {
assert!(channels >= 1);
assert!(channels <= APM_MAX_CHANNELS as usize);
assert!(samples_per_channel <= APM_MAX_FRAME_SAMPLES);
assert!(out.len() == channels * samples_per_channel);
for (sample_index, frame) in out.chunks_exact_mut(channels).enumerate() {
for (channel_index, slot) in frame.iter_mut().enumerate() {
let value = channels_data[channel_index][sample_index];
let scaled = (value * F32_TO_I16_SCALE).clamp(i16::MIN as f32, i16::MAX as f32);
*slot = scaled as i16;
}
}
}
fn peak_level_dbfs(samples: &[i16]) -> f32 {
assert!(!samples.is_empty());
let mut peak_abs: i32 = 0;
for &sample in samples {
let abs_value = (sample as i32).unsigned_abs() as i32;
if abs_value > peak_abs {
peak_abs = abs_value;
}
}
if peak_abs == 0 {
return -120.0;
}
let normalised = (peak_abs as f32) * I16_TO_F32_SCALE;
20.0 * normalised.log10()
}
fn report_from(stats: Stats, level_dbfs: f32) -> ApmReport {
let erl = stats.echo_return_loss.unwrap_or(0.0) as f32;
let erle = stats.echo_return_loss_enhancement.unwrap_or(0.0) as f32;
let delay_ms = stats.delay_ms.unwrap_or(0) as i32;
ApmReport {
aec_metrics: AecMetrics {
echo_return_loss_db: erl,
echo_return_loss_enhancement_db: erle,
delay_ms,
},
voice_detected: level_dbfs > -50.0,
level_dbfs,
}
}
impl AudioProcessor for WebRtcAudioProcessor {
fn process_capture_frame(
&mut self,
samples: &mut [i16],
sample_rate_hz: u32,
channels: u16,
) -> Result<ApmReport, ApmError> {
assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(self.expected_channels >= APM_MIN_CHANNELS);
validate_frame_shape(
samples.len(),
sample_rate_hz,
channels,
self.expected_sample_rate_hz,
self.expected_channels,
)?;
let channels_usize = channels as usize;
let per_channel = self.samples_per_channel;
deinterleave_i16(
samples,
channels_usize,
per_channel,
&mut self.capture_channels,
);
let mut view: [&mut [f32]; APM_MAX_CHANNELS as usize] = {
let (first, second) = self.capture_channels.split_at_mut(1);
[&mut first[0][..per_channel], &mut second[0][..per_channel]]
};
let frame = &mut view[..channels_usize];
self.inner
.process_capture_frame(frame)
.map_err(|_| ApmError::BackendUnavailable)?;
interleave_to_i16(&self.capture_channels, channels_usize, per_channel, samples);
let level_dbfs = peak_level_dbfs(samples);
let stats = self.inner.get_stats();
self.capture_frames_processed = self.capture_frames_processed.saturating_add(1);
Ok(report_from(stats, level_dbfs))
}
fn process_render_frame(
&mut self,
samples: &[i16],
sample_rate_hz: u32,
channels: u16,
) -> Result<(), ApmError> {
assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(self.expected_channels >= APM_MIN_CHANNELS);
validate_frame_shape(
samples.len(),
sample_rate_hz,
channels,
self.expected_sample_rate_hz,
self.expected_channels,
)?;
let channels_usize = channels as usize;
let per_channel = self.samples_per_channel;
deinterleave_i16(
samples,
channels_usize,
per_channel,
&mut self.render_channels,
);
let mut view: [&mut [f32]; APM_MAX_CHANNELS as usize] = {
let (first, second) = self.render_channels.split_at_mut(1);
[&mut first[0][..per_channel], &mut second[0][..per_channel]]
};
let frame = &mut view[..channels_usize];
self.inner
.process_render_frame(frame)
.map_err(|_| ApmError::BackendUnavailable)?;
self.render_frames_processed = self.render_frames_processed.saturating_add(1);
Ok(())
}
fn reset(&mut self) -> Result<(), ApmError> {
assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE);
assert!(self.expected_channels >= APM_MIN_CHANNELS);
self.inner.reinitialize();
self.capture_frames_processed = 0;
self.render_frames_processed = 0;
Ok(())
}
}
}
#[cfg(feature = "real-apm")]
pub use real::WebRtcAudioProcessor;
#[cfg(test)]
mod tests {
use super::*;
fn make_frame(sample_rate_hz: u32, channels: u16) -> Vec<i16> {
let n = expected_frame_samples(sample_rate_hz, channels);
(0..n).map(|i| (i as i16).wrapping_mul(7)).collect()
}
#[test]
fn frame_samples_constant_matches_formula() {
assert_eq!(APM_MAX_FRAME_SAMPLES, 480);
assert_eq!(expected_frame_samples(48_000, 1), 480);
assert_eq!(expected_frame_samples(48_000, 2), 960);
assert_eq!(expected_frame_samples(16_000, 1), 160);
}
#[test]
fn stub_capture_does_not_modify_samples() {
let config = ApmConfigBuilder::new().build();
let mut stub = StubAudioProcessor::new(config, 48_000, 1).expect("ctor");
let original = make_frame(48_000, 1);
let mut samples = original.clone();
let report = stub
.process_capture_frame(&mut samples, 48_000, 1)
.expect("ok");
assert_eq!(samples, original);
assert_eq!(report, ApmReport::NEUTRAL);
}
#[test]
fn stub_report_has_neutral_metrics() {
let config = ApmConfig::default();
let mut stub = StubAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = make_frame(48_000, 1);
let report = stub
.process_capture_frame(&mut samples, 48_000, 1)
.expect("ok");
assert_eq!(report.aec_metrics.echo_return_loss_db, 0.0);
assert_eq!(report.aec_metrics.echo_return_loss_enhancement_db, 0.0);
assert_eq!(report.aec_metrics.delay_ms, 0);
assert!(!report.voice_detected);
assert_eq!(report.level_dbfs, -120.0);
}
#[test]
fn rejects_mismatched_sample_rate() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let mut samples = make_frame(16_000, 1);
let err = stub
.process_capture_frame(&mut samples, 16_000, 1)
.expect_err("err");
assert!(matches!(err, ApmError::SampleRateMismatch { .. }));
}
#[test]
fn rejects_mismatched_channels() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let mut samples = make_frame(48_000, 2);
let err = stub
.process_capture_frame(&mut samples, 48_000, 2)
.expect_err("err");
assert!(matches!(err, ApmError::ChannelsMismatch { .. }));
}
#[test]
fn rejects_wrong_frame_length() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let mut samples = vec![0i16; 100];
let err = stub
.process_capture_frame(&mut samples, 48_000, 1)
.expect_err("err");
assert!(matches!(err, ApmError::FrameLengthMismatch { .. }));
}
#[test]
fn rejects_render_frame_length() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let samples = vec![0i16; 99];
let err = stub
.process_render_frame(&samples, 48_000, 1)
.expect_err("err");
assert!(matches!(err, ApmError::FrameLengthMismatch { .. }));
}
#[test]
fn rejects_sample_rate_out_of_range_on_construct() {
let err = StubAudioProcessor::new(ApmConfig::default(), 4_000, 1).expect_err("err");
assert!(matches!(err, ApmError::SampleRateOutOfRange { .. }));
}
#[test]
fn rejects_channels_out_of_range_on_construct() {
let err = StubAudioProcessor::new(ApmConfig::default(), 48_000, 4).expect_err("err");
assert!(matches!(err, ApmError::ChannelsOutOfRange { .. }));
}
#[test]
fn reset_returns_ok() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let mut samples = make_frame(48_000, 1);
stub.process_capture_frame(&mut samples, 48_000, 1)
.expect("ok");
assert_eq!(stub.capture_frames_processed(), 1);
stub.reset().expect("reset");
assert_eq!(stub.capture_frames_processed(), 0);
assert_eq!(stub.render_frames_processed(), 0);
}
#[test]
fn builder_pattern_produces_valid_config() {
let config = ApmConfigBuilder::new()
.aec(false)
.ns(true)
.agc(false)
.aec_mobile_mode(true)
.target_level_dbfs(-6)
.build();
assert!(!config.aec_enabled);
assert!(config.ns_enabled);
assert!(!config.agc_enabled);
assert!(config.aec_mobile_mode);
assert_eq!(config.target_level_dbfs, -6);
}
#[test]
fn builder_default_matches_struct_default() {
let builder = ApmConfigBuilder::new().build();
let default = ApmConfig::default();
assert_eq!(builder, default);
}
#[test]
fn state_preserved_across_many_frames() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let mut samples = make_frame(48_000, 1);
const N: u64 = 100;
for _ in 0..N {
stub.process_capture_frame(&mut samples, 48_000, 1)
.expect("ok");
}
assert_eq!(stub.capture_frames_processed(), N);
let render = vec![0i16; expected_frame_samples(48_000, 1)];
for _ in 0..N {
stub.process_render_frame(&render, 48_000, 1).expect("ok");
}
assert_eq!(stub.render_frames_processed(), N);
}
#[test]
fn determinism_stub_produces_identical_output() {
let mut stub_a = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let mut stub_b = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor");
let original = make_frame(48_000, 1);
let mut samples_a = original.clone();
let mut samples_b = original.clone();
let report_a = stub_a
.process_capture_frame(&mut samples_a, 48_000, 1)
.expect("ok");
let report_b = stub_b
.process_capture_frame(&mut samples_b, 48_000, 1)
.expect("ok");
assert_eq!(samples_a, samples_b);
assert_eq!(samples_a, original);
assert_eq!(report_a, report_b);
}
#[test]
fn stereo_round_trip_capture_succeeds() {
let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 2).expect("ctor");
let mut samples = make_frame(48_000, 2);
let original = samples.clone();
let report = stub
.process_capture_frame(&mut samples, 48_000, 2)
.expect("ok");
assert_eq!(samples, original);
assert_eq!(report, ApmReport::NEUTRAL);
}
#[test]
fn validate_frame_shape_accepts_canonical_48k_mono() {
validate_frame_shape(480, 48_000, 1, 48_000, 1).expect("ok");
}
#[test]
#[cfg(not(feature = "real-apm"))]
fn real_apm_type_absent_when_feature_off() {
let _ = ApmConfig::default();
}
#[cfg(feature = "real-apm")]
mod real_apm_tests {
use super::*;
fn sine_frame_i16(sample_rate_hz: u32, channels: u16, frequency_hz: f32) -> Vec<i16> {
let per_channel = (APM_FRAME_MS as usize) * (sample_rate_hz as usize) / 1000;
let total = per_channel * (channels as usize);
let mut samples = Vec::with_capacity(total);
for sample_index in 0..per_channel {
let phase = (sample_index as f32) * frequency_hz / (sample_rate_hz as f32);
let value = (phase * 2.0 * core::f32::consts::PI).sin() * 0.5;
let scaled = (value * 32767.0) as i16;
for _ in 0..channels {
samples.push(scaled);
}
}
samples
}
#[test]
fn real_apm_constructs_at_48k_mono() {
let config = ApmConfig::default();
let processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
assert_eq!(processor.samples_per_channel(), 480);
assert_eq!(processor.capture_frames_processed(), 0);
assert_eq!(processor.render_frames_processed(), 0);
assert_eq!(processor.config(), config);
}
#[test]
fn real_apm_rejects_invalid_sample_rate_on_construct() {
let config = ApmConfig::default();
let err = WebRtcAudioProcessor::new(config, 4_000, 1).expect_err("err");
assert!(matches!(err, ApmError::SampleRateOutOfRange { .. }));
}
#[test]
fn real_apm_rejects_invalid_channels_on_construct() {
let config = ApmConfig::default();
let err = WebRtcAudioProcessor::new(config, 48_000, 8).expect_err("err");
assert!(matches!(err, ApmError::ChannelsOutOfRange { .. }));
}
#[test]
fn real_apm_capture_increments_counter_and_returns_report() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = sine_frame_i16(48_000, 1, 440.0);
let report = processor
.process_capture_frame(&mut samples, 48_000, 1)
.expect("capture ok");
assert_eq!(processor.capture_frames_processed(), 1);
assert!(report.level_dbfs <= 0.0);
assert!(report.level_dbfs >= -120.0);
}
#[test]
fn real_apm_render_increments_counter() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let samples = sine_frame_i16(48_000, 1, 880.0);
processor
.process_render_frame(&samples, 48_000, 1)
.expect("render ok");
assert_eq!(processor.render_frames_processed(), 1);
}
#[test]
fn real_apm_rejects_mismatched_frame_length() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = vec![0i16; 100];
let err = processor
.process_capture_frame(&mut samples, 48_000, 1)
.expect_err("err");
assert!(matches!(err, ApmError::FrameLengthMismatch { .. }));
}
#[test]
fn real_apm_rejects_mismatched_sample_rate_at_process() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = sine_frame_i16(16_000, 1, 440.0);
let err = processor
.process_capture_frame(&mut samples, 16_000, 1)
.expect_err("err");
assert!(matches!(err, ApmError::SampleRateMismatch { .. }));
}
#[test]
fn real_apm_processes_many_capture_frames_without_panic() {
let config = ApmConfigBuilder::new().aec(true).ns(true).agc(true).build();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = sine_frame_i16(48_000, 1, 440.0);
const N: u64 = 50;
for _ in 0..N {
let mut frame = samples.clone();
processor
.process_capture_frame(&mut frame, 48_000, 1)
.expect("capture ok");
processor
.process_render_frame(&samples, 48_000, 1)
.expect("render ok");
}
assert_eq!(processor.capture_frames_processed(), N);
assert_eq!(processor.render_frames_processed(), N);
samples.clear();
}
#[test]
fn real_apm_reset_clears_counters() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = sine_frame_i16(48_000, 1, 440.0);
processor
.process_capture_frame(&mut samples, 48_000, 1)
.expect("capture ok");
processor
.process_render_frame(&samples, 48_000, 1)
.expect("render ok");
assert_eq!(processor.capture_frames_processed(), 1);
assert_eq!(processor.render_frames_processed(), 1);
processor.reset().expect("reset ok");
assert_eq!(processor.capture_frames_processed(), 0);
assert_eq!(processor.render_frames_processed(), 0);
}
#[test]
fn real_apm_silent_frame_reports_quiet_level() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = vec![0i16; expected_frame_samples(48_000, 1)];
let report = processor
.process_capture_frame(&mut samples, 48_000, 1)
.expect("capture ok");
assert!(report.level_dbfs <= -50.0);
assert!(!report.voice_detected);
}
#[test]
fn real_apm_stereo_round_trip_succeeds() {
let config = ApmConfig::default();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 2).expect("ctor");
let mut samples = sine_frame_i16(48_000, 2, 440.0);
processor
.process_capture_frame(&mut samples, 48_000, 2)
.expect("capture ok");
assert_eq!(processor.capture_frames_processed(), 1);
}
#[test]
fn real_apm_disabling_all_submodules_constructs_ok() {
let config = ApmConfigBuilder::new()
.aec(false)
.ns(false)
.agc(false)
.target_level_dbfs(-6)
.build();
let mut processor = WebRtcAudioProcessor::new(config, 48_000, 1).expect("ctor");
let mut samples = sine_frame_i16(48_000, 1, 440.0);
processor
.process_capture_frame(&mut samples, 48_000, 1)
.expect("capture ok");
assert_eq!(processor.capture_frames_processed(), 1);
}
}
}