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:
+309
@@ -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]
|
||||
@@ -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 "$@"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user