Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89bbfa1e18 | ||
|
|
0a6ef55937 | ||
|
|
aa796d7395 | ||
|
|
08d7ace25d | ||
|
|
ddd977796f | ||
|
|
2b285491d0 | ||
|
|
413f247378 | ||
|
|
3f9ea4f7b8 | ||
|
|
2f6d45fb04 | ||
|
|
5c3dd70bba | ||
|
|
ef14c22300 |
+46
-31
@@ -1,34 +1,49 @@
|
||||
# Environment variables set for all cargo invocations in this workspace.
|
||||
# CMAKE_POLICY_VERSION_MINIMUM is required for audiopus_sys's bundled
|
||||
# Opus CMake build to succeed on CMake 4.x (which removed compatibility
|
||||
# with cmake_minimum_required < 3.5). audiopus_sys v0.2.2 bundles
|
||||
# Opus 1.3.1 whose CMakeLists.txt uses a very old minimum version.
|
||||
# audiopus_sys calls cmake::build(opus_path), so downstream Cargo env cannot
|
||||
# call cmake-rs Config::define() to override CMake's MSVC Debug CRT defaults.
|
||||
# Instead, point cmake-rs at a small wrapper that injects -D cache/policy
|
||||
# variables during configure while passing cmake --build / --version / -E /
|
||||
# --install / --open through unchanged. This keeps Opus Debug builds on
|
||||
# Rust's release dynamic CRT (/MD) instead of CMake's default debug CRT
|
||||
# (/MDd), which otherwise pulls in unresolved __imp__CrtDbgReportW symbols
|
||||
# at test link.
|
||||
#
|
||||
# IMPORTANT: Cargo's `[target.<triple>]` config sections only forward a
|
||||
# fixed allowlist of keys (linker, runner, rustflags, rustdocflags, ar)
|
||||
# to build scripts. Arbitrary keys such as `CMAKE` placed under
|
||||
# `[target.<triple>]` are silently ignored and never reach the
|
||||
# audiopus_sys build script. cmake-rs (via cc-style env resolution)
|
||||
# looks up CMAKE in this order:
|
||||
# 1. CMAKE_<target-triple-with-dashes>
|
||||
# 2. CMAKE_<target_triple_with_underscores>
|
||||
# 3. TARGET_CMAKE (or HOST_CMAKE when host == target)
|
||||
# 4. CMAKE
|
||||
# We therefore scope the wrapper to Windows MSVC targets by setting the
|
||||
# target-suffixed variant in the global [env] section. Non-Windows
|
||||
# hosts (macOS, Linux, iOS, Android) never see CMAKE set and invoke
|
||||
# `cmake` directly.
|
||||
#
|
||||
# NOTE: CMAKE_POLICY_DEFAULT_CMP0091 and CMAKE_MSVC_RUNTIME_LIBRARY cannot
|
||||
# be set via the process environment because CMake does NOT auto-import
|
||||
# them into its cache; they must be passed as `-D` definitions, which the
|
||||
# wrapper does.
|
||||
#
|
||||
# iOS deployment target (DEC-003: iOS 13.0 minimum) is NOT set here. It is
|
||||
# enforced in two places that own the iOS build:
|
||||
# 1. tools/build-ios.sh — sets IPHONEOS_DEPLOYMENT_TARGET for the cargo
|
||||
# invocation and bypasses audiopus_sys's CMake build via
|
||||
# LIBOPUS_STATIC=1 / LIBOPUS_NO_PKG=1 / LIBOPUS_LIB_DIR.
|
||||
# 2. apps/chanora_flutter/ios/Runner.xcodeproj — sets the Xcode
|
||||
# IPHONEOS_DEPLOYMENT_TARGET build setting for the final link.
|
||||
# Setting it globally here would make native macOS `cargo check` runs try
|
||||
# to link iPhone objects against the macOS SDK.
|
||||
|
||||
[env]
|
||||
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||
|
||||
# iOS builds must set IPHONEOS_DEPLOYMENT_TARGET in the invoking script
|
||||
# or Xcode build phase. Do not set it globally here: native macOS cargo
|
||||
# checks also compile bundled C/C++ dependencies, and a global iOS
|
||||
# deployment target makes clang try to link iPhone objects against the
|
||||
# macOS SDK.
|
||||
|
||||
# iOS target linker flags (DEC-003: minimum deployment target iOS 13.0).
|
||||
#
|
||||
# These rustflags pass -miphoneos-version-min=13.0 to the linker, ensuring
|
||||
# the final binary targets iOS 13.0+. This is defense-in-depth alongside
|
||||
# the IPHONEOS_DEPLOYMENT_TARGET env var above — the env var affects C
|
||||
# compilation (cc crate, CMake), while these rustflags affect the final
|
||||
# link step.
|
||||
#
|
||||
# NOTE: The canonical iOS build is done via tools/build-ios.sh, which
|
||||
# sets LIBOPUS_STATIC=1, LIBOPUS_NO_PKG=1, and LIBOPUS_LIB_DIR to
|
||||
# bypass audiopus_sys's CMake build entirely.
|
||||
|
||||
[target.aarch64-apple-ios]
|
||||
rustflags = ["-C", "link-arg=-miphoneos-version-min=13.0"]
|
||||
|
||||
[target.aarch64-apple-ios-sim]
|
||||
rustflags = ["-C", "link-arg=-miphonesimulator-version-min=13.0"]
|
||||
|
||||
[target.x86_64-apple-ios]
|
||||
rustflags = ["-C", "link-arg=-miphonesimulator-version-min=13.0"]
|
||||
# Scope the cmake wrapper to Windows MSVC targets only via the
|
||||
# target-suffixed env var name that cc/cmake-rs already resolve.
|
||||
# Force = true so a developer's pre-existing CMAKE_x86_64-pc-windows-msvc
|
||||
# does not silently bypass the wrapper. Relative = true so the path
|
||||
# resolves from the workspace root regardless of where cargo is invoked.
|
||||
CMAKE_x86_64-pc-windows-msvc = { value = "tools/cmake-msvc-release-crt.cmd", force = true, relative = true }
|
||||
CMAKE_aarch64-pc-windows-msvc = { value = "tools/cmake-msvc-release-crt.cmd", force = true, relative = true }
|
||||
|
||||
Generated
+367
-15
@@ -148,12 +148,111 @@ version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener 5.4.1",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener 2.5.3",
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-global-executor"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c"
|
||||
dependencies = [
|
||||
"async-channel 2.5.0",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"blocking",
|
||||
"futures-lite",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener 5.4.1",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel 2.5.0",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener 5.4.1",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
@@ -165,6 +264,57 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-std"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b"
|
||||
dependencies = [
|
||||
"async-channel 1.9.0",
|
||||
"async-global-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"crossbeam-utils",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"gloo-timers",
|
||||
"kv-log-macro",
|
||||
"log",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -257,6 +407,12 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -308,6 +464,19 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel 2.5.0",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "build-target"
|
||||
version = "0.4.0"
|
||||
@@ -352,6 +521,32 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cacache"
|
||||
version = "13.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c5063741c7b2e260bbede781cf4679632dd90e2718e99f7715e46824b65670b"
|
||||
dependencies = [
|
||||
"async-std",
|
||||
"digest 0.10.7",
|
||||
"either",
|
||||
"futures",
|
||||
"hex",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"miette",
|
||||
"reflink-copy",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"ssri",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -475,11 +670,23 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chanora_cache"
|
||||
version = "0.2.0-beta.1"
|
||||
dependencies = [
|
||||
"cacache",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chanora_core"
|
||||
version = "0.2.0-beta.1"
|
||||
dependencies = [
|
||||
"chanora_audio",
|
||||
"chanora_cache",
|
||||
"chanora_diagnostics",
|
||||
"chanora_prefetch",
|
||||
"chanora_protocol",
|
||||
@@ -515,7 +722,7 @@ name = "chanora_protocol"
|
||||
version = "0.2.0-beta.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chanora_resolver",
|
||||
"futures",
|
||||
"reqwest 0.13.4",
|
||||
@@ -554,7 +761,7 @@ dependencies = [
|
||||
name = "chanora_storage"
|
||||
version = "0.2.0-beta.1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chacha20poly1305",
|
||||
"keyring",
|
||||
"rand 0.8.6",
|
||||
@@ -1239,6 +1446,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@@ -1256,7 +1469,7 @@ version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener 5.4.1",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
@@ -1559,6 +1772,18 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-timers"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -1837,7 +2062,7 @@ version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
@@ -2142,6 +2367,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kv-log-macro"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -2226,6 +2460,9 @@ name = "log"
|
||||
version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
|
||||
dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
@@ -2274,6 +2511,15 @@ version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
@@ -2283,6 +2529,29 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miette"
|
||||
version = "5.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e"
|
||||
dependencies = [
|
||||
"miette-derive",
|
||||
"once_cell",
|
||||
"thiserror 1.0.69",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miette-derive"
|
||||
version = "5.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -2813,6 +3082,17 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
@@ -2857,6 +3137,20 @@ dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
@@ -3183,6 +3477,18 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reflink-copy"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13362233b147e57674c37b802d216b7c5e3dcccbed8967c84f0d8d223868ae27"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"rustix",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
@@ -3218,7 +3524,7 @@ version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
@@ -3256,7 +3562,7 @@ version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
@@ -3672,6 +3978,17 @@ dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -3851,6 +4168,23 @@ dependencies = [
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssri"
|
||||
version = "9.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"digest 0.10.7",
|
||||
"hex",
|
||||
"miette",
|
||||
"serde",
|
||||
"sha-1",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -4349,7 +4683,7 @@ name = "ts-bookkeeping"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"heck",
|
||||
"itertools 0.14.0",
|
||||
"num-derive",
|
||||
@@ -4370,7 +4704,7 @@ version = "0.2.0"
|
||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||
dependencies = [
|
||||
"audiopus",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"futures",
|
||||
"git-testament",
|
||||
"hickory-net",
|
||||
@@ -4398,7 +4732,7 @@ version = "0.2.0"
|
||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"curve25519-dalek-ng",
|
||||
"eax",
|
||||
"futures",
|
||||
@@ -4427,7 +4761,7 @@ name = "tsproto-packets"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.12.1",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
@@ -4442,7 +4776,7 @@ name = "tsproto-structs"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/EdisonJwa/tsclientlib.git?branch=fix%2Fp256-short-coordinate-pad#8b7a3226c692319b714ea1d32fd5ded05911aa40"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"csv",
|
||||
"heck",
|
||||
"once_cell",
|
||||
@@ -4455,7 +4789,7 @@ name = "tsproto-structs"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"csv",
|
||||
"heck",
|
||||
"once_cell",
|
||||
@@ -4468,7 +4802,7 @@ name = "tsproto-types"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/EdisonJwa/tsclientlib.git?branch=fix%2Fp256-short-coordinate-pad#8b7a3226c692319b714ea1d32fd5ded05911aa40"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.12.1",
|
||||
"curve25519-dalek-ng",
|
||||
"elliptic-curve",
|
||||
@@ -4512,6 +4846,12 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -4570,6 +4910,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "value-bag"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -5256,6 +5602,12 @@ version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
@@ -5289,7 +5641,7 @@ dependencies = [
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"event-listener 5.4.1",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# crates/chanora_resolver/ — TeamSpeak address resolution
|
||||
# crates/chanora_state/ — snapshot, deltas, reducers
|
||||
# crates/chanora_audio/ — capture, DSP, Opus, jitter, mixer
|
||||
# crates/chanora_cache/ — avatar/icon blob cache (cacache-backed)
|
||||
# crates/chanora_storage/ — bookmarks, settings, identity refs
|
||||
# crates/chanora_diagnostics/ — logs, redaction, export
|
||||
# crates/chanora_prefetch — server-resolution prefetch cache/policy
|
||||
@@ -30,6 +31,7 @@ members = [
|
||||
"crates/chanora_state",
|
||||
"crates/chanora_audio",
|
||||
"crates/chanora_storage",
|
||||
"crates/chanora_cache",
|
||||
"crates/chanora_diagnostics",
|
||||
"crates/chanora_prefetch",
|
||||
"crates/chanora_bridge",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
// Mirror Release.xcconfig (see explanation there).
|
||||
OTHER_LDFLAGS = $(inherited) -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
|
||||
// Mirror Release.xcconfig (see explanation there). `-u` is the load-bearing
|
||||
// flag: without it the linker drops Swift @_cdecl symbols (no Swift caller)
|
||||
// before `-exported_symbol` can re-export them, and the verify_silero_exports
|
||||
// build phase fails the build.
|
||||
OTHER_LDFLAGS = $(inherited) -Xlinker -u -Xlinker _chanora_silero_vad_create -Xlinker -u -Xlinker _chanora_silero_vad_destroy -Xlinker -u -Xlinker _chanora_silero_vad_reset -Xlinker -u -Xlinker _chanora_silero_vad_process -Xlinker -u -Xlinker _chanora_silero_vad_last_error -Xlinker -u -Xlinker _chanora_silero_vad_free_string -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
|
||||
STRIP_STYLE = non-global
|
||||
|
||||
@@ -23,7 +23,7 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/haptic_kit/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
chanora_bridge: 26252acdf9ca660ce9c132ad25cd5ad5af467b16
|
||||
chanora_bridge: 27a03592058709f6f38701343eb51c3a55b02da0
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
||||
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
||||
|
||||
@@ -46,7 +46,8 @@ import AVFoundation
|
||||
//
|
||||
// VoIP configuration is engaged on voice-channel join via the
|
||||
// `chanora/ios_audio_session` MethodChannel, driven from Dart
|
||||
// by the BridgeEvent::AudioStarted / AudioStopped lifecycle.
|
||||
// before `voiceJoin` starts VoiceProcessingIO and again as an
|
||||
// idempotent guard on the AudioStarted lifecycle.
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||
logAudioSessionState(context: "launch-ambient")
|
||||
@@ -79,8 +80,8 @@ import AVFoundation
|
||||
}
|
||||
|
||||
/// Activate the VoIP audio session. Called from Dart via the
|
||||
/// `chanora/ios_audio_session` channel when a voice channel join
|
||||
/// reaches the `BridgeEvent::AudioStarted` stage. Configures
|
||||
/// `chanora/ios_audio_session` channel before a voice channel join
|
||||
/// starts VoiceProcessingIO. Configures
|
||||
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
|
||||
/// (Spotify, podcasts) can keep playing alongside the voice
|
||||
/// channel — matching the Telegram group-call UX. Idempotent:
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
|
||||
@@ -34,6 +34,7 @@ import 'services/prefetch_debouncer.dart';
|
||||
import 'services/snapshot_state_mapper.dart';
|
||||
import 'services/ts3_server_link.dart';
|
||||
import 'services/ui_preferences_service.dart';
|
||||
import 'services/voice_join_ordering.dart';
|
||||
import 'src/rust/api.dart' as rust;
|
||||
import 'src/rust/frb_generated.dart';
|
||||
import 'src/rust/lib.dart' as rust_err;
|
||||
@@ -92,7 +93,10 @@ String _kAppVersion = appSemverBaseline;
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await RustLib.init();
|
||||
unawaited(wireStorage());
|
||||
unawaited(() async {
|
||||
await wireStorage();
|
||||
await wireCache();
|
||||
}());
|
||||
unawaited(wireConnectivity());
|
||||
wireAudioLifecycle();
|
||||
await configureBundledVadModels();
|
||||
@@ -744,6 +748,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_reconnectAttempt = null;
|
||||
_reconnectDelay = null;
|
||||
});
|
||||
unawaited(iosAudioSessionController.activate());
|
||||
unawaited(_refreshSnapshot(recordActivity: false, reportErrors: true));
|
||||
case rust.BridgeEvent_Lost(:final reason):
|
||||
_recordUiDiagnostic('connection', 'lost: $reason');
|
||||
@@ -1157,6 +1162,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_chatMessages.clear();
|
||||
});
|
||||
_releaseFocusedPttIfHeld();
|
||||
await iosAudioSessionController.activate();
|
||||
try {
|
||||
final snap = await rust.connect(
|
||||
host: (host ?? _hostCtl.text).trim(),
|
||||
@@ -1301,7 +1307,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
});
|
||||
}
|
||||
}
|
||||
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
|
||||
await joinVoiceChannelWithIosAudioSession(
|
||||
channelId: ch.id,
|
||||
password: password ?? '',
|
||||
voiceJoin: rust.voiceJoin,
|
||||
activateIosAudioSession: iosAudioSessionController.activate,
|
||||
deactivateIosAudioSession: iosAudioSessionController.deactivate,
|
||||
// Server says we are already in the target channel: the user is
|
||||
// still joined to a voice channel, so the iOS audio session must
|
||||
// stay active. The catch below converts this rethrow into the
|
||||
// success-on-already-joined branch.
|
||||
isJoinSuccess: _isAlreadyInChannel,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_currentVoiceChannelId = ch.id;
|
||||
|
||||
@@ -15,15 +15,22 @@ typedef StorageDirectoryProvider = Future<Directory> Function();
|
||||
typedef StorageInitializer = Future<void> Function(String dir);
|
||||
|
||||
Future<void>? _storageInitFuture;
|
||||
Future<void>? _cacheInitFuture;
|
||||
Future<void>? _vadBootstrapFuture;
|
||||
StorageDirectoryProvider _storageDirectoryProvider =
|
||||
getApplicationSupportDirectory;
|
||||
StorageDirectoryProvider _cacheDirectoryProvider = getApplicationCacheDirectory;
|
||||
StorageInitializer _storageInitializer = _defaultStorageInitializer;
|
||||
StorageInitializer _cacheInitializer = _defaultCacheInitializer;
|
||||
|
||||
Future<void> _defaultStorageInitializer(String dir) {
|
||||
return rust.initStorage(dir: dir);
|
||||
}
|
||||
|
||||
Future<void> _defaultCacheInitializer(String dir) {
|
||||
return rust.initCache(dir: dir);
|
||||
}
|
||||
|
||||
Future<File> _copyBundledAssetToDocuments({
|
||||
required String assetPath,
|
||||
required String fileName,
|
||||
@@ -121,16 +128,50 @@ Future<void> _wireStorageImpl() async {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> wireCache() async {
|
||||
final existing = _cacheInitFuture;
|
||||
if (existing != null) {
|
||||
await existing;
|
||||
return;
|
||||
}
|
||||
|
||||
final initFuture = _wireCacheImpl();
|
||||
_cacheInitFuture = initFuture;
|
||||
await initFuture;
|
||||
}
|
||||
|
||||
Future<void> _wireCacheImpl() async {
|
||||
var initialized = false;
|
||||
try {
|
||||
final dir = await _cacheDirectoryProvider();
|
||||
await _cacheInitializer(dir.path);
|
||||
initialized = true;
|
||||
} catch (_) {
|
||||
// Best-effort; missing cache just means protocol-owned assets are
|
||||
// re-downloaded this session.
|
||||
} finally {
|
||||
if (!initialized) {
|
||||
_cacheInitFuture = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugResetStorageBootstrap({
|
||||
StorageDirectoryProvider? storageDirectoryProvider,
|
||||
StorageDirectoryProvider? cacheDirectoryProvider,
|
||||
StorageInitializer? storageInitializer,
|
||||
StorageInitializer? cacheInitializer,
|
||||
}) {
|
||||
_storageInitFuture = null;
|
||||
_cacheInitFuture = null;
|
||||
_vadBootstrapFuture = null;
|
||||
_storageDirectoryProvider =
|
||||
storageDirectoryProvider ?? getApplicationSupportDirectory;
|
||||
_cacheDirectoryProvider =
|
||||
cacheDirectoryProvider ?? getApplicationCacheDirectory;
|
||||
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
|
||||
_cacheInitializer = cacheInitializer ?? _defaultCacheInitializer;
|
||||
}
|
||||
|
||||
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
|
||||
|
||||
@@ -10,9 +10,9 @@ const iosAudioSessionChannelName = 'chanora/ios_audio_session';
|
||||
/// launch and leaves it inactive. The session is only switched to
|
||||
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
|
||||
/// voice channel is actually active. This controller is the Dart
|
||||
/// side of that contract — call [activate] when the Rust engine
|
||||
/// emits `BridgeEvent::AudioStarted` and [deactivate] on
|
||||
/// `BridgeEvent::AudioStopped`.
|
||||
/// side of that contract — call [activate] before the Rust engine
|
||||
/// starts VoiceProcessingIO and [deactivate] on
|
||||
/// `BridgeEvent::AudioStopped` or failed joins.
|
||||
///
|
||||
/// On non-iOS platforms both methods are no-ops; the platforms
|
||||
/// handle their own session lifecycle elsewhere (Android via
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
typedef VoiceJoinCallback = Future<void> Function({
|
||||
required BigInt channelId,
|
||||
required String password,
|
||||
});
|
||||
|
||||
typedef IosVoiceSessionActivation = Future<void> Function();
|
||||
typedef IosVoiceSessionDeactivation = Future<void> Function();
|
||||
|
||||
/// Predicate used to recognise `voiceJoin` errors that the caller treats as a
|
||||
/// successful join outcome (e.g. the server replied "already in channel").
|
||||
///
|
||||
/// When this returns `true` for a thrown error, the iOS audio session is kept
|
||||
/// active because the user is still considered joined to the channel. The
|
||||
/// error is still rethrown so the caller can run its success-on-already-joined
|
||||
/// branch and update local state.
|
||||
typedef VoiceJoinSuccessPredicate = bool Function(Object error);
|
||||
|
||||
Future<void> joinVoiceChannelWithIosAudioSession({
|
||||
required BigInt channelId,
|
||||
required String password,
|
||||
required VoiceJoinCallback voiceJoin,
|
||||
required IosVoiceSessionActivation activateIosAudioSession,
|
||||
required IosVoiceSessionDeactivation deactivateIosAudioSession,
|
||||
VoiceJoinSuccessPredicate? isJoinSuccess,
|
||||
}) async {
|
||||
await activateIosAudioSession();
|
||||
try {
|
||||
await voiceJoin(channelId: channelId, password: password);
|
||||
} catch (e) {
|
||||
if (isJoinSuccess != null && isJoinSuccess(e)) {
|
||||
rethrow;
|
||||
}
|
||||
await deactivateIosAudioSession();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,29 @@ String exportDiagnostics() => RustLib.instance.api.crateApiExportDiagnostics();
|
||||
Future<void> initStorage({required String dir}) =>
|
||||
RustLib.instance.api.crateApiInitStorage(dir: dir);
|
||||
|
||||
/// Configure the bridge blob cache root.
|
||||
Future<void> initCache({required String dir}) =>
|
||||
RustLib.instance.api.crateApiInitCache(dir: dir);
|
||||
|
||||
/// Resolve avatar bytes through the bridge.
|
||||
Future<Uint8List?> downloadAvatar({
|
||||
required String avatarHash,
|
||||
required String clientUid,
|
||||
}) => RustLib.instance.api.crateApiDownloadAvatar(
|
||||
avatarHash: avatarHash,
|
||||
clientUid: clientUid,
|
||||
);
|
||||
|
||||
/// Resolve icon bytes through the bridge.
|
||||
Future<Uint8List?> downloadIcon({required BigInt iconId}) =>
|
||||
RustLib.instance.api.crateApiDownloadIcon(iconId: iconId);
|
||||
|
||||
/// Purge cached protocol-owned assets.
|
||||
Future<void> clearFileCache() => RustLib.instance.api.crateApiClearFileCache();
|
||||
|
||||
/// Report the configured file-cache size.
|
||||
Future<BigInt> fileCacheSize() => RustLib.instance.api.crateApiFileCacheSize();
|
||||
|
||||
/// List persisted bookmarks.
|
||||
Future<List<BridgeBookmark>> listBookmarks() =>
|
||||
RustLib.instance.api.crateApiListBookmarks();
|
||||
@@ -1339,11 +1362,8 @@ sealed class BridgeEvent with _$BridgeEvent {
|
||||
|
||||
/// Bridge iOS voice-processing mode.
|
||||
enum BridgeIosVoiceProcessingMode {
|
||||
/// Shipping VPIO path.
|
||||
/// Apple VoiceProcessingIO path.
|
||||
platformVoiceProcessing,
|
||||
|
||||
/// Experimental Sonora path.
|
||||
sonoraExperimental,
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||
String get codegenVersion => '2.12.0';
|
||||
|
||||
@override
|
||||
int get rustContentHash => -20394775;
|
||||
int get rustContentHash => 635684021;
|
||||
|
||||
static const kDefaultExternalLibraryLoaderConfig =
|
||||
ExternalLibraryLoaderConfig(
|
||||
@@ -87,6 +87,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<void> crateApiBridgeInit();
|
||||
|
||||
Future<void> crateApiClearFileCache();
|
||||
|
||||
Future<BridgeClientProfile> crateApiClientProfile({required BigInt clientId});
|
||||
|
||||
Future<BridgeSnapshot> crateApiConnect({
|
||||
@@ -99,12 +101,21 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<void> crateApiDisconnect();
|
||||
|
||||
Future<Uint8List?> crateApiDownloadAvatar({
|
||||
required String avatarHash,
|
||||
required String clientUid,
|
||||
});
|
||||
|
||||
Future<Uint8List?> crateApiDownloadIcon({required BigInt iconId});
|
||||
|
||||
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled});
|
||||
|
||||
Stream<BridgeEvent> crateApiEventsStream();
|
||||
|
||||
String crateApiExportDiagnostics();
|
||||
|
||||
Future<BigInt> crateApiFileCacheSize();
|
||||
|
||||
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig();
|
||||
|
||||
Future<BridgePttBinding> crateApiGetPttBinding();
|
||||
@@ -121,6 +132,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
void crateApiHandleRouteChange({required BridgeAudioRoute route});
|
||||
|
||||
Future<void> crateApiInitCache({required String dir});
|
||||
|
||||
Future<void> crateApiInitStorage({required String dir});
|
||||
|
||||
Stream<double> crateApiInputLevelStream();
|
||||
@@ -320,6 +333,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
TaskConstMeta get kCrateApiBridgeInitConstMeta =>
|
||||
const TaskConstMeta(debugName: "bridge_init", argNames: []);
|
||||
|
||||
@override
|
||||
Future<void> crateApiClearFileCache() {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 5,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiClearFileCacheConstMeta,
|
||||
argValues: [],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiClearFileCacheConstMeta =>
|
||||
const TaskConstMeta(debugName: "clear_file_cache", argNames: []);
|
||||
|
||||
@override
|
||||
Future<BridgeClientProfile> crateApiClientProfile({
|
||||
required BigInt clientId,
|
||||
@@ -332,7 +372,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 5,
|
||||
funcId: 6,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -366,7 +406,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 6,
|
||||
funcId: 7,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -396,7 +436,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 7,
|
||||
funcId: 8,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -423,7 +463,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 8,
|
||||
funcId: 9,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -441,6 +481,68 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
TaskConstMeta get kCrateApiDisconnectConstMeta =>
|
||||
const TaskConstMeta(debugName: "disconnect", argNames: []);
|
||||
|
||||
@override
|
||||
Future<Uint8List?> crateApiDownloadAvatar({
|
||||
required String avatarHash,
|
||||
required String clientUid,
|
||||
}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(avatarHash, serializer);
|
||||
sse_encode_String(clientUid, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 10,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_opt_list_prim_u_8_strict,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiDownloadAvatarConstMeta,
|
||||
argValues: [avatarHash, clientUid],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiDownloadAvatarConstMeta => const TaskConstMeta(
|
||||
debugName: "download_avatar",
|
||||
argNames: ["avatarHash", "clientUid"],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Uint8List?> crateApiDownloadIcon({required BigInt iconId}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_u_64(iconId, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 11,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_opt_list_prim_u_8_strict,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiDownloadIconConstMeta,
|
||||
argValues: [iconId],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiDownloadIconConstMeta =>
|
||||
const TaskConstMeta(debugName: "download_icon", argNames: ["iconId"]);
|
||||
|
||||
@override
|
||||
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled}) {
|
||||
return handler.executeNormal(
|
||||
@@ -451,7 +553,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 9,
|
||||
funcId: 12,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -484,7 +586,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 10,
|
||||
funcId: 13,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -510,7 +612,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_String,
|
||||
@@ -526,6 +628,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
TaskConstMeta get kCrateApiExportDiagnosticsConstMeta =>
|
||||
const TaskConstMeta(debugName: "export_diagnostics", argNames: []);
|
||||
|
||||
@override
|
||||
Future<BigInt> crateApiFileCacheSize() {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 15,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_u_64,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiFileCacheSizeConstMeta,
|
||||
argValues: [],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiFileCacheSizeConstMeta =>
|
||||
const TaskConstMeta(debugName: "file_cache_size", argNames: []);
|
||||
|
||||
@override
|
||||
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig() {
|
||||
return handler.executeNormal(
|
||||
@@ -535,7 +664,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 12,
|
||||
funcId: 16,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -565,7 +694,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 13,
|
||||
funcId: 17,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -592,7 +721,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 14,
|
||||
funcId: 18,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -619,7 +748,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 15,
|
||||
funcId: 19,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -643,7 +772,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -666,7 +795,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bool(shouldResume, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -692,7 +821,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(routeClass, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 22)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -718,7 +847,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_audio_route(route, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -736,6 +865,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
argNames: ["route"],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> crateApiInitCache({required String dir}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(dir, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 24,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiInitCacheConstMeta,
|
||||
argValues: [dir],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiInitCacheConstMeta =>
|
||||
const TaskConstMeta(debugName: "init_cache", argNames: ["dir"]);
|
||||
|
||||
@override
|
||||
Future<void> crateApiInitStorage({required String dir}) {
|
||||
return handler.executeNormal(
|
||||
@@ -746,7 +903,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 20,
|
||||
funcId: 25,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -776,7 +933,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 21,
|
||||
funcId: 26,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -805,7 +962,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 22,
|
||||
funcId: 27,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -832,7 +989,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 23,
|
||||
funcId: 28,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -859,7 +1016,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 24,
|
||||
funcId: 29,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -883,7 +1040,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_String,
|
||||
@@ -913,7 +1070,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 26,
|
||||
funcId: 31,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -943,7 +1100,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 27,
|
||||
funcId: 32,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -970,7 +1127,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 28,
|
||||
funcId: 33,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -995,7 +1152,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(state, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 34)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -1028,7 +1185,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 30,
|
||||
funcId: 35,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1055,7 +1212,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_audio_route(route, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 36)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -1089,7 +1246,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 32,
|
||||
funcId: 37,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1124,7 +1281,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 33,
|
||||
funcId: 38,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1154,7 +1311,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 34,
|
||||
funcId: 39,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1182,7 +1339,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 35,
|
||||
funcId: 40,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1210,7 +1367,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 36,
|
||||
funcId: 41,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1240,7 +1397,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 37,
|
||||
funcId: 42,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1268,7 +1425,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_network_state(state, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 43)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -1294,7 +1451,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 39,
|
||||
funcId: 44,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1322,7 +1479,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 40,
|
||||
funcId: 45,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1350,7 +1507,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 41,
|
||||
funcId: 46,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1378,7 +1535,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 42,
|
||||
funcId: 47,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1410,7 +1567,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 43,
|
||||
funcId: 48,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1440,7 +1597,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 44,
|
||||
funcId: 49,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1468,7 +1625,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 45,
|
||||
funcId: 50,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1496,7 +1653,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 46,
|
||||
funcId: 51,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1523,7 +1680,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 47,
|
||||
funcId: 52,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1551,7 +1708,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 48,
|
||||
funcId: 53,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1583,7 +1740,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 49,
|
||||
funcId: 54,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1612,7 +1769,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 50,
|
||||
funcId: 55,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -2290,6 +2447,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return raw == null ? null : dco_decode_box_autoadd_u_64(raw);
|
||||
}
|
||||
|
||||
@protected
|
||||
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return raw == null ? null : dco_decode_list_prim_u_8_strict(raw);
|
||||
}
|
||||
|
||||
@protected
|
||||
PermissionStateKind dco_decode_permission_state_kind(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
@@ -3249,6 +3412,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
|
||||
if (sse_decode_bool(deserializer)) {
|
||||
return (sse_decode_list_prim_u_8_strict(deserializer));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
PermissionStateKind sse_decode_permission_state_kind(
|
||||
SseDeserializer deserializer,
|
||||
@@ -4096,6 +4270,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_list_prim_u_8_strict(
|
||||
Uint8List? self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
|
||||
sse_encode_bool(self != null, serializer);
|
||||
if (self != null) {
|
||||
sse_encode_list_prim_u_8_strict(self, serializer);
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_permission_state_kind(
|
||||
PermissionStateKind self,
|
||||
|
||||
@@ -201,6 +201,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
|
||||
|
||||
@protected
|
||||
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
|
||||
|
||||
@@ -444,6 +447,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
PermissionStateKind sse_decode_permission_state_kind(
|
||||
SseDeserializer deserializer,
|
||||
@@ -746,6 +752,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_list_prim_u_8_strict(
|
||||
Uint8List? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_permission_state_kind(
|
||||
PermissionStateKind self,
|
||||
|
||||
@@ -203,6 +203,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
|
||||
|
||||
@protected
|
||||
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
|
||||
|
||||
@@ -446,6 +449,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
PermissionStateKind sse_decode_permission_state_kind(
|
||||
SseDeserializer deserializer,
|
||||
@@ -748,6 +754,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_list_prim_u_8_strict(
|
||||
Uint8List? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_permission_state_kind(
|
||||
PermissionStateKind self,
|
||||
|
||||
@@ -638,12 +638,19 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
selected: _mode == rust.BridgeTransmitMode.continuous,
|
||||
onTap: () => _setMode(rust.BridgeTransmitMode.continuous),
|
||||
),
|
||||
_ModeRow(
|
||||
label: l10n.voiceModeVoiceActivity,
|
||||
icon: Icons.graphic_eq,
|
||||
selected: _mode == rust.BridgeTransmitMode.voiceActivity,
|
||||
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
||||
),
|
||||
// Voice-activity transmit is only honoured by the engine on
|
||||
// hosts that ship a Chanora-owned VAD pipeline (DEC-030:
|
||||
// Windows + Linux desktop and Android). iOS / macOS rely
|
||||
// on Apple VoiceProcessingIO and have no VAD bridge, so
|
||||
// hiding the row prevents the UI from advertising a
|
||||
// transmit mode the engine cannot honour.
|
||||
if (voiceActivityTransmitAvailable)
|
||||
_ModeRow(
|
||||
label: l10n.voiceModeVoiceActivity,
|
||||
icon: Icons.graphic_eq,
|
||||
selected: _mode == rust.BridgeTransmitMode.voiceActivity,
|
||||
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
||||
),
|
||||
|
||||
// 3) Release-tail slider (PTT only).
|
||||
if (isPtt) ...[
|
||||
|
||||
@@ -128,7 +128,12 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
VoiceSectionHeader(l10n.voiceModeLabel),
|
||||
SegmentedButton<rust.BridgeTransmitMode>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: transmitModeSegments,
|
||||
// DEC-030: hide the voice-activity segment on hosts
|
||||
// that ship no Chanora-owned VAD pipeline (iOS,
|
||||
// macOS, web).
|
||||
segments: transmitModeSegmentsFor(
|
||||
voiceActivityAvailable: voiceActivityTransmitAvailable,
|
||||
),
|
||||
selected: {_mode},
|
||||
onSelectionChanged: (s) => setState(() => _mode = s.first),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../src/rust/api.dart' as rust;
|
||||
@@ -10,7 +13,11 @@ ButtonStyle voiceSegmentedButtonStyle(ThemeData theme) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Transmit mode selector segments.
|
||||
/// Transmit mode selector segments — full set, all three modes.
|
||||
///
|
||||
/// This list is kept stable for legacy call sites and tests; UI
|
||||
/// surfaces that must respect DEC-030 platform gating should prefer
|
||||
/// [transmitModeSegmentsFor] with [voiceActivityTransmitAvailable].
|
||||
const transmitModeSegments = [
|
||||
ButtonSegment(
|
||||
value: rust.BridgeTransmitMode.ptt,
|
||||
@@ -29,6 +36,43 @@ const transmitModeSegments = [
|
||||
),
|
||||
];
|
||||
|
||||
/// Transmit mode selector segments, optionally dropping the
|
||||
/// voice-activity entry on hosts that do not ship a VAD pipeline.
|
||||
///
|
||||
/// Voice activity transmit is gated by [voiceActivityTransmitAvailable]
|
||||
/// because the underlying VAD pipeline ships only on Windows, Linux, and
|
||||
/// Android per DEC-030. Builds for unsupported platforms (iOS, macOS,
|
||||
/// web) drop the VAD segment entirely so the UI never advertises a
|
||||
/// transmit mode the engine cannot honour.
|
||||
List<ButtonSegment<rust.BridgeTransmitMode>> transmitModeSegmentsFor({
|
||||
required bool voiceActivityAvailable,
|
||||
}) {
|
||||
if (voiceActivityAvailable) return transmitModeSegments;
|
||||
return const [
|
||||
ButtonSegment(
|
||||
value: rust.BridgeTransmitMode.ptt,
|
||||
label: Text('PTT'),
|
||||
icon: Icon(Icons.radio_button_checked, size: 14),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: rust.BridgeTransmitMode.continuous,
|
||||
label: Text('Always'),
|
||||
icon: Icon(Icons.podcasts, size: 14),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// True when this host advertises VAD transmit per DEC-030.
|
||||
///
|
||||
/// The desktop Silero ONNX + WebRTC fallback ships on Windows and
|
||||
/// Linux; the Android Oboe + WebRTC path covers Android; iOS uses the
|
||||
/// Apple CoreML Silero VAD pipeline via `vad::apple_coreml`. macOS is
|
||||
/// still gated until its VAD pipeline is confirmed.
|
||||
bool get voiceActivityTransmitAvailable {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isWindows || Platform.isLinux || Platform.isAndroid || Platform.isIOS;
|
||||
}
|
||||
|
||||
/// Android hardware/WebRTC selector segments.
|
||||
const androidProcessingSegments = [
|
||||
ButtonSegment(
|
||||
|
||||
@@ -5,18 +5,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
|
||||
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "93.0.0"
|
||||
version: "96.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
|
||||
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.1"
|
||||
version: "10.2.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -133,10 +133,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.2.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -197,10 +197,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
version: "0.7.13"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -361,18 +361,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: haptic_kit
|
||||
sha256: "39efffa513c9f8ce3cdded8a4423797f69d71c9281779b83727337f3ee1ed9b8"
|
||||
sha256: "457f825a3413be2651954639bed27bb2987570f75d90c4e8e1cb9be62db2e59d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.0.1"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "2.0.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -509,14 +509,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -529,10 +521,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
version: "9.4.1"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -705,10 +697,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
||||
sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.23"
|
||||
version: "2.4.25"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -862,10 +854,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.30"
|
||||
version: "6.3.32"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -974,10 +966,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9
|
||||
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.0"
|
||||
version: "6.3.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -990,10 +982,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
version: "7.0.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1003,5 +995,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.11.5 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
|
||||
@@ -31,6 +31,18 @@ if [ ! -f "${BINARY}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Flutter Debug builds split user code into a sibling `<App>.debug.dylib`
|
||||
# alongside a tiny launcher executable; the @_cdecl symbols live in the
|
||||
# dylib. Release/Profile builds put everything in the main executable.
|
||||
# Prefer the dylib when both exist so the check verifies the slice that
|
||||
# actually carries the symbols.
|
||||
EXECUTABLE_DIR=$(dirname "${BINARY}")
|
||||
EXECUTABLE_NAME=$(basename "${BINARY}")
|
||||
DEBUG_DYLIB="${EXECUTABLE_DIR}/${EXECUTABLE_NAME}.debug.dylib"
|
||||
if [ -f "${DEBUG_DYLIB}" ]; then
|
||||
BINARY="${DEBUG_DYLIB}"
|
||||
fi
|
||||
|
||||
REQUIRED_SYMBOLS="
|
||||
_chanora_silero_vad_create
|
||||
_chanora_silero_vad_destroy
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/voice_join_ordering.dart';
|
||||
|
||||
void main() {
|
||||
group('joinVoiceChannelWithIosAudioSession', () {
|
||||
test('activates the iOS audio session before Rust voiceJoin', () async {
|
||||
final calls = <String>[];
|
||||
|
||||
await joinVoiceChannelWithIosAudioSession(
|
||||
channelId: BigInt.from(42),
|
||||
password: 'secret',
|
||||
activateIosAudioSession: () async {
|
||||
calls.add('activateIosAudioSession');
|
||||
},
|
||||
deactivateIosAudioSession: () async {
|
||||
calls.add('deactivateIosAudioSession');
|
||||
},
|
||||
voiceJoin: ({required channelId, required password}) async {
|
||||
expect(channelId, BigInt.from(42));
|
||||
expect(password, 'secret');
|
||||
calls.add('voiceJoin');
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls, ['activateIosAudioSession', 'voiceJoin']);
|
||||
});
|
||||
|
||||
test('deactivates the iOS audio session when Rust voiceJoin fails',
|
||||
() async {
|
||||
final calls = <String>[];
|
||||
|
||||
await expectLater(
|
||||
joinVoiceChannelWithIosAudioSession(
|
||||
channelId: BigInt.from(42),
|
||||
password: '',
|
||||
activateIosAudioSession: () async {
|
||||
calls.add('activateIosAudioSession');
|
||||
},
|
||||
deactivateIosAudioSession: () async {
|
||||
calls.add('deactivateIosAudioSession');
|
||||
},
|
||||
voiceJoin: ({required channelId, required password}) async {
|
||||
calls.add('voiceJoin');
|
||||
throw StateError('join rejected');
|
||||
},
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
|
||||
expect(calls, [
|
||||
'activateIosAudioSession',
|
||||
'voiceJoin',
|
||||
'deactivateIosAudioSession',
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
'keeps the iOS audio session active when voiceJoin throws but the '
|
||||
'error is recognised as already-in-channel (treated as success); '
|
||||
'still rethrows so the caller runs its success-on-already-joined branch',
|
||||
() async {
|
||||
final calls = <String>[];
|
||||
|
||||
await expectLater(
|
||||
joinVoiceChannelWithIosAudioSession(
|
||||
channelId: BigInt.from(42),
|
||||
password: '',
|
||||
activateIosAudioSession: () async {
|
||||
calls.add('activateIosAudioSession');
|
||||
},
|
||||
deactivateIosAudioSession: () async {
|
||||
calls.add('deactivateIosAudioSession');
|
||||
},
|
||||
voiceJoin: ({required channelId, required password}) async {
|
||||
calls.add('voiceJoin');
|
||||
throw _FakeAlreadyInChannel();
|
||||
},
|
||||
isJoinSuccess: (error) => error is _FakeAlreadyInChannel,
|
||||
),
|
||||
throwsA(isA<_FakeAlreadyInChannel>()),
|
||||
);
|
||||
|
||||
expect(calls, ['activateIosAudioSession', 'voiceJoin']);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'deactivates the iOS audio session when isJoinSuccess returns false '
|
||||
'for a non-success error',
|
||||
() async {
|
||||
final calls = <String>[];
|
||||
|
||||
await expectLater(
|
||||
joinVoiceChannelWithIosAudioSession(
|
||||
channelId: BigInt.from(42),
|
||||
password: '',
|
||||
activateIosAudioSession: () async {
|
||||
calls.add('activateIosAudioSession');
|
||||
},
|
||||
deactivateIosAudioSession: () async {
|
||||
calls.add('deactivateIosAudioSession');
|
||||
},
|
||||
voiceJoin: ({required channelId, required password}) async {
|
||||
calls.add('voiceJoin');
|
||||
throw StateError('join rejected');
|
||||
},
|
||||
isJoinSuccess: (error) => error is _FakeAlreadyInChannel,
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
|
||||
expect(calls, [
|
||||
'activateIosAudioSession',
|
||||
'voiceJoin',
|
||||
'deactivateIosAudioSession',
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeAlreadyInChannel implements Exception {}
|
||||
@@ -13,6 +13,24 @@ void main() {
|
||||
]);
|
||||
});
|
||||
|
||||
test('gated transmit mode segments drop VAD when unsupported', () {
|
||||
expect(
|
||||
transmitModeSegmentsFor(voiceActivityAvailable: false).map((s) => s.value),
|
||||
[rust.BridgeTransmitMode.ptt, rust.BridgeTransmitMode.continuous],
|
||||
);
|
||||
});
|
||||
|
||||
test('gated transmit mode segments include VAD when supported', () {
|
||||
expect(
|
||||
transmitModeSegmentsFor(voiceActivityAvailable: true).map((s) => s.value),
|
||||
[
|
||||
rust.BridgeTransmitMode.ptt,
|
||||
rust.BridgeTransmitMode.continuous,
|
||||
rust.BridgeTransmitMode.voiceActivity,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('shared Android processing segments expose hardware and WebRTC', () {
|
||||
expect(androidProcessingSegments.map((s) => s.value), [true, false]);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ repository.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chanora_cache = { path = "../../crates/chanora_cache" }
|
||||
chanora_protocol = { path = "../../crates/chanora_protocol" }
|
||||
chanora_state = { path = "../../crates/chanora_state" }
|
||||
chanora_audio = { path = "../../crates/chanora_audio" }
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chanora_cache::{BlobCache, BlobCacheError, PREFIX_AVATAR, PREFIX_ICON};
|
||||
use chanora_protocol::{ProtocolClient, ProtocolError};
|
||||
use tokio::sync::{Mutex, Semaphore, oneshot};
|
||||
use tracing::warn;
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS: usize = 2;
|
||||
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
type InFlightWaiters = Vec<oneshot::Sender<Result<Option<Vec<u8>>, FileTransferError>>>;
|
||||
|
||||
/// Errors raised while resolving protocol-owned file assets.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FileTransferError {
|
||||
/// No live protocol client is available for a download.
|
||||
#[error("not connected")]
|
||||
NotConnected,
|
||||
/// The protocol layer failed while downloading the asset.
|
||||
#[error("protocol error: {0}")]
|
||||
Protocol(#[from] ProtocolError),
|
||||
/// The blob cache failed while reading or writing the asset.
|
||||
#[error("cache error: {0}")]
|
||||
Cache(#[from] BlobCacheError),
|
||||
}
|
||||
|
||||
impl Clone for FileTransferError {
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
Self::NotConnected => Self::NotConnected,
|
||||
Self::Protocol(error) => Self::Protocol(clone_protocol_error(error)),
|
||||
Self::Cache(error) => Self::Cache(clone_blob_cache_error(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FileTransferService {
|
||||
cache: BlobCache,
|
||||
protocol: Arc<Mutex<Option<ProtocolClient>>>,
|
||||
semaphore: Arc<Semaphore>,
|
||||
in_flight: Arc<Mutex<HashMap<String, InFlightWaiters>>>,
|
||||
negative_cache: Arc<Mutex<HashMap<String, Instant>>>,
|
||||
}
|
||||
|
||||
impl FileTransferService {
|
||||
pub fn new(cache: BlobCache, protocol: Arc<Mutex<Option<ProtocolClient>>>) -> Self {
|
||||
Self {
|
||||
cache,
|
||||
protocol,
|
||||
semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_DOWNLOADS)),
|
||||
in_flight: Arc::new(Mutex::new(HashMap::new())),
|
||||
negative_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_protocol(&self, client: Option<ProtocolClient>) {
|
||||
*self.protocol.lock().await = client;
|
||||
}
|
||||
|
||||
pub async fn get_avatar(
|
||||
&self,
|
||||
avatar_hash: &str,
|
||||
client_uid: &str,
|
||||
) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||
if let Some(bytes) = self.cache.get(PREFIX_AVATAR, avatar_hash).await? {
|
||||
return Ok(Some(bytes));
|
||||
}
|
||||
|
||||
if self.is_negative_cache_hit(avatar_hash).await {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let rx = {
|
||||
let mut in_flight = self.in_flight.lock().await;
|
||||
if let Some(waiters) = in_flight.get_mut(avatar_hash) {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
waiters.push(tx);
|
||||
Some(rx)
|
||||
} else {
|
||||
in_flight.insert(avatar_hash.to_string(), Vec::new());
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(rx) = rx {
|
||||
return rx.await.unwrap_or_else(|_| {
|
||||
Err(FileTransferError::Protocol(ProtocolError::Lost(
|
||||
"coalesced avatar download waiter dropped".to_string(),
|
||||
)))
|
||||
});
|
||||
}
|
||||
|
||||
let _permit = self
|
||||
.semaphore
|
||||
.acquire()
|
||||
.await
|
||||
.expect("file transfer semaphore should stay open");
|
||||
|
||||
let result = self.do_download_avatar(avatar_hash, client_uid).await;
|
||||
self.finish_in_flight(avatar_hash, &result).await;
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn get_icon(&self, icon_id: u64) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||
let icon_key = icon_id.to_string();
|
||||
let negative_key = format!("ic_{icon_id}");
|
||||
let in_flight_key = format!("icon_{icon_id}");
|
||||
|
||||
if let Some(bytes) = self.cache.get(PREFIX_ICON, &icon_key).await? {
|
||||
return Ok(Some(bytes));
|
||||
}
|
||||
|
||||
if self.is_negative_cache_hit(&negative_key).await {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let rx = {
|
||||
let mut in_flight = self.in_flight.lock().await;
|
||||
if let Some(waiters) = in_flight.get_mut(&in_flight_key) {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
waiters.push(tx);
|
||||
Some(rx)
|
||||
} else {
|
||||
in_flight.insert(in_flight_key.clone(), Vec::new());
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(rx) = rx {
|
||||
return rx.await.unwrap_or_else(|_| {
|
||||
Err(FileTransferError::Protocol(ProtocolError::Lost(
|
||||
"coalesced icon download waiter dropped".to_string(),
|
||||
)))
|
||||
});
|
||||
}
|
||||
|
||||
let _permit = self
|
||||
.semaphore
|
||||
.acquire()
|
||||
.await
|
||||
.expect("file transfer semaphore should stay open");
|
||||
|
||||
let result = self.do_download_icon(icon_id).await;
|
||||
self.finish_in_flight(&in_flight_key, &result).await;
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn clear_cache(&self) -> Result<(), FileTransferError> {
|
||||
self.cache.clear().await?;
|
||||
self.negative_cache.lock().await.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cache_size(&self) -> Result<u64, FileTransferError> {
|
||||
Ok(self.cache.total_size().await?)
|
||||
}
|
||||
|
||||
async fn do_download_avatar(
|
||||
&self,
|
||||
avatar_hash: &str,
|
||||
client_uid: &str,
|
||||
) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||
if let Some(bytes) = self.cache.get(PREFIX_AVATAR, avatar_hash).await? {
|
||||
return Ok(Some(bytes));
|
||||
}
|
||||
|
||||
if self.is_negative_cache_hit(avatar_hash).await {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(FileTransferError::NotConnected)?;
|
||||
match client.download_avatar(client_uid).await {
|
||||
Ok(bytes) => {
|
||||
self.cache.put(PREFIX_AVATAR, avatar_hash, &bytes).await?;
|
||||
self.negative_cache.lock().await.remove(avatar_hash);
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
Err(ProtocolError::ServerRejected { .. }) => {
|
||||
self.negative_cache
|
||||
.lock()
|
||||
.await
|
||||
.insert(avatar_hash.to_string(), Instant::now() + NEGATIVE_CACHE_TTL);
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(FileTransferError::Protocol(error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_download_icon(&self, icon_id: u64) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||
let icon_key = icon_id.to_string();
|
||||
let negative_key = format!("ic_{icon_id}");
|
||||
|
||||
if let Some(bytes) = self.cache.get(PREFIX_ICON, &icon_key).await? {
|
||||
return Ok(Some(bytes));
|
||||
}
|
||||
|
||||
if self.is_negative_cache_hit(&negative_key).await {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(FileTransferError::NotConnected)?;
|
||||
match client.download_icon(icon_id).await {
|
||||
Ok(bytes) => {
|
||||
self.cache.put(PREFIX_ICON, &icon_key, &bytes).await?;
|
||||
self.negative_cache.lock().await.remove(&negative_key);
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
Err(ProtocolError::ServerRejected { .. }) => {
|
||||
self.negative_cache
|
||||
.lock()
|
||||
.await
|
||||
.insert(negative_key, Instant::now() + NEGATIVE_CACHE_TTL);
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(FileTransferError::Protocol(error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn finish_in_flight(
|
||||
&self,
|
||||
avatar_hash: &str,
|
||||
result: &Result<Option<Vec<u8>>, FileTransferError>,
|
||||
) {
|
||||
let waiters = self.in_flight.lock().await.remove(avatar_hash).unwrap_or_default();
|
||||
for waiter in waiters {
|
||||
if waiter.send(result.clone()).is_err() {
|
||||
warn!(target: "chanora_core", avatar_hash, "avatar download waiter dropped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_negative_cache_hit(&self, avatar_hash: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
let mut negative_cache = self.negative_cache.lock().await;
|
||||
match negative_cache.get(avatar_hash).copied() {
|
||||
Some(expires_at) if expires_at > now => true,
|
||||
Some(_) => {
|
||||
negative_cache.remove(avatar_hash);
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_protocol_error(error: &ProtocolError) -> ProtocolError {
|
||||
match error {
|
||||
ProtocolError::Invalid(message) => ProtocolError::Invalid(message.clone()),
|
||||
ProtocolError::DnsFailed { host, reason } => ProtocolError::DnsFailed {
|
||||
host: host.clone(),
|
||||
reason: reason.clone(),
|
||||
},
|
||||
ProtocolError::Connect(message) => ProtocolError::Connect(message.clone()),
|
||||
ProtocolError::DisconnectedEarly(message) => {
|
||||
ProtocolError::DisconnectedEarly(message.clone())
|
||||
}
|
||||
ProtocolError::Lost(message) => ProtocolError::Lost(message.clone()),
|
||||
ProtocolError::Identity(message) => ProtocolError::Identity(message.clone()),
|
||||
ProtocolError::Timeout => ProtocolError::Timeout,
|
||||
ProtocolError::ServerRejected { code, message } => ProtocolError::ServerRejected {
|
||||
code: *code,
|
||||
message: message.clone(),
|
||||
},
|
||||
ProtocolError::Backend(message) => ProtocolError::Backend(message.clone()),
|
||||
ProtocolError::FileTransfer(message) => ProtocolError::FileTransfer(message.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_blob_cache_error(error: &BlobCacheError) -> BlobCacheError {
|
||||
match error {
|
||||
BlobCacheError::Io(message) => BlobCacheError::Io(message.clone()),
|
||||
BlobCacheError::InvalidKey(message) => BlobCacheError::InvalidKey(message.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_cache_dir(name: &str) -> std::path::PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
path.push(format!("chanora-core-file-transfer-{name}-{nanos}"));
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_cached_avatar_without_connection() {
|
||||
let cache_dir = test_cache_dir("cache-hit");
|
||||
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
|
||||
cache.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"avatar")
|
||||
.await
|
||||
.unwrap();
|
||||
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
|
||||
|
||||
let avatar = service
|
||||
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(avatar, Some(b"avatar".to_vec()));
|
||||
let _ = std::fs::remove_dir_all(cache_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn negative_cache_short_circuits_not_connected() {
|
||||
let cache_dir = test_cache_dir("negative-cache");
|
||||
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
|
||||
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
|
||||
service
|
||||
.negative_cache
|
||||
.lock()
|
||||
.await
|
||||
.insert("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6".to_string(), Instant::now() + NEGATIVE_CACHE_TTL);
|
||||
|
||||
let avatar = service
|
||||
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(avatar, None);
|
||||
let _ = std::fs::remove_dir_all(cache_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_cached_icon_without_connection() {
|
||||
let cache_dir = test_cache_dir("icon-cache-hit");
|
||||
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
|
||||
cache.put(PREFIX_ICON, "12345", b"icon").await.unwrap();
|
||||
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
|
||||
|
||||
let icon = service.get_icon(12345).await.unwrap();
|
||||
|
||||
assert_eq!(icon, Some(b"icon".to_vec()));
|
||||
let _ = std::fs::remove_dir_all(cache_dir);
|
||||
}
|
||||
}
|
||||
+157
-40
@@ -53,6 +53,7 @@ use chanora_state::channel_join::{
|
||||
};
|
||||
|
||||
mod events;
|
||||
mod file_transfer;
|
||||
mod network_diagnostics;
|
||||
pub mod ptt;
|
||||
|
||||
@@ -75,6 +76,7 @@ pub use events::{
|
||||
NetworkState, PersistedPttBinding, PttDescriptorSnapshot, SessionEvent, VoiceJoinErrorCode,
|
||||
VoiceJoinSyncState,
|
||||
};
|
||||
pub use file_transfer::FileTransferError;
|
||||
use network_diagnostics::NetworkDiagnostics;
|
||||
|
||||
/// Errors that can arise during top-level orchestration.
|
||||
@@ -92,6 +94,12 @@ pub enum CoreError {
|
||||
/// Storage error.
|
||||
#[error("storage: {0}")]
|
||||
Storage(#[from] chanora_storage::StorageError),
|
||||
/// Blob-cache failure.
|
||||
#[error("cache: {0}")]
|
||||
Cache(#[from] chanora_cache::BlobCacheError),
|
||||
/// File-transfer failure.
|
||||
#[error("file transfer: {0}")]
|
||||
FileTransfer(#[from] FileTransferError),
|
||||
/// Diagnostics error.
|
||||
#[error("diagnostics: {0}")]
|
||||
Diagnostics(#[from] chanora_diagnostics::DiagnosticsError),
|
||||
@@ -130,7 +138,6 @@ struct SupervisorInner {
|
||||
}
|
||||
|
||||
struct ConnectedState {
|
||||
protocol: chanora_protocol::ProtocolClient,
|
||||
audio: Option<chanora_audio::AudioEngine>,
|
||||
/// Active PTT controller (SDD-088). Owns the platform input
|
||||
/// backend, the active binding, and the capability watch
|
||||
@@ -197,6 +204,8 @@ pub struct ChanoraSession {
|
||||
/// extension). Lives alongside the identity file. Wired by
|
||||
/// [`Self::init_storage`].
|
||||
bookmark_store: Arc<Mutex<Option<BookmarkRepository>>>,
|
||||
protocol: Arc<Mutex<Option<chanora_protocol::ProtocolClient>>>,
|
||||
file_transfer: Arc<Mutex<Option<Arc<file_transfer::FileTransferService>>>>,
|
||||
/// Invisible server-address prefetch cache. Warmed by Flutter typing
|
||||
/// but validated by Rust before Connect can reuse it.
|
||||
server_prefetch: ServerPrefetcher,
|
||||
@@ -252,6 +261,8 @@ impl ChanoraSession {
|
||||
network_tx,
|
||||
identity_store: Arc::new(Mutex::new(None)),
|
||||
bookmark_store: Arc::new(Mutex::new(None)),
|
||||
protocol: Arc::new(Mutex::new(None)),
|
||||
file_transfer: Arc::new(Mutex::new(None)),
|
||||
server_prefetch: ServerPrefetcher::new(),
|
||||
voice_selector: selector,
|
||||
release_tail,
|
||||
@@ -273,6 +284,17 @@ impl ChanoraSession {
|
||||
ConnectionEpoch(epoch)
|
||||
}
|
||||
|
||||
async fn store_protocol(&self, client: Option<chanora_protocol::ProtocolClient>) {
|
||||
// Always update the shared Arc. The FileTransferService holds
|
||||
// the same Arc, so it sees the new client automatically — no
|
||||
// separate set_protocol call needed.
|
||||
*self.protocol.lock().await = client;
|
||||
}
|
||||
|
||||
async fn take_protocol(&self) -> Option<chanora_protocol::ProtocolClient> {
|
||||
self.protocol.lock().await.take()
|
||||
}
|
||||
|
||||
/// Wire a directory-backed identity store. Called by the bridge
|
||||
/// during `bridge_init` once Flutter has resolved the platform
|
||||
/// app-private storage directory. Subsequent [`Self::connect`]
|
||||
@@ -337,6 +359,65 @@ impl ChanoraSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure the blob cache root.
|
||||
pub async fn init_cache(&self, dir: &str) -> Result<(), CoreError> {
|
||||
let cache = chanora_cache::BlobCache::new(dir, 100 * 1024 * 1024)?;
|
||||
cache.evict().await?;
|
||||
let service = Arc::new(file_transfer::FileTransferService::new(
|
||||
cache,
|
||||
self.protocol.clone(),
|
||||
));
|
||||
let mut guard = self.file_transfer.lock().await;
|
||||
*guard = Some(service);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve avatar bytes.
|
||||
pub async fn get_avatar(
|
||||
&self,
|
||||
avatar_hash: &str,
|
||||
client_uid: &str,
|
||||
) -> Result<Option<Vec<u8>>, CoreError> {
|
||||
let service = { self.file_transfer.lock().await.clone() };
|
||||
if let Some(service) = service {
|
||||
return Ok(service.get_avatar(avatar_hash, client_uid).await?);
|
||||
}
|
||||
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
Ok(Some(client.download_avatar(client_uid).await?))
|
||||
}
|
||||
|
||||
/// Resolve icon bytes.
|
||||
pub async fn get_icon(&self, icon_id: u64) -> Result<Option<Vec<u8>>, CoreError> {
|
||||
let service = { self.file_transfer.lock().await.clone() };
|
||||
if let Some(service) = service {
|
||||
return Ok(service.get_icon(icon_id).await?);
|
||||
}
|
||||
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
Ok(Some(client.download_icon(icon_id).await?))
|
||||
}
|
||||
|
||||
/// Purge cached protocol-owned assets.
|
||||
pub async fn clear_cache(&self) -> Result<(), CoreError> {
|
||||
let service = { self.file_transfer.lock().await.clone() };
|
||||
if let Some(service) = service {
|
||||
service.clear_cache().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report the configured blob-cache size.
|
||||
pub async fn cache_size(&self) -> Result<u64, CoreError> {
|
||||
let service = { self.file_transfer.lock().await.clone() };
|
||||
match service {
|
||||
Some(service) => Ok(service.cache_size().await?),
|
||||
None => Ok(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// List persisted bookmarks. Returns an empty list if the store
|
||||
/// has not been wired or has no entries.
|
||||
pub async fn list_bookmarks(&self) -> Result<Vec<Bookmark>, CoreError> {
|
||||
@@ -514,6 +595,7 @@ impl ChanoraSession {
|
||||
|
||||
let supervisor = tokio::spawn(supervisor_loop(SupervisorContext {
|
||||
state_arc: self.inner.clone(),
|
||||
protocol: self.protocol.clone(),
|
||||
events_tx: self.events_tx.clone(),
|
||||
initial_cfg: cfg.clone(),
|
||||
initial_lost_rx: lost_rx,
|
||||
@@ -568,9 +650,9 @@ impl ChanoraSession {
|
||||
}
|
||||
|
||||
spawn_event_forwarders(&client, &self.events_tx);
|
||||
self.store_protocol(Some(client)).await;
|
||||
|
||||
*guard = Some(ConnectedState {
|
||||
protocol: client,
|
||||
audio: None,
|
||||
ptt_controller: None,
|
||||
cancel_tx: Some(cancel_tx),
|
||||
@@ -631,9 +713,13 @@ impl ChanoraSession {
|
||||
|
||||
/// Return a fresh snapshot of the current server state.
|
||||
pub async fn snapshot(&self) -> Result<ServerSnapshot, CoreError> {
|
||||
let snap = {
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
client.snapshot().await?
|
||||
};
|
||||
let mut guard = self.inner.lock().await;
|
||||
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
||||
let snap = state.protocol.snapshot().await?;
|
||||
let current_channel = self
|
||||
.find_own_in(&snap)
|
||||
.await
|
||||
@@ -661,9 +747,9 @@ impl ChanoraSession {
|
||||
|
||||
/// Fetch richer profile and live connection details for one online client.
|
||||
pub async fn client_profile(&self, client_id: u64) -> Result<ClientProfile, CoreError> {
|
||||
let guard = self.inner.lock().await;
|
||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
Ok(state.protocol.client_profile(client_id).await?)
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
Ok(client.client_profile(client_id).await?)
|
||||
}
|
||||
|
||||
/// True if a connection is currently active.
|
||||
@@ -680,9 +766,9 @@ impl ChanoraSession {
|
||||
if !should_dispatch_text_message(&message, &target) {
|
||||
return Ok(());
|
||||
}
|
||||
let guard = self.inner.lock().await;
|
||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
state.protocol.send_text_message(message, target).await?;
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
client.send_text_message(message, target).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -741,11 +827,16 @@ impl ChanoraSession {
|
||||
// session permanently unable to restart audio without a
|
||||
// reconnect (the user saw "voice_in already taken" on the
|
||||
// second channel switch).
|
||||
let voice_out = state.protocol.voice_out();
|
||||
let voice_in = state
|
||||
.protocol
|
||||
.take_voice_in()
|
||||
.ok_or(CoreError::Invariant("voice_in already taken"))?;
|
||||
let (voice_out, voice_in) = {
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
(
|
||||
client.voice_out(),
|
||||
client
|
||||
.take_voice_in()
|
||||
.ok_or(CoreError::Invariant("voice_in already taken"))?,
|
||||
)
|
||||
};
|
||||
let gate = AudioTransmitGate::new(cfg.ptt_initial);
|
||||
cfg.voice_activity_selector = Some(self.voice_selector.clone());
|
||||
let new_engine = match chanora_audio::AudioEngine::start_with_gate(
|
||||
@@ -976,10 +1067,11 @@ impl ChanoraSession {
|
||||
let password_to_send = requested_password
|
||||
.clone()
|
||||
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
|
||||
state
|
||||
.protocol
|
||||
.move_to_channel(channel_id, password_to_send)
|
||||
.await?;
|
||||
{
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
client.move_to_channel(channel_id, password_to_send).await?;
|
||||
}
|
||||
if let Some(pw) = requested_password {
|
||||
state.channel_passwords.insert(channel_id, pw);
|
||||
}
|
||||
@@ -1004,7 +1096,11 @@ impl ChanoraSession {
|
||||
if let Some(muted) = output {
|
||||
state.local_output_muted = muted;
|
||||
}
|
||||
state.protocol.set_muted(input, output).await?;
|
||||
{
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
client.set_muted(input, output).await?;
|
||||
}
|
||||
if let Some(muted) = output {
|
||||
if let Some(audio) = state.audio.as_ref() {
|
||||
audio.set_output_muted(muted);
|
||||
@@ -1116,11 +1212,19 @@ impl ChanoraSession {
|
||||
/// Configure the preferred Silero ONNX VAD model path on platforms
|
||||
/// that ship the ONNX detector.
|
||||
///
|
||||
/// This does not require an active connection. Running non-iOS
|
||||
/// audio backends can observe the model-path epoch and reload on
|
||||
/// the next capture frame when Silero is selected.
|
||||
/// Set the Silero VAD model path on supported platforms.
|
||||
///
|
||||
/// This does not require an active connection. On desktop, the audio
|
||||
/// engine immediately reloads the Silero ONNX worker if one is active
|
||||
/// or if the model file is now available at the new path.
|
||||
pub async fn set_vad_model_path(&self, path: String) -> Result<(), CoreError> {
|
||||
chanora_audio::vad::set_silero_model_path(&path)?;
|
||||
let guard = self.inner.lock().await;
|
||||
if let Some(state) = guard.as_ref() {
|
||||
if let Some(audio) = state.audio.as_ref() {
|
||||
audio.reload_audio_processing_config()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1319,11 +1423,12 @@ impl ChanoraSession {
|
||||
let password_to_send = requested_password
|
||||
.clone()
|
||||
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
|
||||
if let Err(e) = state
|
||||
.protocol
|
||||
.queue_move_to_channel(channel_id, password_to_send)
|
||||
.await
|
||||
{
|
||||
let move_result = {
|
||||
let protocol = self.protocol.lock().await;
|
||||
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
client.queue_move_to_channel(channel_id, password_to_send).await
|
||||
};
|
||||
if let Err(e) = move_result {
|
||||
// TS3 error 0x0302 = `channel_already_in`: we're already
|
||||
// in the target channel, so this is a no-op success.
|
||||
// Rolling `in_channel` back to false would break PTT
|
||||
@@ -1585,7 +1690,9 @@ impl ChanoraSession {
|
||||
audio.stop();
|
||||
let _ = self.events_tx.send(SessionEvent::AudioStopped);
|
||||
}
|
||||
state.protocol.disconnect().await;
|
||||
if let Some(protocol) = self.take_protocol().await {
|
||||
protocol.disconnect().await;
|
||||
}
|
||||
// Wait for the supervisor to wind down so we don't race
|
||||
// a redial against the explicit disconnect.
|
||||
if let Some(handle) = state.supervisor.take() {
|
||||
@@ -1647,6 +1754,7 @@ async fn await_supervisor_shutdown(mut handle: JoinHandle<()>, timeout_duration:
|
||||
|
||||
struct SupervisorContext {
|
||||
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
||||
protocol: Arc<Mutex<Option<chanora_protocol::ProtocolClient>>>,
|
||||
events_tx: broadcast::Sender<SessionEvent>,
|
||||
initial_cfg: ConnectConfig,
|
||||
initial_lost_rx: oneshot::Receiver<chanora_protocol::DisconnectReason>,
|
||||
@@ -1781,6 +1889,7 @@ fn spawn_event_forwarders(
|
||||
async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
let SupervisorContext {
|
||||
state_arc,
|
||||
protocol,
|
||||
events_tx,
|
||||
initial_cfg,
|
||||
initial_lost_rx,
|
||||
@@ -2050,20 +2159,21 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
|
||||
// Reattach into the session state.
|
||||
let restart_audio = {
|
||||
let guard = state_arc.lock().await;
|
||||
if guard.is_none() {
|
||||
// Session was disposed mid-reconnect.
|
||||
return;
|
||||
}
|
||||
drop(guard);
|
||||
let old = protocol.lock().await.replace(new_client);
|
||||
drop(old);
|
||||
let mut guard = state_arc.lock().await;
|
||||
let state = match guard.as_mut() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
// Session was disposed mid-reconnect.
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Replace the dead protocol client with the new one.
|
||||
// The old client's background task either already
|
||||
// exited (loss notifier fired) or will exit when
|
||||
// its request channel drops (watchdog path).
|
||||
let old = std::mem::replace(&mut state.protocol, new_client);
|
||||
drop(old);
|
||||
|
||||
let _ = channel_join::reduce(
|
||||
&mut state.join_state,
|
||||
@@ -2093,9 +2203,9 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
});
|
||||
|
||||
{
|
||||
let guard = state_arc.lock().await;
|
||||
if let Some(state) = guard.as_ref() {
|
||||
spawn_event_forwarders(&state.protocol, &events_tx);
|
||||
let protocol = protocol.lock().await;
|
||||
if let Some(client) = protocol.as_ref() {
|
||||
spawn_event_forwarders(client, &events_tx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2107,8 +2217,15 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
};
|
||||
let mut guard = state_arc.lock().await;
|
||||
if let Some(state) = guard.as_mut() {
|
||||
let voice_out = state.protocol.voice_out();
|
||||
if let Some(voice_in) = state.protocol.take_voice_in() {
|
||||
let (voice_out, voice_in) = {
|
||||
let protocol = protocol.lock().await;
|
||||
let client = match protocol.as_ref() {
|
||||
Some(client) => client,
|
||||
None => return,
|
||||
};
|
||||
(client.voice_out(), client.take_voice_in())
|
||||
};
|
||||
if let Some(voice_in) = voice_in {
|
||||
let gate = chanora_audio::AudioTransmitGate::new(
|
||||
audio_cfg.ptt_initial,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_avatar_returns_cached_bytes_without_connection() {
|
||||
let tmp = mktemp("chanora_core_avatar_cache_test");
|
||||
let session = chanora_core::ChanoraSession::new();
|
||||
session.init_cache(tmp.to_str().unwrap()).await.unwrap();
|
||||
|
||||
let cache = chanora_cache::BlobCache::new(&tmp, 100 * 1024 * 1024).unwrap();
|
||||
let hash = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
|
||||
cache
|
||||
.put(chanora_cache::PREFIX_AVATAR, hash, b"avatar-bytes")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let bytes = session
|
||||
.get_avatar(hash, "client-uid")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(bytes, b"avatar-bytes");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_avatar_without_cache_or_connection_returns_not_connected() {
|
||||
let session = chanora_core::ChanoraSession::new();
|
||||
let err = session
|
||||
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client-uid")
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, chanora_core::CoreError::NotConnected));
|
||||
}
|
||||
|
||||
fn mktemp(label: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let p = env::temp_dir()
|
||||
.join(label)
|
||||
.join(format!("{}-{nanos}", process::id()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
@@ -51,7 +51,7 @@ coreaudio-rs = "0.14"
|
||||
# on the main queue to avoid the VPIO RPC timeout on iOS simulator.
|
||||
dispatch2 = "0.3"
|
||||
|
||||
[target.'cfg(not(target_os = "ios"))'.dependencies]
|
||||
[target.'cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))'.dependencies]
|
||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "api-24"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
|
||||
@@ -375,38 +375,20 @@ impl AndroidCaptureState {
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
match crate::vad::callback_vad_worker_policy(
|
||||
voice_activity_mode,
|
||||
vad_backend,
|
||||
self.silero_vad_worker.is_some(),
|
||||
) {
|
||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
||||
let worker = self
|
||||
.silero_vad_worker
|
||||
.as_ref()
|
||||
.expect("policy checked worker");
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
}
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
if let Some(worker) = self.silero_vad_worker.as_ref() {
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
}
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
@@ -414,10 +396,13 @@ impl AndroidCaptureState {
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
|
||||
@@ -56,10 +56,8 @@ impl AudioRoute {
|
||||
/// iOS voice-processing mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IosVoiceProcessingMode {
|
||||
/// Shipping default: Apple VoiceProcessingIO owns AEC/NS/AGC.
|
||||
/// Apple VoiceProcessingIO owns AEC/NS/AGC.
|
||||
PlatformVoiceProcessing,
|
||||
/// Experimental raw capture-processing path.
|
||||
SonoraExperimental,
|
||||
}
|
||||
|
||||
/// Processing backend selected by policy/config.
|
||||
@@ -195,28 +193,20 @@ impl AudioProcessingConfig {
|
||||
"bluetooth_a2dp is output-only and cannot transmit duplex voice".to_string(),
|
||||
));
|
||||
}
|
||||
if self.ios_mode == IosVoiceProcessingMode::PlatformVoiceProcessing
|
||||
&& (self.processing_backend == AudioBackend::Sonora
|
||||
|| self.processing_backend == AudioBackend::WebrtcApm
|
||||
|| self.aec == EffectOwner::Sonora
|
||||
|| self.aec == EffectOwner::WebrtcApm
|
||||
|| self.ns == EffectOwner::Sonora
|
||||
|| self.ns == EffectOwner::WebrtcApm
|
||||
|| self.agc == EffectOwner::Sonora
|
||||
|| self.agc == EffectOwner::WebrtcApm)
|
||||
if self.processing_backend == AudioBackend::Sonora
|
||||
|| self.processing_backend == AudioBackend::WebrtcApm
|
||||
|| self.aec == EffectOwner::Sonora
|
||||
|| self.aec == EffectOwner::WebrtcApm
|
||||
|| self.ns == EffectOwner::Sonora
|
||||
|| self.ns == EffectOwner::WebrtcApm
|
||||
|| self.agc == EffectOwner::Sonora
|
||||
|| self.agc == EffectOwner::WebrtcApm
|
||||
{
|
||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||
"software audio processing cannot be enabled with iOS VoiceProcessingIO"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental
|
||||
&& self.processing_backend != AudioBackend::WebrtcApm
|
||||
{
|
||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||
"ios raw processing mode requires the WebRTC APM backend".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -262,34 +252,6 @@ mod tests {
|
||||
assert!(config.validate_for_ios().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_processing_allows_full_webrtc_apm_chain() {
|
||||
let config = AudioProcessingConfig {
|
||||
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
|
||||
processing_backend: AudioBackend::WebrtcApm,
|
||||
aec: EffectOwner::WebrtcApm,
|
||||
ns: EffectOwner::WebrtcApm,
|
||||
agc: EffectOwner::WebrtcApm,
|
||||
..AudioProcessingConfig::default()
|
||||
};
|
||||
|
||||
assert!(config.validate_for_ios().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_processing_rejects_non_webrtc_apm_backend() {
|
||||
let config = AudioProcessingConfig {
|
||||
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
|
||||
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
||||
aec: EffectOwner::WebrtcApm,
|
||||
ns: EffectOwner::WebrtcApm,
|
||||
agc: EffectOwner::WebrtcApm,
|
||||
..AudioProcessingConfig::default()
|
||||
};
|
||||
|
||||
assert!(config.validate_for_ios().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disable_failed_vad_backend_demotes_to_webrtc() {
|
||||
let mut config = AudioProcessingConfig {
|
||||
@@ -404,10 +366,8 @@ impl Default for SharedAudioProcessingStats {
|
||||
}
|
||||
|
||||
impl SharedAudioProcessingStats {
|
||||
/// Store the raw input dBFS level (desktop capture path).
|
||||
/// Mobile platforms use [`Self::update_capture`] instead, which
|
||||
/// also records VAD state; this lighter method is for the cpal
|
||||
/// capture path that has no VAD pipeline.
|
||||
/// Store the raw input dBFS level for capture paths that do not
|
||||
/// update the full processing/VAD snapshot on this callback.
|
||||
pub fn set_input_dbfs(&self, dbfs: f32) {
|
||||
self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -314,15 +314,6 @@ mod tests {
|
||||
rec.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ios_raw_debug_wav_does_not_push_from_realtime_callback() {
|
||||
let src = include_str!("ios_raw_unit.rs");
|
||||
assert!(
|
||||
!src.contains("push_raw_mic") && !src.contains("push_processed_mic"),
|
||||
"ios raw callbacks must not call WavDebugRecorder push_*_mic until it has a preallocated handoff"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_header_is_44_bytes() {
|
||||
// Write to a temp file to test the header.
|
||||
|
||||
@@ -307,6 +307,8 @@ pub struct AudioEngine {
|
||||
output_muted: Arc<AtomicBool>,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||
#[cfg(not(target_os = "android"))]
|
||||
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -393,8 +395,6 @@ unsafe impl Sync for AudioEngine {}
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
enum IosVoiceBackend {
|
||||
Vpio(crate::ios_voice_unit::IosVoiceUnit),
|
||||
#[cfg(target_os = "ios")]
|
||||
Raw(crate::ios_raw_unit::IosRawUnit),
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
@@ -404,14 +404,12 @@ impl IosVoiceBackend {
|
||||
{
|
||||
match self {
|
||||
Self::Vpio(unit) => unit.restart(),
|
||||
Self::Raw(unit) => unit.restart(),
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
match self {
|
||||
Self::Vpio(_unit) => Ok(()),
|
||||
}
|
||||
let _ = self;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,14 +418,12 @@ impl IosVoiceBackend {
|
||||
{
|
||||
match self {
|
||||
Self::Vpio(unit) => unit.pause(),
|
||||
Self::Raw(unit) => unit.pause(),
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
match self {
|
||||
Self::Vpio(_unit) => Ok(()),
|
||||
}
|
||||
let _ = self;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,14 +432,12 @@ impl IosVoiceBackend {
|
||||
{
|
||||
match self {
|
||||
Self::Vpio(unit) => unit.resume(),
|
||||
Self::Raw(unit) => unit.resume(),
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
match self {
|
||||
Self::Vpio(_unit) => Ok(()),
|
||||
}
|
||||
let _ = self;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,27 +446,6 @@ impl IosVoiceBackend {
|
||||
fn open_ios_voice_backend(
|
||||
params: crate::mobile_voice_backend::VoiceAudioParams,
|
||||
) -> Result<IosVoiceBackend, AudioError> {
|
||||
let _cfg = params.audio_processing_config.lock().unwrap().clone();
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
if _cfg.ios_mode == crate::IosVoiceProcessingMode::SonoraExperimental {
|
||||
let raw_params = params.clone();
|
||||
match crate::ios_raw_unit::IosRawUnit::start(raw_params) {
|
||||
Ok(unit) => {
|
||||
info!(target: "chanora_audio", "ios: RemoteIO/WebRTC APM backend selected");
|
||||
return Ok(IosVoiceBackend::Raw(unit));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"ios: RemoteIO/WebRTC APM backend failed; falling back to VoiceProcessingIO"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unit = crate::ios_voice_unit::IosVoiceUnit::start(params)?;
|
||||
Ok(IosVoiceBackend::Vpio(unit))
|
||||
}
|
||||
@@ -741,7 +714,7 @@ impl AudioEngine {
|
||||
) -> Option<cpal::Device>
|
||||
where
|
||||
DefaultFn: Fn(&cpal::Host) -> Option<cpal::Device>,
|
||||
AllFn: Fn(&cpal::Host) -> Result<Devices, cpal::DevicesError>,
|
||||
AllFn: Fn(&cpal::Host) -> Result<Devices, cpal::Error>,
|
||||
Devices: IntoIterator<Item = cpal::Device>,
|
||||
{
|
||||
if let Some(id) = prefer {
|
||||
@@ -823,8 +796,8 @@ impl AudioEngine {
|
||||
let frames_received = Arc::new(AtomicU32::new(0));
|
||||
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
||||
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 (audio_processing_config, audio_processing_stats, silero_vad_worker) =
|
||||
new_desktop_audio_processing_state();
|
||||
|
||||
// ---------- Capture ----------
|
||||
// Capture is best-effort. If the platform default input
|
||||
@@ -838,6 +811,9 @@ impl AudioEngine {
|
||||
transmit_flag_for_capture,
|
||||
frames_sent.clone(),
|
||||
cfg.mic_gain,
|
||||
cfg.voice_activity_selector.clone(),
|
||||
audio_processing_config.clone(),
|
||||
silero_vad_worker.clone(),
|
||||
audio_processing_stats.clone(),
|
||||
);
|
||||
let (input_stream, capture_active) = match capture_result {
|
||||
@@ -981,11 +957,13 @@ impl AudioEngine {
|
||||
|
||||
Ok(Self {
|
||||
transmit_gate,
|
||||
frames_sent,
|
||||
frames_received,
|
||||
output_gain,
|
||||
output_muted,
|
||||
audio_processing_config,
|
||||
audio_processing_stats,
|
||||
silero_vad_worker,
|
||||
audio_handler,
|
||||
_input_stream: Mutex::new(input_stream),
|
||||
_output_stream: Mutex::new(Some(output_stream)),
|
||||
@@ -1667,15 +1645,40 @@ impl AudioEngine {
|
||||
/// Apply a voice-processing config after validating iOS invariants.
|
||||
pub fn set_audio_processing_config(
|
||||
&self,
|
||||
config: crate::AudioProcessingConfig,
|
||||
mut config: crate::AudioProcessingConfig,
|
||||
) -> Result<(), AudioError> {
|
||||
#[cfg(target_os = "ios")]
|
||||
config.validate_for_ios()?;
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
{
|
||||
config.processing_backend = crate::AudioBackend::Noop;
|
||||
}
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
self.apply_desktop_vad_backend(&config);
|
||||
let mut guard = self.audio_processing_config.lock().unwrap();
|
||||
*guard = config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-apply the current audio processing config.
|
||||
///
|
||||
/// Used by the core layer to trigger VAD worker reload after a
|
||||
/// model-path change (the epoch increments but the worker is only
|
||||
/// reconstructed when `set_audio_processing_config` is called).
|
||||
pub fn reload_audio_processing_config(&self) -> Result<(), AudioError> {
|
||||
let config = self.audio_processing_config_snapshot();
|
||||
self.set_audio_processing_config(config)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
fn apply_desktop_vad_backend(&self, config: &crate::AudioProcessingConfig) {
|
||||
apply_desktop_vad_backend_to_worker(
|
||||
config,
|
||||
&self.silero_vad_worker,
|
||||
self.audio_processing_stats.as_ref(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Current voice-processing stats snapshot.
|
||||
pub fn audio_processing_stats(&self) -> crate::AudioProcessingStats {
|
||||
let config = self.audio_processing_config.lock().unwrap().clone();
|
||||
@@ -1777,10 +1780,369 @@ fn release_android_audio_mode_for_startup_rollback(
|
||||
audio_mode_stack.release()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
fn new_desktop_audio_processing_state() -> (
|
||||
Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
Arc<crate::SharedAudioProcessingStats>,
|
||||
Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||
) {
|
||||
let mut config = crate::AudioProcessingConfig::default();
|
||||
// Desktop cpal capture does not use a platform voice-processing API.
|
||||
// The global default (PlatformVoiceProcessing) is correct for iOS/macOS
|
||||
// VPIO but would mislabel the desktop path in bridge diagnostics and
|
||||
// set `platform_voice_processing_enabled = true` when no such
|
||||
// processing exists. Override to Noop; the bridge/UI stats layer
|
||||
// will then report the accurate backend.
|
||||
config.processing_backend = crate::AudioBackend::Noop;
|
||||
let audio_processing_config = Arc::new(Mutex::new(config.clone()));
|
||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
let silero_vad_worker = Arc::new(Mutex::new(None));
|
||||
apply_desktop_vad_backend_to_worker(
|
||||
&config,
|
||||
&silero_vad_worker,
|
||||
audio_processing_stats.as_ref(),
|
||||
);
|
||||
(
|
||||
audio_processing_config,
|
||||
audio_processing_stats,
|
||||
silero_vad_worker,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
fn apply_desktop_vad_backend_to_worker(
|
||||
config: &crate::AudioProcessingConfig,
|
||||
silero_vad_worker: &Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||
audio_processing_stats: &crate::SharedAudioProcessingStats,
|
||||
) {
|
||||
if config.vad_backend != crate::VadBackend::SileroOnnx {
|
||||
let mut worker_guard = silero_vad_worker.lock().unwrap();
|
||||
if worker_guard.is_some() {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
backend = config.vad_backend.as_str(),
|
||||
"desktop: Silero ONNX VAD worker cleared because another VAD backend is selected"
|
||||
);
|
||||
}
|
||||
*worker_guard = None;
|
||||
audio_processing_stats.set_vad_fallback_active(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load model and spawn worker BEFORE taking the lock so the
|
||||
// realtime capture callback is not blocked on try_lock() during
|
||||
// model I/O + thread spawn. The old worker (if any) is dropped
|
||||
// after the new one is installed under the short lock hold.
|
||||
let model_path = crate::vad::silero_model_bundle_path();
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
path = %model_path,
|
||||
"desktop: Silero ONNX VAD selected; loading model worker"
|
||||
);
|
||||
let new_worker = crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&model_path);
|
||||
{
|
||||
let mut worker_guard = silero_vad_worker.lock().unwrap();
|
||||
*worker_guard = new_worker;
|
||||
}
|
||||
// Check result after releasing the lock. Re-acquire is cheap and
|
||||
// ensures we log the correct state without holding the mutex.
|
||||
let worker_installed = silero_vad_worker.lock().unwrap().is_some();
|
||||
if worker_installed {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
path = %model_path,
|
||||
"desktop: Silero ONNX VAD worker loaded"
|
||||
);
|
||||
audio_processing_stats.set_vad_fallback_active(false);
|
||||
} else {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
path = %model_path,
|
||||
"desktop: Silero ONNX VAD worker unavailable; WebRTC fallback will be used"
|
||||
);
|
||||
audio_processing_stats.set_vad_fallback_active(true);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
#[test]
|
||||
fn desktop_capture_voice_activity_opens_selector_from_speech() {
|
||||
let gate = crate::ptt::AudioTransmitGate::new(false);
|
||||
let selector = Arc::new(crate::TransmitModeSelector::new(gate.clone()));
|
||||
selector.set_mode(crate::TransmitMode::VoiceActivity);
|
||||
selector.set_in_channel(true);
|
||||
|
||||
let encoder = crate::opus_voice::new_voip_encoder("desktop VAD test").unwrap();
|
||||
let (voice_out_tx, _voice_out_rx) = mpsc::channel::<OutPacket>(16);
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
let voice_out_tx = crate::opus_voice::start_out_packet_worker(
|
||||
voice_out_tx,
|
||||
frames_sent,
|
||||
"desktop-vad-test",
|
||||
)
|
||||
.unwrap();
|
||||
let stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
let mut capture = CaptureState::new(
|
||||
encoder,
|
||||
SAMPLE_RATE,
|
||||
1,
|
||||
1.0,
|
||||
voice_out_tx,
|
||||
gate.flag_arc(),
|
||||
Some(selector.clone()),
|
||||
Arc::new(Mutex::new(crate::AudioProcessingConfig {
|
||||
vad_backend: crate::VadBackend::WebrtcVad,
|
||||
..crate::AudioProcessingConfig::default()
|
||||
})),
|
||||
Arc::new(Mutex::new(None)),
|
||||
stats.clone(),
|
||||
);
|
||||
|
||||
let mut voiced = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
for (idx, sample) in voiced.iter_mut().enumerate() {
|
||||
let phase = idx as f32 * 2.0 * std::f32::consts::PI * 220.0 / SAMPLE_RATE as f32;
|
||||
*sample = phase.sin() * 0.4;
|
||||
}
|
||||
for _ in 0..6 {
|
||||
capture.ingest(&voiced);
|
||||
}
|
||||
|
||||
assert!(
|
||||
selector.voice_activity_open(),
|
||||
"desktop capture must feed VAD and open VoiceActivity selector before transmit is already active"
|
||||
);
|
||||
assert!(
|
||||
gate.load(),
|
||||
"VoiceActivity selector should publish transmit gate"
|
||||
);
|
||||
let snapshot = stats.snapshot(&crate::AudioProcessingConfig::default());
|
||||
assert!(snapshot.vad_active, "stats should expose active VAD");
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
#[test]
|
||||
fn desktop_startup_audio_processing_state_applies_default_silero_fallback() {
|
||||
let _guard = crate::vad::SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap();
|
||||
crate::vad::clear_silero_model_path_for_test();
|
||||
|
||||
let (config, stats, worker) = new_desktop_audio_processing_state();
|
||||
|
||||
assert_eq!(
|
||||
config.lock().unwrap().vad_backend,
|
||||
crate::VadBackend::SileroOnnx,
|
||||
"desktop startup config should keep the default Silero backend selected"
|
||||
);
|
||||
assert!(
|
||||
worker.lock().unwrap().is_none(),
|
||||
"missing startup model should not create an ONNX worker"
|
||||
);
|
||||
let snapshot = stats.snapshot(&config.lock().unwrap());
|
||||
assert!(
|
||||
snapshot.vad_fallback_active,
|
||||
"desktop startup should mark WebRTC fallback active when the default Silero worker cannot load"
|
||||
);
|
||||
crate::vad::clear_silero_model_path_for_test();
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
#[test]
|
||||
fn desktop_set_audio_processing_config_normalizes_default_backend_to_noop() {
|
||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
let engine = AudioEngine {
|
||||
transmit_gate: crate::ptt::AudioTransmitGate::new(false),
|
||||
frames_sent: Arc::new(AtomicU32::new(0)),
|
||||
frames_received: Arc::new(AtomicU32::new(0)),
|
||||
output_gain: Arc::new(AtomicU32::new(1.0_f32.to_bits())),
|
||||
output_muted: Arc::new(AtomicBool::new(false)),
|
||||
audio_processing_config: audio_processing_config.clone(),
|
||||
audio_processing_stats,
|
||||
silero_vad_worker: Arc::new(Mutex::new(None)),
|
||||
audio_handler: Arc::new(Mutex::new(AudioHandler::new())),
|
||||
_input_stream: Mutex::new(None),
|
||||
_output_stream: Mutex::new(None),
|
||||
shutdown_tx: None,
|
||||
capture_active: false,
|
||||
};
|
||||
|
||||
engine
|
||||
.set_audio_processing_config(crate::AudioProcessingConfig::default())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
engine.audio_processing_config_snapshot().processing_backend,
|
||||
crate::AudioBackend::Noop,
|
||||
"desktop setter should report the actual no-op processing backend for default configs"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
#[test]
|
||||
fn desktop_capture_silero_selection_reports_fallback_when_worker_unavailable() {
|
||||
let _guard = crate::vad::SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap();
|
||||
crate::vad::clear_silero_model_path_for_test();
|
||||
|
||||
let gate = crate::ptt::AudioTransmitGate::new(false);
|
||||
let selector = Arc::new(crate::TransmitModeSelector::new(gate.clone()));
|
||||
selector.set_mode(crate::TransmitMode::VoiceActivity);
|
||||
selector.set_in_channel(true);
|
||||
|
||||
let encoder = crate::opus_voice::new_voip_encoder("desktop Silero fallback test").unwrap();
|
||||
let (voice_out_tx, _voice_out_rx) = mpsc::channel::<OutPacket>(16);
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
let voice_out_tx = crate::opus_voice::start_out_packet_worker(
|
||||
voice_out_tx,
|
||||
frames_sent,
|
||||
"desktop-silero-fallback-test",
|
||||
)
|
||||
.unwrap();
|
||||
let stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
let config = Arc::new(Mutex::new(crate::AudioProcessingConfig {
|
||||
vad_backend: crate::VadBackend::SileroOnnx,
|
||||
..crate::AudioProcessingConfig::default()
|
||||
}));
|
||||
let mut capture = CaptureState::new(
|
||||
encoder,
|
||||
SAMPLE_RATE,
|
||||
1,
|
||||
1.0,
|
||||
voice_out_tx,
|
||||
gate.flag_arc(),
|
||||
Some(selector),
|
||||
config.clone(),
|
||||
Arc::new(Mutex::new(None)),
|
||||
stats.clone(),
|
||||
);
|
||||
|
||||
let mut voiced = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
for (idx, sample) in voiced.iter_mut().enumerate() {
|
||||
let phase = idx as f32 * 2.0 * std::f32::consts::PI * 220.0 / SAMPLE_RATE as f32;
|
||||
*sample = phase.sin() * 0.4;
|
||||
}
|
||||
capture.ingest(&voiced);
|
||||
|
||||
let snapshot = stats.snapshot(&config.lock().unwrap());
|
||||
assert_eq!(snapshot.vad_backend, crate::VadBackend::SileroOnnx);
|
||||
assert!(
|
||||
snapshot.vad_fallback_active,
|
||||
"desktop Silero selection should make WebRTC fallback visible when ONNX worker cannot load"
|
||||
);
|
||||
crate::vad::clear_silero_model_path_for_test();
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
#[test]
|
||||
fn desktop_capture_vad_consumes_only_new_pcm_while_transmitting() {
|
||||
let gate = crate::ptt::AudioTransmitGate::new(true);
|
||||
let selector = Arc::new(crate::TransmitModeSelector::new(gate.clone()));
|
||||
selector.set_mode(crate::TransmitMode::VoiceActivity);
|
||||
selector.set_in_channel(true);
|
||||
|
||||
let encoder = crate::opus_voice::new_voip_encoder("desktop VAD duplicate test").unwrap();
|
||||
let (voice_out_tx, _voice_out_rx) = mpsc::channel::<OutPacket>(16);
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
let voice_out_tx = crate::opus_voice::start_out_packet_worker(
|
||||
voice_out_tx,
|
||||
frames_sent,
|
||||
"desktop-vad-duplicate-test",
|
||||
)
|
||||
.unwrap();
|
||||
let stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
let mut capture = CaptureState::new(
|
||||
encoder,
|
||||
SAMPLE_RATE,
|
||||
1,
|
||||
1.0,
|
||||
voice_out_tx,
|
||||
gate.flag_arc(),
|
||||
Some(selector),
|
||||
Arc::new(Mutex::new(crate::AudioProcessingConfig {
|
||||
vad_backend: crate::VadBackend::Disabled,
|
||||
..crate::AudioProcessingConfig::default()
|
||||
})),
|
||||
Arc::new(Mutex::new(None)),
|
||||
stats,
|
||||
);
|
||||
|
||||
capture.pcm_accum.extend(std::iter::repeat_n(
|
||||
0.1_f32,
|
||||
crate::frame::FRAME_10MS_SAMPLES + 240,
|
||||
));
|
||||
capture.pending_10ms[..240].fill(0.1);
|
||||
capture.pending_10ms_len = 240;
|
||||
capture.capture_frame_seq = 1;
|
||||
capture.pcm_accum.extend(std::iter::repeat_n(0.2_f32, 240));
|
||||
|
||||
capture.process_pending_vad_frames(crate::frame::FRAME_10MS_SAMPLES + 240);
|
||||
|
||||
assert_eq!(
|
||||
capture.capture_frame_seq, 2,
|
||||
"VAD should consume only the 240 samples appended by the current ingest and complete one pending 10 ms frame"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
#[test]
|
||||
fn desktop_capture_silero_stale_enqueued_worker_uses_fallback() {
|
||||
let gate = crate::ptt::AudioTransmitGate::new(false);
|
||||
let selector = Arc::new(crate::TransmitModeSelector::new(gate.clone()));
|
||||
selector.set_mode(crate::TransmitMode::VoiceActivity);
|
||||
selector.set_in_channel(true);
|
||||
|
||||
let encoder =
|
||||
crate::opus_voice::new_voip_encoder("desktop stale Silero fallback test").unwrap();
|
||||
let (voice_out_tx, _voice_out_rx) = mpsc::channel::<OutPacket>(16);
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
let voice_out_tx = crate::opus_voice::start_out_packet_worker(
|
||||
voice_out_tx,
|
||||
frames_sent,
|
||||
"desktop-stale-silero-fallback-test",
|
||||
)
|
||||
.unwrap();
|
||||
let stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
let config = Arc::new(Mutex::new(crate::AudioProcessingConfig {
|
||||
vad_backend: crate::VadBackend::SileroOnnx,
|
||||
..crate::AudioProcessingConfig::default()
|
||||
}));
|
||||
let worker = Arc::new(Mutex::new(Some(
|
||||
crate::vad::silero_onnx::SileroOnnxVadWorker::stale_test_worker(),
|
||||
)));
|
||||
let mut capture = CaptureState::new(
|
||||
encoder,
|
||||
SAMPLE_RATE,
|
||||
1,
|
||||
1.0,
|
||||
voice_out_tx,
|
||||
gate.flag_arc(),
|
||||
Some(selector),
|
||||
config.clone(),
|
||||
worker,
|
||||
stats.clone(),
|
||||
);
|
||||
|
||||
let mut voiced = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
for (idx, sample) in voiced.iter_mut().enumerate() {
|
||||
let phase = idx as f32 * 2.0 * std::f32::consts::PI * 220.0 / SAMPLE_RATE as f32;
|
||||
*sample = phase.sin() * 0.4;
|
||||
}
|
||||
|
||||
capture.process_10ms_capture_frame(&voiced);
|
||||
|
||||
let snapshot = stats.snapshot(&config.lock().unwrap());
|
||||
assert!(
|
||||
snapshot.vad_fallback_active,
|
||||
"stale Silero worker output should report active WebRTC fallback"
|
||||
);
|
||||
assert!(
|
||||
snapshot.vad_probability > 0.5,
|
||||
"stale Silero worker output should use WebRTC fallback probability instead of forced silence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn android_startup_rollback_releases_acquired_mode_snapshot() {
|
||||
let mut stack = crate::mode_stack::ModeStack::new();
|
||||
@@ -1806,6 +2168,9 @@ fn try_open_capture(
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Result<cpal::Stream, AudioError> {
|
||||
let in_cfg = in_dev
|
||||
@@ -1845,6 +2210,9 @@ fn try_open_capture(
|
||||
"cpal-capture",
|
||||
)?,
|
||||
transmit_active,
|
||||
voice_activity_selector,
|
||||
audio_processing_config,
|
||||
silero_vad_worker,
|
||||
audio_processing_stats,
|
||||
)));
|
||||
|
||||
@@ -1883,6 +2251,17 @@ struct CaptureState {
|
||||
/// The PTT transmission gate. Read once per outbound frame; the
|
||||
/// CaptureState never mutates this flag.
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||
silero_model_epoch: u64,
|
||||
current_vad_backend: crate::VadBackend,
|
||||
fallback_warned_backend: Option<crate::VadBackend>,
|
||||
capture_frame_seq: u64,
|
||||
vad_state: crate::voice_activity::VoiceActivityStateMachine,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
pending_10ms: [f32; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: usize,
|
||||
/// Pre-allocated mono downmix buffer. Resized in-place each
|
||||
/// callback; `clear()` retains capacity. SDD-094 realtime-thread
|
||||
/// invariant: this avoids the heap allocation that the prior fix
|
||||
@@ -1926,6 +2305,9 @@ impl CaptureState {
|
||||
mic_gain: f32,
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -1939,6 +2321,17 @@ impl CaptureState {
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
voice_activity_selector,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||
silero_vad_worker,
|
||||
silero_model_epoch: crate::vad::silero_model_epoch(),
|
||||
current_vad_backend: crate::VadBackend::Disabled,
|
||||
fallback_warned_backend: None,
|
||||
capture_frame_seq: 0,
|
||||
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
|
||||
audio_processing_config,
|
||||
pending_10ms: [0.0; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: 0,
|
||||
mono_scratch: Vec::with_capacity(4096),
|
||||
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
|
||||
audio_processing_stats,
|
||||
@@ -1952,6 +2345,16 @@ impl CaptureState {
|
||||
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
|
||||
/// 48 kHz mono frames; encode and send when `transmit_active`
|
||||
/// is true (PTT engaged).
|
||||
// TODO(realtime-audio): This method runs on the cpal audio callback
|
||||
// thread with a ~10 ms deadline. Known pre-existing violations of the
|
||||
// realtime safety constraint that should be addressed in a future
|
||||
// iteration:
|
||||
// 1. Blocking Mutex::lock().unwrap() on self (via cpal callback)
|
||||
// 2. Potential allocation in mono_scratch.reserve() and
|
||||
// pcm_accum.extend_from_slice() when buffer capacity is exceeded
|
||||
// 3. Encode-path warn!/error! logging via send_voip_frame closures
|
||||
// These were present before the VAD integration and are not
|
||||
// introduced by this changeset.
|
||||
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
||||
// 1. Down-mix to mono (pre-gain). Always performed so the level
|
||||
// meter reflects real mic input even when PTT is released.
|
||||
@@ -1981,15 +2384,11 @@ impl CaptureState {
|
||||
}
|
||||
}
|
||||
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
self.pcm_accum.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Resample to 48 kHz if needed. We re-borrow
|
||||
// `mono_scratch` as a shared slice per branch to satisfy
|
||||
// the borrow checker against `&mut self` on the
|
||||
// resample path.
|
||||
let vad_start_offset = self.pcm_accum.len();
|
||||
if self.in_sample_rate == SAMPLE_RATE {
|
||||
// Disjoint-borrow: copy the slice into pcm_accum without
|
||||
// aliasing &mut self.
|
||||
@@ -2008,6 +2407,13 @@ impl CaptureState {
|
||||
self.mono_scratch = mono;
|
||||
}
|
||||
|
||||
self.process_pending_vad_frames(vad_start_offset);
|
||||
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
self.pcm_accum.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Encode any complete frames. Clamp each sample to
|
||||
// [-1.0, 1.0] before handing to libopus's float encoder —
|
||||
// out-of-range samples are hard-clipped inside libopus,
|
||||
@@ -2055,6 +2461,179 @@ impl CaptureState {
|
||||
}
|
||||
}
|
||||
|
||||
fn process_pending_vad_frames(&mut self, start_offset: usize) {
|
||||
let mut offset = start_offset.min(self.pcm_accum.len());
|
||||
while offset < self.pcm_accum.len() {
|
||||
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
|
||||
let take = remaining.min(self.pcm_accum.len() - offset);
|
||||
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
|
||||
.copy_from_slice(&self.pcm_accum[offset..offset + take]);
|
||||
self.pending_10ms_len += take;
|
||||
offset += take;
|
||||
|
||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
let frame = self.pending_10ms;
|
||||
self.process_10ms_capture_frame(&frame);
|
||||
self.pending_10ms_len = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
|
||||
// Realtime callback: do not log here. Publish state via atomics
|
||||
// and let a non-realtime consumer translate transitions into
|
||||
// info/warn events. The transmit/diagnostic stats stream
|
||||
// already exposes vad_fallback_active for this purpose.
|
||||
self.fallback_warned_backend = Some(failed_backend);
|
||||
}
|
||||
|
||||
fn sync_vad_backend(&mut self, voice_activity_mode: bool, vad_backend: crate::VadBackend) {
|
||||
if !voice_activity_mode {
|
||||
self.current_vad_backend = crate::VadBackend::Disabled;
|
||||
self.fallback_warned_backend = None;
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let silero_epoch = crate::vad::silero_model_epoch();
|
||||
let silero_changed =
|
||||
vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch;
|
||||
if vad_backend == self.current_vad_backend && !silero_changed {
|
||||
return;
|
||||
}
|
||||
|
||||
self.current_vad_backend = vad_backend;
|
||||
self.silero_model_epoch = silero_epoch;
|
||||
self.fallback_warned_backend = None;
|
||||
self.vad_state.reset();
|
||||
|
||||
match vad_backend {
|
||||
crate::VadBackend::SileroOnnx => {
|
||||
let worker_available = self
|
||||
.silero_vad_worker
|
||||
.try_lock()
|
||||
.map(|worker| worker.is_some())
|
||||
.unwrap_or(false);
|
||||
if worker_available {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
} else {
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
}
|
||||
}
|
||||
crate::VadBackend::WebrtcVad => {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
crate::VadBackend::EnergyDebug => {
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
}
|
||||
crate::VadBackend::Disabled => {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_10ms_capture_frame(&mut self, frame: &[f32; crate::frame::FRAME_10MS_SAMPLES]) {
|
||||
let input_dbfs = crate::frame::dbfs(frame);
|
||||
let (vad_backend, vad_hangover) = self
|
||||
.audio_processing_config
|
||||
.try_lock()
|
||||
.map(|cfg| (cfg.vad_backend, cfg.vad_hangover_ms))
|
||||
.unwrap_or((
|
||||
crate::VadBackend::WebrtcVad,
|
||||
crate::voice_activity::VAD_HANGOVER_MS,
|
||||
));
|
||||
let voice_activity_mode = self
|
||||
.voice_activity_selector
|
||||
.as_ref()
|
||||
.map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
|
||||
.unwrap_or(false);
|
||||
|
||||
if voice_activity_mode {
|
||||
self.sync_vad_backend(true, vad_backend);
|
||||
self.vad_state.configure(
|
||||
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
||||
vad_hangover,
|
||||
crate::voice_activity::VAD_MIN_TX_MS,
|
||||
);
|
||||
} else {
|
||||
self.sync_vad_backend(false, vad_backend);
|
||||
}
|
||||
|
||||
let (vad_probability, gate_open, used_fallback_vad) = if voice_activity_mode {
|
||||
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
|
||||
let capture_seq = self.capture_frame_seq;
|
||||
let mut used_fallback_vad = false;
|
||||
let vad = match vad_backend {
|
||||
crate::VadBackend::Disabled => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
crate::VadBackend::SileroOnnx => {
|
||||
// Single `try_lock` that both probes availability and
|
||||
// sends the frame. The guard is scoped to the block
|
||||
// so it drops before the fallback path (which needs
|
||||
// `&mut self` for `mark_vad_fallback_active`).
|
||||
// Returns Some(VadOutput) on a successful, non-stale
|
||||
// send; None means "fall back to WebRTC VAD".
|
||||
let worker_output = {
|
||||
let guard = self.silero_vad_worker.try_lock().ok();
|
||||
guard.and_then(|guard| {
|
||||
let worker = guard.as_ref()?;
|
||||
if worker.try_send(capture_seq, frame) && !worker.is_stale(capture_seq)
|
||||
{
|
||||
let p = worker.latest_probability();
|
||||
Some(crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
if let Some(output) = worker_output {
|
||||
output
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
frame,
|
||||
)
|
||||
}
|
||||
}
|
||||
crate::VadBackend::WebrtcVad | crate::VadBackend::EnergyDebug => {
|
||||
used_fallback_vad = vad_backend == crate::VadBackend::EnergyDebug;
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, frame)
|
||||
}
|
||||
};
|
||||
(
|
||||
vad.probability,
|
||||
self.vad_state.update(vad.speech),
|
||||
used_fallback_vad,
|
||||
)
|
||||
} else {
|
||||
(0.0, false, false)
|
||||
};
|
||||
self.audio_processing_stats
|
||||
.set_vad_fallback_active(used_fallback_vad);
|
||||
|
||||
let vad_active = voice_activity_mode && gate_open;
|
||||
if let Some(selector) = &self.voice_activity_selector {
|
||||
selector.set_voice_activity_open(vad_active);
|
||||
}
|
||||
self.audio_processing_stats.update_capture(
|
||||
input_dbfs,
|
||||
input_dbfs,
|
||||
vad_probability,
|
||||
vad_active,
|
||||
self.transmit_active.load(Ordering::Relaxed),
|
||||
);
|
||||
self.audio_processing_stats
|
||||
.record_capture_frame(frame.iter().all(|sample| sample.abs() <= 0.000_001));
|
||||
}
|
||||
|
||||
/// Simple linear resampler for `in_sample_rate → 48000`.
|
||||
///
|
||||
/// The resampler maintains continuity across cpal buffer
|
||||
@@ -2144,8 +2723,8 @@ where
|
||||
{
|
||||
let stream = device
|
||||
.build_input_stream(
|
||||
config,
|
||||
move |data: &[T], _| {
|
||||
*config,
|
||||
move |data: &[T], _: &cpal::InputCallbackInfo| {
|
||||
let mut s = state.lock().unwrap();
|
||||
s.ingest(data);
|
||||
},
|
||||
@@ -2211,8 +2790,8 @@ where
|
||||
.unwrap_or_else(std::time::Instant::now);
|
||||
let stream = device
|
||||
.build_output_stream(
|
||||
config,
|
||||
move |out: &mut [T], _| {
|
||||
*config,
|
||||
move |out: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||
let cb_start = std::time::Instant::now();
|
||||
let muted = output_muted.load(Ordering::Relaxed);
|
||||
let dev_frames = out.len() / dev_channels.max(1);
|
||||
@@ -2620,14 +3199,25 @@ pub mod bench_seam {
|
||||
let (tx, rx) = mpsc::channel::<OutPacket>(64);
|
||||
let transmit_active = Arc::new(AtomicBool::new(true));
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
// Bridge the bench's private mpsc<OutPacket> to the
|
||||
// realtime-thread-safe EncodedVoiceFrameSender that
|
||||
// CaptureState expects. The worker task forwards
|
||||
// encoded Opus frames to `tx` via `frames_sent`.
|
||||
let voice_out_tx =
|
||||
crate::opus_voice::start_out_packet_worker(tx, frames_sent, "cpal-bench")
|
||||
.expect("start_out_packet_worker");
|
||||
let state = CaptureState::new(
|
||||
encoder,
|
||||
in_sample_rate,
|
||||
in_channels,
|
||||
1.0,
|
||||
tx,
|
||||
voice_out_tx,
|
||||
transmit_active.clone(),
|
||||
frames_sent,
|
||||
None,
|
||||
Arc::new(std::sync::Mutex::new(
|
||||
crate::AudioProcessingConfig::default(),
|
||||
)),
|
||||
Arc::new(std::sync::Mutex::new(None)),
|
||||
Arc::new(crate::SharedAudioProcessingStats::default()),
|
||||
);
|
||||
Self {
|
||||
|
||||
@@ -1,538 +0,0 @@
|
||||
//! Optional raw iOS RemoteIO path for the WebRTC APM experimental mode.
|
||||
//!
|
||||
//! Provides an alternative to `ios_voice_unit.rs` for the
|
||||
//! `SonoraExperimental` processing mode. Instead of
|
||||
//! `kAudioUnitSubType_VoiceProcessingIO` (which owns AEC/NS/AGC), it
|
||||
//! opens `kAudioUnitSubType_RemoteIO` with voice processing explicitly
|
||||
//! disabled so WebRTC APM can own the full signal path.
|
||||
//!
|
||||
//! ## Hard invariants enforced here
|
||||
//!
|
||||
//! * INV_009: Rust AEC only active when platform AEC is disabled.
|
||||
//! * INV_010: VoiceProcessingIO and WebRTC APM AEC are mutually exclusive.
|
||||
//! * INV_011: Software AEC backend receives both capture and render-reference.
|
||||
//! * INV_012: Render reference is copied from decoded/mixed remote PCM
|
||||
//! before playout.
|
||||
//!
|
||||
//! ## Fallback
|
||||
//!
|
||||
//! If RemoteIO construction fails, the caller falls back to `IosVoiceUnit`
|
||||
//! (VPIO) and logs the error.
|
||||
//!
|
||||
//! ## Status
|
||||
//!
|
||||
//! Experimental / disabled by default. Only activated when the user
|
||||
//! explicitly selects `SonoraExperimental` mode via the bridge API.
|
||||
//!
|
||||
//! ## Platform
|
||||
//!
|
||||
//! `kAudioUnitSubType_RemoteIO` is only available in the iOS SDK.
|
||||
//! This module is gated to `target_os = "ios"`.
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
pub use inner::IosRawUnit;
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
mod inner {
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use audiopus::coder::Encoder as OpusEncoder;
|
||||
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
||||
use coreaudio::audio_unit::render_callback::{self, data};
|
||||
use coreaudio::audio_unit::IOType;
|
||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
||||
use crate::processor::AudioProcessor;
|
||||
use crate::AudioError;
|
||||
|
||||
const SAMPLE_RATE_HZ: f64 = 48_000.0;
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Render-reference ring buffer //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
/// 4-slot ring buffer shared between the render callback (writer) and
|
||||
/// the capture callback (reader for Sonora AEC3). Capacity: 4 × 10 ms
|
||||
/// = 40 ms of headroom.
|
||||
///
|
||||
/// If the capture callback runs before the render callback has written
|
||||
/// a frame it reads zeros (silence reference), which is safe — Sonora
|
||||
/// AEC3 simply skips cancellation for that frame.
|
||||
type RenderReferenceBuffer = crate::render_reference::RenderReferenceBuffer<480, 4>;
|
||||
type RenderReferenceFrameAccumulator =
|
||||
crate::render_reference::RenderReferenceFrameAccumulator<480>;
|
||||
const RAW_RENDER_SCRATCH_FRAMES: usize = 1024;
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Capture pipeline state //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
struct RawCaptureState {
|
||||
encoder: OpusEncoder,
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
silero_coreml_worker: Option<crate::vad::apple_coreml::AppleCoreMlVadWorker>,
|
||||
current_vad_backend: crate::VadBackend,
|
||||
capture_frame_seq: u64,
|
||||
vad_state: crate::voice_activity::VoiceActivityStateMachine,
|
||||
/// Processing config — retained for route-change reloads.
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
webrtc_apm_processor: crate::processor::WebRtcApmProcessor,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
render_reference: Arc<RenderReferenceBuffer>,
|
||||
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: usize,
|
||||
fallback_warned_backend: Option<crate::VadBackend>,
|
||||
}
|
||||
|
||||
impl RawCaptureState {
|
||||
fn new(
|
||||
params: &VoiceAudioParams,
|
||||
render_reference: Arc<RenderReferenceBuffer>,
|
||||
) -> Result<Self, AudioError> {
|
||||
let encoder = crate::opus_voice::new_voip_encoder("ios raw")?;
|
||||
let webrtc_apm_config = params
|
||||
.audio_processing_config
|
||||
.lock()
|
||||
.map(|cfg| crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(&cfg))
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||
params.voice_out_tx.clone(),
|
||||
params.frames_sent.clone(),
|
||||
"ios-raw",
|
||||
)?,
|
||||
transmit_active: params.transmit_active.clone(),
|
||||
output_muted: params.output_muted.clone(),
|
||||
mic_gain: params.mic_gain,
|
||||
voice_activity_selector: params.voice_activity_selector.clone(),
|
||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||
silero_coreml_worker: None,
|
||||
current_vad_backend: crate::VadBackend::WebrtcVad,
|
||||
capture_frame_seq: 0,
|
||||
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
|
||||
audio_processing_config: params.audio_processing_config.clone(),
|
||||
webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
|
||||
webrtc_apm_config,
|
||||
)?,
|
||||
audio_processing_stats: params.audio_processing_stats.clone(),
|
||||
render_reference,
|
||||
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: 0,
|
||||
fallback_warned_backend: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
|
||||
if self.fallback_warned_backend != Some(failed_backend) {
|
||||
self.fallback_warned_backend = Some(failed_backend);
|
||||
if self.capture_frame_seq < 128 {
|
||||
tracing::info!(
|
||||
target: "chanora_audio",
|
||||
backend = failed_backend.as_str(),
|
||||
seq = self.capture_frame_seq,
|
||||
"VAD backend warming up; using WebRTC fallback"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target: "chanora_audio",
|
||||
backend = failed_backend.as_str(),
|
||||
"VAD backend unavailable; using WebRTC fallback for runtime detection"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest_i16(&mut self, samples: &[i16]) {
|
||||
// Accumulate into 10 ms frames for VAD / Sonora processing.
|
||||
let mut offset = 0;
|
||||
while offset < samples.len() {
|
||||
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
|
||||
let take = remaining.min(samples.len() - offset);
|
||||
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
|
||||
.copy_from_slice(&samples[offset..offset + take]);
|
||||
self.pending_10ms_len += take;
|
||||
offset += take;
|
||||
|
||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
let frame = self.pending_10ms;
|
||||
self.process_10ms_capture_frame(&frame);
|
||||
self.encode_complete_20ms_frames();
|
||||
self.pending_10ms_len = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
self.pcm_accum.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
self.encode_complete_20ms_frames();
|
||||
}
|
||||
|
||||
fn encode_complete_20ms_frames(&mut self) {
|
||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||
self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
|
||||
|
||||
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"ios raw: voice_out queue full; dropping frame"
|
||||
);
|
||||
},
|
||||
|| {},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(target: "chanora_audio",
|
||||
error = %e, "ios raw opus encode failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_10ms_capture_frame(
|
||||
&mut self,
|
||||
samples: &[i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
) {
|
||||
let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
|
||||
*dst = crate::frame::i16_to_f32(src);
|
||||
}
|
||||
let input_dbfs = crate::frame::dbfs(&frame);
|
||||
|
||||
// Debug WAV mic taps are intentionally unavailable on iOS raw
|
||||
// realtime callbacks until WavDebugRecorder supports a
|
||||
// preallocated handoff path; the current recorder push path
|
||||
// allocates per frame.
|
||||
|
||||
// Feed render reference to WebRTC APM before capture so AEC can adapt.
|
||||
let render_ref = self.render_reference.read_latest();
|
||||
self.webrtc_apm_processor.process_render(&render_ref);
|
||||
self.webrtc_apm_processor.process_capture(&mut frame);
|
||||
|
||||
// Processed-mic debug WAV capture is disabled for the same
|
||||
// realtime allocation reason as the raw-mic tap above.
|
||||
|
||||
let voice_activity_mode = self
|
||||
.voice_activity_selector
|
||||
.as_ref()
|
||||
.map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
|
||||
.unwrap_or(false);
|
||||
if !voice_activity_mode {
|
||||
self.silero_coreml_worker = None;
|
||||
self.current_vad_backend = crate::VadBackend::Disabled;
|
||||
self.fallback_warned_backend = None;
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
|
||||
let (vad_backend, vad_hangover) = self
|
||||
.audio_processing_config
|
||||
.try_lock()
|
||||
.map(|cfg| (cfg.vad_backend, cfg.vad_hangover_ms))
|
||||
.unwrap_or((
|
||||
crate::VadBackend::WebrtcVad,
|
||||
crate::voice_activity::VAD_HANGOVER_MS,
|
||||
));
|
||||
if voice_activity_mode {
|
||||
self.vad_state.configure(
|
||||
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
||||
vad_hangover,
|
||||
crate::voice_activity::VAD_MIN_TX_MS,
|
||||
);
|
||||
}
|
||||
|
||||
if voice_activity_mode && vad_backend != self.current_vad_backend {
|
||||
self.current_vad_backend = vad_backend;
|
||||
self.fallback_warned_backend = None;
|
||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
self.silero_coreml_worker = None;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
} else {
|
||||
self.silero_coreml_worker = None;
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
self.vad_state.reset();
|
||||
}
|
||||
|
||||
let (vad_probability, active) = if voice_activity_mode {
|
||||
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
|
||||
let capture_seq = self.capture_frame_seq;
|
||||
let mut used_fallback_vad = false;
|
||||
let vad = if vad_backend == crate::VadBackend::Disabled {
|
||||
crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
match crate::vad::callback_vad_worker_policy(
|
||||
voice_activity_mode,
|
||||
vad_backend,
|
||||
self.silero_coreml_worker.is_some(),
|
||||
) {
|
||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
||||
let worker = self
|
||||
.silero_coreml_worker
|
||||
.as_ref()
|
||||
.expect("policy checked worker");
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
}
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
};
|
||||
self.audio_processing_stats
|
||||
.set_vad_fallback_active(used_fallback_vad);
|
||||
(vad.probability, self.vad_state.update(vad.speech))
|
||||
} else {
|
||||
(0.0, false)
|
||||
};
|
||||
if let Some(sel) = &self.voice_activity_selector {
|
||||
sel.set_voice_activity_open(voice_activity_mode && active);
|
||||
}
|
||||
self.audio_processing_stats.update_capture(
|
||||
input_dbfs,
|
||||
crate::frame::dbfs(&frame),
|
||||
vad_probability,
|
||||
voice_activity_mode && active,
|
||||
self.transmit_active.load(Ordering::Relaxed),
|
||||
);
|
||||
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if crate::capture_accumulator::append_processed_i16_bounded(
|
||||
&mut self.pcm_accum,
|
||||
&frame,
|
||||
self.mic_gain,
|
||||
) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// IosRawUnit //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
/// Raw iOS RemoteIO audio unit for the Sonora experimental path.
|
||||
pub struct IosRawUnit {
|
||||
unit: AudioUnit,
|
||||
}
|
||||
|
||||
impl IosRawUnit {
|
||||
/// Open a RemoteIO AudioUnit, install render + input callbacks, start.
|
||||
pub(crate) fn start(params: VoiceAudioParams) -> Result<Self, AudioError> {
|
||||
// INV_010: reject if config requests VPIO (that's IosVoiceUnit's job).
|
||||
{
|
||||
let cfg = params.audio_processing_config.lock().unwrap();
|
||||
if cfg.ios_mode == crate::IosVoiceProcessingMode::PlatformVoiceProcessing {
|
||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||
"IosRawUnit requires raw WebRTC APM mode".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut unit = AudioUnit::new_uninitialized(IOType::RemoteIO)
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio new: {e}")))?;
|
||||
|
||||
// Enable input on bus 1.
|
||||
const ENABLE_IO: u32 = 2003;
|
||||
let enable: u32 = 1;
|
||||
unit.set_property(ENABLE_IO, Scope::Input, Element::Input, Some(&enable))
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio enable input: {e}")))?;
|
||||
|
||||
// 48 kHz Int16 mono on both buses.
|
||||
let fmt = StreamFormat {
|
||||
sample_rate: SAMPLE_RATE_HZ,
|
||||
sample_format: SampleFormat::I16,
|
||||
flags: LinearPcmFlags::IS_SIGNED_INTEGER | LinearPcmFlags::IS_PACKED,
|
||||
channels: 1,
|
||||
};
|
||||
unit.set_stream_format(fmt, Scope::Input, Element::Output)
|
||||
.map_err(|e| AudioError::StreamConfig(format!("remoteio fmt output: {e}")))?;
|
||||
unit.set_stream_format(fmt, Scope::Output, Element::Input)
|
||||
.map_err(|e| AudioError::StreamConfig(format!("remoteio fmt input: {e}")))?;
|
||||
|
||||
// Shared render-reference buffer (INV_011 / INV_012).
|
||||
let render_ref_buf = RenderReferenceBuffer::new();
|
||||
let render_ref_for_capture = render_ref_buf.clone();
|
||||
|
||||
let mut capture_state = RawCaptureState::new(¶ms, render_ref_for_capture)?;
|
||||
|
||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
capture_state.ingest_i16(args.data.buffer);
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
|
||||
|
||||
let mut scratch = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES * 2];
|
||||
let mut mono = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES];
|
||||
let mut render_ref_accum = RenderReferenceFrameAccumulator::new();
|
||||
let handler = params.handler.clone();
|
||||
let output_gain = params.output_gain.clone();
|
||||
let output_muted = params.output_muted.clone();
|
||||
let stats_render = params.audio_processing_stats.clone();
|
||||
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let out = args.data.buffer;
|
||||
let n = out.len();
|
||||
let process_n = n.min(RAW_RENDER_SCRATCH_FRAMES);
|
||||
let stereo_n = process_n * 2;
|
||||
if n > RAW_RENDER_SCRATCH_FRAMES {
|
||||
stats_render.increment_callback_xrun();
|
||||
}
|
||||
scratch[..stereo_n].fill(0.0);
|
||||
|
||||
match handler.try_lock() {
|
||||
Ok(mut h) => {
|
||||
let _ = h.fill_buffer(&mut scratch[..stereo_n]);
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
stats_render.increment_callback_xrun();
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
warn!(target: "chanora_audio",
|
||||
"AudioHandler poisoned (raw render): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// INV_012: copy render reference BEFORE playout.
|
||||
crate::voice_render::downmix_stereo_f32_to_mono_f32(
|
||||
&scratch[..stereo_n],
|
||||
&mut mono[..process_n],
|
||||
);
|
||||
render_ref_accum.push_mono_samples(&mono[..process_n], |frame| {
|
||||
render_ref_buf.write(frame);
|
||||
});
|
||||
|
||||
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
||||
let muted = output_muted.load(Ordering::Relaxed);
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
||||
&scratch[..stereo_n],
|
||||
&mut out[..process_n],
|
||||
1,
|
||||
gain,
|
||||
muted,
|
||||
);
|
||||
if process_n < n {
|
||||
out[process_n..].fill(0);
|
||||
}
|
||||
if mix_stats.clipped_samples > 0 {
|
||||
stats_render.add_clipped_samples(mix_stats.clipped_samples);
|
||||
}
|
||||
stats_render.update_render(crate::frame::dbfs(&scratch[..stereo_n]), n as u32);
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio render cb: {e}")))?;
|
||||
|
||||
unit.initialize()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio init: {e}")))?;
|
||||
unit.start()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio start: {e}")))?;
|
||||
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
sample_rate_hz = SAMPLE_RATE_HZ,
|
||||
"ios RemoteIO (Sonora experimental) started"
|
||||
);
|
||||
Ok(Self { unit })
|
||||
}
|
||||
|
||||
/// Restart the unit after a route change (stop → uninit → init → start).
|
||||
pub fn restart(&mut self) -> Result<(), AudioError> {
|
||||
self.unit
|
||||
.stop()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio restart stop: {e}")))?;
|
||||
self.unit
|
||||
.uninitialize()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio restart uninit: {e}")))?;
|
||||
self.unit
|
||||
.initialize()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio restart init: {e}")))?;
|
||||
self.unit
|
||||
.start()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio restart start: {e}")))?;
|
||||
info!(target: "chanora_audio", "ios RemoteIO restarted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pause the unit during an AVAudioSession interruption.
|
||||
pub fn pause(&mut self) -> Result<(), AudioError> {
|
||||
self.unit
|
||||
.stop()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio pause: {e}")))
|
||||
}
|
||||
|
||||
/// Resume the unit after an interruption ends.
|
||||
pub fn resume(&mut self) -> Result<(), AudioError> {
|
||||
self.unit
|
||||
.start()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio resume: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IosRawUnit {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.unit.stop() {
|
||||
warn!(target: "chanora_audio", error = %e,
|
||||
"ios RemoteIO stop on drop failed");
|
||||
} else {
|
||||
info!(target: "chanora_audio", "ios RemoteIO stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,38 +406,20 @@ impl IosCaptureState {
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
match crate::vad::callback_vad_worker_policy(
|
||||
voice_activity_mode,
|
||||
vad_backend,
|
||||
self.silero_coreml_worker.is_some(),
|
||||
) {
|
||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
||||
let worker = self
|
||||
.silero_coreml_worker
|
||||
.as_ref()
|
||||
.expect("policy checked worker");
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
}
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
if let Some(worker) = self.silero_coreml_worker.as_ref() {
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
}
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
@@ -445,10 +427,13 @@ impl IosCaptureState {
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
|
||||
@@ -65,10 +65,7 @@ pub(crate) mod voice_render;
|
||||
mod sdl_output;
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
mod ios_voice_unit;
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
pub mod ios_raw_unit;
|
||||
mod ios_voice_unit;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android_voice_unit;
|
||||
|
||||
@@ -26,7 +26,7 @@ use std::thread;
|
||||
|
||||
use tracing::{info, warn};
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::Foundation::{HINSTANCE, HMODULE, HWND, LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::Input::{
|
||||
GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, RAWINPUTHEADER,
|
||||
@@ -35,7 +35,7 @@ use windows::Win32::UI::Input::{
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW,
|
||||
PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx,
|
||||
HC_ACTION, HHOOK, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
|
||||
HC_ACTION, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
|
||||
WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN,
|
||||
WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1, XBUTTON2,
|
||||
};
|
||||
@@ -423,7 +423,7 @@ unsafe fn run_raw_input_loop(
|
||||
// class.
|
||||
let _atom = RegisterClassExW(&wc);
|
||||
|
||||
let hwnd = unsafe {
|
||||
let hwnd = match unsafe {
|
||||
CreateWindowExW(
|
||||
WINDOW_EX_STYLE(0),
|
||||
class_name,
|
||||
@@ -433,13 +433,23 @@ unsafe fn run_raw_input_loop(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
HWND(HWND_MESSAGE_PTR),
|
||||
Some(HWND(HWND_MESSAGE_PTR as *mut core::ffi::c_void)),
|
||||
None,
|
||||
h_instance,
|
||||
Some(HINSTANCE(h_instance.0)),
|
||||
None,
|
||||
)
|
||||
} {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
|
||||
);
|
||||
report!(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if hwnd.0 == 0 {
|
||||
if hwnd.0.is_null() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
|
||||
@@ -517,13 +527,13 @@ unsafe fn run_raw_input_loop(
|
||||
usUsagePage: 0x01,
|
||||
usUsage: 0x06,
|
||||
dwFlags: RIDEV_REMOVE,
|
||||
hwndTarget: HWND(0),
|
||||
hwndTarget: HWND(std::ptr::null_mut()),
|
||||
},
|
||||
RAWINPUTDEVICE {
|
||||
usUsagePage: 0x01,
|
||||
usUsage: 0x02,
|
||||
dwFlags: RIDEV_REMOVE,
|
||||
hwndTarget: HWND(0),
|
||||
hwndTarget: HWND(std::ptr::null_mut()),
|
||||
},
|
||||
];
|
||||
let _ = RegisterRawInputDevices(&undo, std::mem::size_of::<RAWINPUTDEVICE>() as u32);
|
||||
@@ -544,7 +554,7 @@ unsafe extern "system" fn raw_input_wnd_proc(
|
||||
}
|
||||
|
||||
unsafe fn handle_wm_input(lparam: LPARAM) {
|
||||
let h_raw = HRAWINPUT(lparam.0);
|
||||
let h_raw = HRAWINPUT(lparam.0 as *mut core::ffi::c_void);
|
||||
let mut size: u32 = 0;
|
||||
let header_sz = std::mem::size_of::<RAWINPUTHEADER>() as u32;
|
||||
// First call: query buffer size.
|
||||
@@ -841,31 +851,33 @@ unsafe fn run_hook_loop(
|
||||
let kbd_proc: HOOKPROC = Some(kbd_hook_proc);
|
||||
let mouse_proc: HOOKPROC = Some(mouse_hook_proc);
|
||||
|
||||
let kbd_hook = match SetWindowsHookExW(WH_KEYBOARD_LL, kbd_proc, h_instance, 0) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"windows ptt: SetWindowsHookExW(WH_KEYBOARD_LL) failed"
|
||||
);
|
||||
report!(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let mouse_hook = match SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, h_instance, 0) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"windows ptt: SetWindowsHookExW(WH_MOUSE_LL) failed"
|
||||
);
|
||||
let _ = UnhookWindowsHookEx(kbd_hook);
|
||||
report!(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let kbd_hook =
|
||||
match SetWindowsHookExW(WH_KEYBOARD_LL, kbd_proc, Some(HINSTANCE(h_instance.0)), 0) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"windows ptt: SetWindowsHookExW(WH_KEYBOARD_LL) failed"
|
||||
);
|
||||
report!(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let mouse_hook =
|
||||
match SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, Some(HINSTANCE(h_instance.0)), 0) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"windows ptt: SetWindowsHookExW(WH_MOUSE_LL) failed"
|
||||
);
|
||||
let _ = UnhookWindowsHookEx(kbd_hook);
|
||||
report!(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
@@ -908,7 +920,7 @@ unsafe extern "system" fn kbd_hook_proc(code: i32, wparam: WPARAM, lparam: LPARA
|
||||
}
|
||||
});
|
||||
}
|
||||
CallNextHookEx(HHOOK(0), code, wparam, lparam)
|
||||
CallNextHookEx(None, code, wparam, lparam)
|
||||
}
|
||||
|
||||
/// Pure-logic dispatcher for a low-level keyboard hook event (L0
|
||||
@@ -943,7 +955,7 @@ unsafe extern "system" fn mouse_hook_proc(code: i32, wparam: WPARAM, lparam: LPA
|
||||
}
|
||||
});
|
||||
}
|
||||
CallNextHookEx(HHOOK(0), code, wparam, lparam)
|
||||
CallNextHookEx(None, code, wparam, lparam)
|
||||
}
|
||||
|
||||
/// Pure-logic dispatcher for a low-level mouse hook event (L0
|
||||
|
||||
@@ -8,17 +8,17 @@
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
pub mod apple_coreml;
|
||||
pub mod resampler;
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
pub mod silero_onnx;
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
use crate::frame::{f32_to_i16, i16_to_f32};
|
||||
use crate::{AudioError, VadBackend};
|
||||
use crate::AudioError;
|
||||
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
pub use silero_onnx::SileroOnnxVad;
|
||||
|
||||
/// Voice activity detector output for one 10 ms frame.
|
||||
@@ -108,39 +108,12 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16
|
||||
detector.process_10ms(&frame)
|
||||
}
|
||||
|
||||
/// Callback-side policy for optional model-backed VAD workers.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum VadWorkerPolicy {
|
||||
/// Keep using the already-available model worker.
|
||||
UseWorker,
|
||||
/// No worker may be constructed on the callback thread; use WebRTC fallback.
|
||||
UseFallback,
|
||||
/// This backend does not need a model worker.
|
||||
NotModelBacked,
|
||||
}
|
||||
|
||||
/// Decide whether a realtime callback may use a model-backed VAD worker.
|
||||
///
|
||||
/// Model/worker construction is intentionally absent from this policy: if a
|
||||
/// worker is not already present, callbacks must stay nonblocking and fall back.
|
||||
pub(crate) fn callback_vad_worker_policy(
|
||||
voice_activity_mode: bool,
|
||||
backend: VadBackend,
|
||||
worker_available: bool,
|
||||
) -> VadWorkerPolicy {
|
||||
if !voice_activity_mode || backend != VadBackend::SileroOnnx {
|
||||
return VadWorkerPolicy::NotModelBacked;
|
||||
}
|
||||
if worker_available {
|
||||
VadWorkerPolicy::UseWorker
|
||||
} else {
|
||||
VadWorkerPolicy::UseFallback
|
||||
}
|
||||
}
|
||||
|
||||
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
||||
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static SILERO_MODEL_PATH_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn silero_model_path_override() -> &'static RwLock<Option<String>> {
|
||||
SILERO_MODEL_PATH_OVERRIDE.get_or_init(|| RwLock::new(None))
|
||||
}
|
||||
@@ -175,6 +148,19 @@ pub fn silero_model_epoch() -> u64 {
|
||||
SILERO_MODEL_EPOCH.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn clear_silero_model_path_for_test() {
|
||||
set_silero_model_path_for_test(None);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_silero_model_path_for_test(path: Option<String>) {
|
||||
if let Ok(mut guard) = silero_model_path_override().write() {
|
||||
*guard = path;
|
||||
SILERO_MODEL_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the expected path of the Silero VAD v6 ONNX model on
|
||||
/// supported platforms.
|
||||
/// The model is shipped as a Flutter asset and copied to the app's
|
||||
@@ -264,13 +250,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn set_silero_model_path_rejects_missing_file() {
|
||||
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap();
|
||||
clear_silero_model_path_for_test();
|
||||
|
||||
let result = set_silero_model_path("/definitely/not/a/silero_vad.onnx");
|
||||
|
||||
assert!(result.is_err());
|
||||
clear_silero_model_path_for_test();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_silero_model_path_updates_override_and_epoch() {
|
||||
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap();
|
||||
clear_silero_model_path_for_test();
|
||||
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("chanora_test_silero_{}.onnx", std::process::id()));
|
||||
std::fs::write(&path, b"test").unwrap();
|
||||
@@ -280,34 +273,7 @@ mod tests {
|
||||
|
||||
assert!(silero_model_epoch() > before);
|
||||
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
||||
clear_silero_model_path_for_test();
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_policy_uses_existing_model_worker_only() {
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(true, VadBackend::SileroOnnx, true),
|
||||
VadWorkerPolicy::UseWorker
|
||||
);
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(true, VadBackend::SileroOnnx, false),
|
||||
VadWorkerPolicy::UseFallback
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_policy_keeps_disabled_and_webrtc_paths_worker_free() {
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(false, VadBackend::SileroOnnx, false),
|
||||
VadWorkerPolicy::NotModelBacked
|
||||
);
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(true, VadBackend::Disabled, false),
|
||||
VadWorkerPolicy::NotModelBacked
|
||||
);
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(true, VadBackend::WebrtcVad, false),
|
||||
VadWorkerPolicy::NotModelBacked
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +361,31 @@ impl SileroOnnxVadWorker {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn stale_test_worker() -> Self {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<SileroFrameMessage>(64);
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let alive_for_thread = alive.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("chanora-silero-vad-stale-test".to_string())
|
||||
.spawn(move || {
|
||||
while alive_for_thread.load(Ordering::Relaxed) {
|
||||
match rx.recv_timeout(std::time::Duration::from_millis(10)) {
|
||||
Ok(_) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
Self {
|
||||
tx: Some(tx),
|
||||
latest_probability: Arc::new(AtomicU32::new(0.0_f32.to_bits())),
|
||||
latest_processed_seq: Arc::new(AtomicU64::new(u64::MAX)),
|
||||
alive,
|
||||
handle,
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort enqueue of a 10 ms frame for background inference.
|
||||
pub fn try_send(&self, seq: u64, frame: &[f32; super::resampler::INPUT_FRAME_10MS]) -> bool {
|
||||
let Some(tx) = &self.tx else {
|
||||
@@ -393,8 +418,15 @@ impl SileroOnnxVadWorker {
|
||||
impl Drop for SileroOnnxVadWorker {
|
||||
fn drop(&mut self) {
|
||||
self.alive.store(false, Ordering::Relaxed);
|
||||
// Drop the sender first so the worker thread's rx.recv() returns
|
||||
// Err and the loop exits promptly.
|
||||
let _ = self.tx.take();
|
||||
let _ = self.handle.take();
|
||||
// Join the thread instead of detaching. The channel close
|
||||
// unblocks rx.recv() so the join is bounded; it waits at most
|
||||
// until the current in-flight inference completes.
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -196,8 +196,8 @@ fn windows_exercise() {
|
||||
// start/stop lifecycle through the public trait surface so
|
||||
// any info!/warn! the factory or the backend's `start` path
|
||||
// emits is captured by the layer.
|
||||
use chanora_audio::ptt_backends::{select_ptt_backend, PttBinding, PttInputClass};
|
||||
use chanora_audio::AudioTransmitGate;
|
||||
use chanora_audio::ptt_backends::{PttBinding, PttInputClass};
|
||||
use chanora_audio::{select_ptt_backend, AudioTransmitGate};
|
||||
|
||||
let mut backend = select_ptt_backend();
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
|
||||
@@ -1062,10 +1062,8 @@ pub enum BridgeAudioRoute {
|
||||
/// Bridge iOS voice-processing mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BridgeIosVoiceProcessingMode {
|
||||
/// Shipping VPIO path.
|
||||
/// Apple VoiceProcessingIO path.
|
||||
PlatformVoiceProcessing,
|
||||
/// Experimental Sonora path.
|
||||
SonoraExperimental,
|
||||
}
|
||||
|
||||
/// Bridge processing backend.
|
||||
@@ -1223,7 +1221,6 @@ impl From<BridgeIosVoiceProcessingMode> for chanora_core::IosVoiceProcessingMode
|
||||
fn from(mode: BridgeIosVoiceProcessingMode) -> Self {
|
||||
match mode {
|
||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
||||
BridgeIosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1234,7 +1231,6 @@ impl From<chanora_core::IosVoiceProcessingMode> for BridgeIosVoiceProcessingMode
|
||||
chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => {
|
||||
Self::PlatformVoiceProcessing
|
||||
}
|
||||
chanora_core::IosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1469,6 +1465,54 @@ pub async fn init_storage(dir: String) -> Result<(), BridgeError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure the bridge blob cache root.
|
||||
pub async fn init_cache(dir: String) -> Result<(), BridgeError> {
|
||||
runtime()
|
||||
.spawn(async move { session().init_cache(&dir).await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("init_cache", e))??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve avatar bytes through the bridge.
|
||||
pub async fn download_avatar(
|
||||
avatar_hash: String,
|
||||
client_uid: String,
|
||||
) -> Result<Option<Vec<u8>>, BridgeError> {
|
||||
runtime()
|
||||
.spawn(async move { session().get_avatar(&avatar_hash, &client_uid).await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("download_avatar", e))?
|
||||
.map_err(BridgeError::from)
|
||||
}
|
||||
|
||||
/// Resolve icon bytes through the bridge.
|
||||
pub async fn download_icon(icon_id: u64) -> Result<Option<Vec<u8>>, BridgeError> {
|
||||
runtime()
|
||||
.spawn(async move { session().get_icon(icon_id).await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("download_icon", e))?
|
||||
.map_err(BridgeError::from)
|
||||
}
|
||||
|
||||
/// Purge cached protocol-owned assets.
|
||||
pub async fn clear_file_cache() -> Result<(), BridgeError> {
|
||||
runtime()
|
||||
.spawn(async move { session().clear_cache().await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("clear_file_cache", e))??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report the configured file-cache size.
|
||||
pub async fn file_cache_size() -> Result<u64, BridgeError> {
|
||||
runtime()
|
||||
.spawn(async move { session().cache_size().await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("file_cache_size", e))?
|
||||
.map_err(BridgeError::from)
|
||||
}
|
||||
|
||||
/// Bookmark DTO mirroring [`chanora_core::Bookmark`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BridgeBookmark {
|
||||
@@ -2361,25 +2405,11 @@ pub async fn set_ios_voice_processing_mode(
|
||||
let config = BridgeAudioProcessingConfig {
|
||||
route: BridgeAudioRoute::Speaker,
|
||||
ios_mode: mode,
|
||||
processing_backend: match mode {
|
||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => {
|
||||
BridgeAudioBackend::PlatformVoiceProcessing
|
||||
}
|
||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::WebrtcApm,
|
||||
},
|
||||
processing_backend: BridgeAudioBackend::PlatformVoiceProcessing,
|
||||
vad_backend: BridgeVadBackend::SileroOnnx,
|
||||
aec: match mode {
|
||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
|
||||
},
|
||||
ns: match mode {
|
||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
|
||||
},
|
||||
agc: match mode {
|
||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
|
||||
},
|
||||
aec: BridgeEffectOwner::Platform,
|
||||
ns: BridgeEffectOwner::Platform,
|
||||
agc: BridgeEffectOwner::Platform,
|
||||
hpf_enabled: true,
|
||||
limiter_enabled: true,
|
||||
vad_hangover_ms: 500,
|
||||
|
||||
@@ -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 = -20394775;
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 635684021;
|
||||
|
||||
// Section: executor
|
||||
|
||||
@@ -186,6 +186,41 @@ fn wire__crate__api__bridge_init_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__clear_file_cache_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: "clear_file_cache",
|
||||
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);
|
||||
deserializer.end();
|
||||
move |context| async move {
|
||||
transform_result_sse::<_, crate::BridgeError>(
|
||||
(move || async move {
|
||||
let output_ok = crate::api::clear_file_cache().await?;
|
||||
Ok(output_ok)
|
||||
})()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__client_profile_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
@@ -332,6 +367,80 @@ fn wire__crate__api__disconnect_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__download_avatar_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: "download_avatar",
|
||||
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_avatar_hash = <String>::sse_decode(&mut deserializer);
|
||||
let api_client_uid = <String>::sse_decode(&mut deserializer);
|
||||
deserializer.end();
|
||||
move |context| async move {
|
||||
transform_result_sse::<_, crate::BridgeError>(
|
||||
(move || async move {
|
||||
let output_ok =
|
||||
crate::api::download_avatar(api_avatar_hash, api_client_uid).await?;
|
||||
Ok(output_ok)
|
||||
})()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__download_icon_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: "download_icon",
|
||||
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_icon_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::download_icon(api_icon_id).await?;
|
||||
Ok(output_ok)
|
||||
})()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__enable_audio_debug_wav_dump_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
@@ -434,6 +543,41 @@ fn wire__crate__api__export_diagnostics_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__file_cache_size_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: "file_cache_size",
|
||||
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);
|
||||
deserializer.end();
|
||||
move |context| async move {
|
||||
transform_result_sse::<_, crate::BridgeError>(
|
||||
(move || async move {
|
||||
let output_ok = crate::api::file_cache_size().await?;
|
||||
Ok(output_ok)
|
||||
})()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__get_audio_processing_config_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
@@ -702,6 +846,42 @@ fn wire__crate__api__handle_route_change_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__init_cache_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: "init_cache",
|
||||
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_dir = <String>::sse_decode(&mut deserializer);
|
||||
deserializer.end();
|
||||
move |context| async move {
|
||||
transform_result_sse::<_, crate::BridgeError>(
|
||||
(move || async move {
|
||||
let output_ok = crate::api::init_cache(api_dir).await?;
|
||||
Ok(output_ok)
|
||||
})()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__init_storage_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
@@ -2409,7 +2589,6 @@ impl SseDecode for crate::api::BridgeIosVoiceProcessingMode {
|
||||
let mut inner = <i32>::sse_decode(deserializer);
|
||||
return match inner {
|
||||
0 => crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||
1 => crate::api::BridgeIosVoiceProcessingMode::SonoraExperimental,
|
||||
_ => unreachable!(
|
||||
"Invalid variant for BridgeIosVoiceProcessingMode: {}",
|
||||
inner
|
||||
@@ -2764,6 +2943,17 @@ impl SseDecode for Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for Option<Vec<u8>> {
|
||||
// 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(<Vec<u8>>::sse_decode(deserializer));
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::PermissionStateKind {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
@@ -2817,45 +3007,50 @@ 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__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__input_level_stream_impl(port, ptr, rust_vec_len, data_len),
|
||||
22 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
||||
23 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
|
||||
24 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
|
||||
26 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
|
||||
27 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
|
||||
28 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
|
||||
30 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
|
||||
32 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
|
||||
33 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
|
||||
34 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
|
||||
35 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
|
||||
36 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||
37 => {
|
||||
5 => wire__crate__api__clear_file_cache_impl(port, ptr, rust_vec_len, data_len),
|
||||
6 => wire__crate__api__client_profile_impl(port, ptr, rust_vec_len, data_len),
|
||||
7 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
|
||||
8 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||
9 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
|
||||
10 => wire__crate__api__download_avatar_impl(port, ptr, rust_vec_len, data_len),
|
||||
11 => wire__crate__api__download_icon_impl(port, ptr, rust_vec_len, data_len),
|
||||
12 => wire__crate__api__enable_audio_debug_wav_dump_impl(port, ptr, rust_vec_len, data_len),
|
||||
13 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
|
||||
15 => wire__crate__api__file_cache_size_impl(port, ptr, rust_vec_len, data_len),
|
||||
16 => wire__crate__api__get_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
|
||||
17 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||
18 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||
19 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||
24 => wire__crate__api__init_cache_impl(port, ptr, rust_vec_len, data_len),
|
||||
25 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
|
||||
26 => wire__crate__api__input_level_stream_impl(port, ptr, rust_vec_len, data_len),
|
||||
27 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
||||
28 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
|
||||
29 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
|
||||
31 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
|
||||
32 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
|
||||
33 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
|
||||
35 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
|
||||
37 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
|
||||
38 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
|
||||
39 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
|
||||
40 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
|
||||
41 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||
42 => {
|
||||
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
|
||||
}
|
||||
39 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
|
||||
40 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
|
||||
41 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||
42 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
||||
43 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||
44 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||
45 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||
46 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
|
||||
47 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
||||
48 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||
49 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
|
||||
50 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
|
||||
44 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
|
||||
45 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
|
||||
46 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||
47 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
||||
48 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||
49 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||
50 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||
51 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
|
||||
52 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
||||
53 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||
54 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
|
||||
55 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -2868,19 +3063,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 {
|
||||
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(
|
||||
14 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
|
||||
20 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
|
||||
21 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
|
||||
22 => wire__crate__api__handle_media_services_reset_with_route_impl(
|
||||
ptr,
|
||||
rust_vec_len,
|
||||
data_len,
|
||||
),
|
||||
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
|
||||
25 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
|
||||
29 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
|
||||
31 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
|
||||
38 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
||||
23 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
|
||||
30 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
|
||||
34 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
|
||||
36 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
|
||||
43 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -3445,7 +3640,6 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeIosVoiceProcessingMode
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
Self::PlatformVoiceProcessing => 0.into_dart(),
|
||||
Self::SonoraExperimental => 1.into_dart(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -4215,7 +4409,6 @@ impl SseEncode for crate::api::BridgeIosVoiceProcessingMode {
|
||||
<i32>::sse_encode(
|
||||
match self {
|
||||
crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => 0,
|
||||
crate::api::BridgeIosVoiceProcessingMode::SonoraExperimental => 1,
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
@@ -4559,6 +4752,16 @@ impl SseEncode for Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for Option<Vec<u8>> {
|
||||
// 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 {
|
||||
<Vec<u8>>::sse_encode(value, serializer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::PermissionStateKind {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
|
||||
@@ -110,9 +110,13 @@ impl From<chanora_core::CoreError> for BridgeError {
|
||||
code,
|
||||
message,
|
||||
}) => BridgeError::ServerRejected { code, message },
|
||||
chanora_core::CoreError::Protocol(chanora_core::ProtocolError::FileTransfer(p)) => {
|
||||
BridgeError::Connection(format!("file transfer: {p}"))
|
||||
}
|
||||
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
|
||||
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
|
||||
chanora_core::CoreError::Storage(s) => BridgeError::Connection(format!("storage: {s}")),
|
||||
chanora_core::CoreError::Cache(c) => BridgeError::Connection(format!("cache: {c}")),
|
||||
other => BridgeError::Unmapped(format!("{other}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "chanora_cache"
|
||||
description = "Chanora disposable content-addressed blob cache for avatars and icons"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cacache = "13"
|
||||
thiserror.workspace = true
|
||||
tokio = { version = "1", features = ["fs", "rt"] }
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["rt", "macros", "time"] }
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Disposable content-addressed blob cache for avatar and icon files.
|
||||
//!
|
||||
//! Wraps [`cacache`] for production-tested crash safety and integrity
|
||||
//! verification. The on-disk layout is managed by cacache (content-v2,
|
||||
//! index-v2). Chanora maps protocol keys (`av_<md5>`, `ic_<crc32>`) to
|
||||
//! cacache string keys.
|
||||
//!
|
||||
//! This crate is intentionally separate from `chanora_storage`:
|
||||
//! storage owns persistent identity/bookmark data; cache owns
|
||||
//! reconstructible, disposable blob data with different durability
|
||||
//! and backup semantics.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Errors raised by the blob cache.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BlobCacheError {
|
||||
/// Filesystem I/O error.
|
||||
#[error("io: {0}")]
|
||||
Io(String),
|
||||
/// Key validation error.
|
||||
#[error("invalid key: {0}")]
|
||||
InvalidKey(String),
|
||||
}
|
||||
|
||||
/// Content-addressed blob cache backed by cacache.
|
||||
pub struct BlobCache {
|
||||
cache_dir: PathBuf,
|
||||
/// Maximum total cache size in bytes. 0 = no limit.
|
||||
max_bytes: u64,
|
||||
}
|
||||
|
||||
/// Avatar blob prefix.
|
||||
pub const PREFIX_AVATAR: &str = "av_";
|
||||
/// Icon blob prefix.
|
||||
pub const PREFIX_ICON: &str = "ic_";
|
||||
|
||||
impl BlobCache {
|
||||
/// Create or open a [`BlobCache`] rooted at `cache_dir/chanora/`.
|
||||
///
|
||||
/// Creates the cacache directory. `max_bytes` sets the eviction
|
||||
/// threshold; 0 means no automatic eviction.
|
||||
pub fn new(cache_dir: impl AsRef<Path>, max_bytes: u64) -> Result<Self, BlobCacheError> {
|
||||
let cache_dir = cache_dir.as_ref().join("chanora").join("blobs");
|
||||
// cacache creates the directory on first write, but we create
|
||||
// it eagerly so total_size() works before any writes.
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.map_err(|e| BlobCacheError::Io(format!("mkdir cache: {e}")))?;
|
||||
Ok(Self {
|
||||
cache_dir,
|
||||
max_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Store a blob. `prefix` is [`PREFIX_AVATAR`] or [`PREFIX_ICON`].
|
||||
/// `key` is the content hash (MD5 hex for avatars, unsigned
|
||||
/// decimal CRC32 for icons).
|
||||
///
|
||||
/// Cacache handles dedup and integrity internally.
|
||||
pub async fn put(
|
||||
&self,
|
||||
prefix: &str,
|
||||
key: &str,
|
||||
data: &[u8],
|
||||
) -> Result<(), BlobCacheError> {
|
||||
validate_key(prefix, key)?;
|
||||
let cache_key = format!("{prefix}{key}");
|
||||
cacache::write(&self.cache_dir, &cache_key, data)
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("cacache write: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a blob. Returns `None` if not cached.
|
||||
///
|
||||
/// Cacache verifies SSRI integrity on every read.
|
||||
pub async fn get(&self, prefix: &str, key: &str) -> Result<Option<Vec<u8>>, BlobCacheError> {
|
||||
validate_key(prefix, key)?;
|
||||
let cache_key = format!("{prefix}{key}");
|
||||
match cacache::read(&self.cache_dir, &cache_key).await {
|
||||
Ok(data) => Ok(Some(data)),
|
||||
Err(cacache::Error::EntryNotFound(_, _)) => Ok(None),
|
||||
Err(e) => {
|
||||
// Integrity failure or I/O error — remove corrupt entry.
|
||||
tracing::warn!(
|
||||
target: "chanora_cache",
|
||||
key = %cache_key,
|
||||
error = %e,
|
||||
"cache read failed; removing entry"
|
||||
);
|
||||
let _ = cacache::remove(&self.cache_dir, &cache_key).await;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a specific blob.
|
||||
pub async fn remove(&self, prefix: &str, key: &str) -> Result<(), BlobCacheError> {
|
||||
validate_key(prefix, key)?;
|
||||
let cache_key = format!("{prefix}{key}");
|
||||
cacache::remove(&self.cache_dir, &cache_key)
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("cacache remove: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all blobs.
|
||||
pub async fn clear(&self) -> Result<(), BlobCacheError> {
|
||||
let path = self.cache_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if path.exists() {
|
||||
std::fs::remove_dir_all(&path)
|
||||
.map_err(|e| BlobCacheError::Io(format!("clear cache: {e}")))?;
|
||||
std::fs::create_dir_all(&path)
|
||||
.map_err(|e| BlobCacheError::Io(format!("recreate cache dir: {e}")))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("clear task: {e}")))?
|
||||
}
|
||||
|
||||
/// Return total bytes used by all blobs.
|
||||
///
|
||||
/// Walks cacache entries and sums sizes.
|
||||
pub async fn total_size(&self) -> Result<u64, BlobCacheError> {
|
||||
let cache_dir = self.cache_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut total: u64 = 0;
|
||||
for entry in cacache::list_sync(&cache_dir) {
|
||||
match entry {
|
||||
Ok(meta) => total += meta.size as u64,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "chanora_cache",
|
||||
error = %e,
|
||||
"skipping bad entry during size scan"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("total_size task: {e}")))?
|
||||
}
|
||||
|
||||
/// Evict oldest entries by timestamp until total size is under
|
||||
/// `max_bytes`. Call on startup or periodically. No-op if
|
||||
/// `max_bytes` is 0.
|
||||
pub async fn evict(&self) -> Result<(), BlobCacheError> {
|
||||
if self.max_bytes == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let cache_dir = self.cache_dir.clone();
|
||||
let max_bytes = self.max_bytes;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut entries: Vec<(String, usize, u128)> = Vec::new();
|
||||
for entry in cacache::list_sync(&cache_dir) {
|
||||
match entry {
|
||||
Ok(meta) => {
|
||||
entries.push((meta.key, meta.size, meta.time));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "chanora_cache",
|
||||
error = %e,
|
||||
"skipping bad entry during eviction scan"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let total: usize = entries.iter().map(|(_, s, _)| *s).sum();
|
||||
if total as u64 <= max_bytes {
|
||||
return Ok(());
|
||||
}
|
||||
entries.sort_by_key(|(_, _, t)| *t);
|
||||
let mut freed: usize = 0;
|
||||
let target = total - max_bytes as usize;
|
||||
for (key, size, _) in entries {
|
||||
if freed >= target {
|
||||
break;
|
||||
}
|
||||
let _ = cacache::remove_sync(&cache_dir, &key);
|
||||
freed += size;
|
||||
}
|
||||
tracing::info!(
|
||||
target: "chanora_cache",
|
||||
freed_bytes = freed,
|
||||
"evicted oldest blobs"
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| BlobCacheError::Io(format!("evict task: {e}")))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate key format to prevent malformed entries.
|
||||
fn validate_key(prefix: &str, key: &str) -> Result<(), BlobCacheError> {
|
||||
if !matches!(prefix, PREFIX_AVATAR | PREFIX_ICON) {
|
||||
return Err(BlobCacheError::InvalidKey(format!("bad prefix: {prefix}")));
|
||||
}
|
||||
match prefix {
|
||||
PREFIX_AVATAR => {
|
||||
// MD5 hex = exactly 32 hex chars.
|
||||
if key.len() != 32 || !key.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(BlobCacheError::InvalidKey(format!(
|
||||
"avatar key must be 32 hex chars, got: {key}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
PREFIX_ICON => {
|
||||
// Unsigned CRC32 = decimal digits.
|
||||
if key.is_empty() || !key.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(BlobCacheError::InvalidKey(format!(
|
||||
"icon key must be decimal digits, got: {key}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tempdir() -> tempfile::TempDir {
|
||||
tempfile::Builder::new()
|
||||
.prefix("chanora_cache_test_")
|
||||
.tempdir()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_get_roundtrip() {
|
||||
let tmp = tempdir();
|
||||
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||
assert!(cache
|
||||
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
cache
|
||||
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"avatar-bytes")
|
||||
.await
|
||||
.unwrap();
|
||||
let data = cache
|
||||
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(data.as_deref(), Some(b"avatar-bytes".as_slice()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_missing_returns_none() {
|
||||
let tmp = tempdir();
|
||||
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||
assert!(cache
|
||||
.get(PREFIX_AVATAR, "00000000000000000000000000000000")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_removes_all() {
|
||||
let tmp = tempdir();
|
||||
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||
cache
|
||||
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"data")
|
||||
.await
|
||||
.unwrap();
|
||||
cache.put(PREFIX_ICON, "12345", b"icon").await.unwrap();
|
||||
cache.clear().await.unwrap();
|
||||
assert_eq!(cache.total_size().await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn total_size_accounts_for_all_entries() {
|
||||
let tmp = tempdir();
|
||||
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||
cache
|
||||
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"12345")
|
||||
.await
|
||||
.unwrap();
|
||||
cache.put(PREFIX_ICON, "99999", b"12").await.unwrap();
|
||||
assert_eq!(cache.total_size().await.unwrap(), 5 + 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_key_rejected() {
|
||||
let tmp = tempdir();
|
||||
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||
// Too short for MD5.
|
||||
assert!(cache.put(PREFIX_AVATAR, "abc", b"data").await.is_err());
|
||||
// Non-hex in MD5.
|
||||
assert!(cache
|
||||
.put(PREFIX_AVATAR, "g".repeat(32).as_str(), b"data")
|
||||
.await
|
||||
.is_err());
|
||||
// Non-digit in icon key.
|
||||
assert!(cache.put(PREFIX_ICON, "12a45", b"data").await.is_err());
|
||||
// Bad prefix.
|
||||
assert!(cache.put("xx_", "abc", b"data").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evict_deletes_oldest_until_under_cap() {
|
||||
let tmp = tempdir();
|
||||
// 10 byte cap.
|
||||
let cache = BlobCache::new(&tmp, 10).unwrap();
|
||||
cache
|
||||
.put(
|
||||
PREFIX_AVATAR,
|
||||
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
|
||||
b"12345678",
|
||||
)
|
||||
.await
|
||||
.unwrap(); // 8 bytes
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
cache
|
||||
.put(PREFIX_ICON, "11111", b"12345")
|
||||
.await
|
||||
.unwrap(); // 5 bytes → total 13, over cap
|
||||
cache.evict().await.unwrap();
|
||||
// Oldest (avatar) should be evicted.
|
||||
assert!(cache
|
||||
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(cache.get(PREFIX_ICON, "11111").await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_deletes_entry() {
|
||||
let tmp = tempdir();
|
||||
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||
cache
|
||||
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"data")
|
||||
.await
|
||||
.unwrap();
|
||||
cache
|
||||
.remove(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(cache
|
||||
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ use base64::prelude::*;
|
||||
use chanora_resolver::ChanoraResolver;
|
||||
use futures::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -32,7 +33,8 @@ use tsclientlib::messages::s2c::{InClientDbInfoPart, InMessage};
|
||||
use tsclientlib::prelude::*;
|
||||
use tsclientlib::{
|
||||
ChannelId as TsChannelId, ClientId as TsClientId, Connection, ConnectionStats,
|
||||
DisconnectOptions, Identity, MessageHandle, OutCommandExt, StreamItem, Version,
|
||||
DisconnectOptions, FileDownloadResult, FiletransferHandle, Identity, MessageHandle,
|
||||
OutCommandExt, StreamItem, Version,
|
||||
};
|
||||
use tsproto_packets::packets::{Direction, Flags, InAudioBuf, OutCommand, OutPacket, PacketType};
|
||||
use tsproto_types::ClientType;
|
||||
@@ -60,6 +62,9 @@ type PendingMoves = HashMap<
|
||||
),
|
||||
>;
|
||||
|
||||
type PendingDownloads =
|
||||
HashMap<FiletransferHandle, oneshot::Sender<Result<Vec<u8>, ProtocolError>>>;
|
||||
|
||||
struct EventChannels {
|
||||
voice_in: mpsc::Sender<InboundVoice>,
|
||||
chat: mpsc::Sender<ChatMessage>,
|
||||
@@ -207,6 +212,10 @@ enum Request {
|
||||
client_id: u64,
|
||||
reply: oneshot::Sender<Result<ClientProfile, ProtocolError>>,
|
||||
},
|
||||
DownloadFile {
|
||||
path: String,
|
||||
reply: oneshot::Sender<Result<Vec<u8>, ProtocolError>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
|
||||
@@ -366,6 +375,26 @@ impl ProtocolClient {
|
||||
.map_err(|_| ProtocolError::Lost("client_profile reply dropped".to_string()))?
|
||||
}
|
||||
|
||||
async fn download_file(&self, path: String) -> Result<Vec<u8>, ProtocolError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send(Request::DownloadFile { path, reply: tx })
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("download_file reply dropped".to_string()))?
|
||||
}
|
||||
|
||||
/// Download the current avatar bytes for a TeamSpeak client UID.
|
||||
pub async fn download_avatar(&self, client_uid: &str) -> Result<Vec<u8>, ProtocolError> {
|
||||
self.download_file(avatar_download_path(client_uid)).await
|
||||
}
|
||||
|
||||
/// Download the current channel/server icon bytes for an icon id.
|
||||
pub async fn download_icon(&self, icon_id: u64) -> Result<Vec<u8>, ProtocolError> {
|
||||
self.download_file(icon_download_path(icon_id)).await
|
||||
}
|
||||
|
||||
/// Disconnect cleanly. Blocks until the task exits.
|
||||
pub async fn disconnect(self) {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
@@ -713,6 +742,7 @@ async fn connection_task(
|
||||
// deadline so a server that never replies doesn't leak the
|
||||
// reply channel — at most 3 s of pending state per move.
|
||||
let mut pending_moves: PendingMoves = HashMap::new();
|
||||
let mut pending_downloads: PendingDownloads = HashMap::new();
|
||||
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
|
||||
let mut poke_limiter = PokeLimiter::new();
|
||||
|
||||
@@ -737,6 +767,12 @@ async fn connection_task(
|
||||
StreamItem::Audio(buf) => {
|
||||
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
||||
}
|
||||
StreamItem::FileDownload(handle, result) => {
|
||||
handle_download_stream_item(&mut pending_downloads, handle, result).await;
|
||||
}
|
||||
StreamItem::FiletransferFailed(handle, error) => {
|
||||
handle_download_failure(&mut pending_downloads, handle, error);
|
||||
}
|
||||
other => handle_non_audio_stream_item(
|
||||
&con,
|
||||
other,
|
||||
@@ -843,12 +879,25 @@ async fn connection_task(
|
||||
client_id,
|
||||
&channels,
|
||||
&mut pending_moves,
|
||||
&mut pending_downloads,
|
||||
&mut voice_activity,
|
||||
&mut poke_limiter,
|
||||
)
|
||||
.await;
|
||||
let _ = reply.send(r);
|
||||
}
|
||||
Ok(Request::DownloadFile { path, reply }) => {
|
||||
match con.download_file(TsChannelId(0), &path, None, None) {
|
||||
Ok(handle) => {
|
||||
pending_downloads.insert(handle, reply);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reply.send(Err(ProtocolError::FileTransfer(format!(
|
||||
"start download {path}: {e}"
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Request::Disconnect(reply)) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||
@@ -1024,6 +1073,47 @@ fn handle_non_audio_stream_item(
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_download_stream_item(
|
||||
pending_downloads: &mut PendingDownloads,
|
||||
handle: FiletransferHandle,
|
||||
result: FileDownloadResult,
|
||||
) {
|
||||
if let Some(reply) = pending_downloads.remove(&handle) {
|
||||
let _ = reply.send(read_download_bytes(result).await);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_download_failure(
|
||||
pending_downloads: &mut PendingDownloads,
|
||||
handle: FiletransferHandle,
|
||||
error: tsclientlib::Error,
|
||||
) {
|
||||
if let Some(reply) = pending_downloads.remove(&handle) {
|
||||
let _ = reply.send(Err(ProtocolError::FileTransfer(error.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DOWNLOAD_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
async fn read_download_bytes(result: FileDownloadResult) -> Result<Vec<u8>, ProtocolError> {
|
||||
if result.size > MAX_DOWNLOAD_SIZE {
|
||||
return Err(ProtocolError::FileTransfer(format!(
|
||||
"download too large: {} bytes (max {})",
|
||||
result.size, MAX_DOWNLOAD_SIZE
|
||||
)));
|
||||
}
|
||||
let size = usize::try_from(result.size).map_err(|_| {
|
||||
ProtocolError::FileTransfer(format!("download too large to buffer: {} bytes", result.size))
|
||||
})?;
|
||||
let mut stream = result.stream;
|
||||
let mut bytes = vec![0_u8; size];
|
||||
stream
|
||||
.read_exact(&mut bytes)
|
||||
.await
|
||||
.map_err(|e| ProtocolError::FileTransfer(e.to_string()))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
async fn resolve_server_socket(address: &str) -> Result<SocketAddr, ProtocolError> {
|
||||
let resolver = ChanoraResolver::new().map_err(|err| ProtocolError::DnsFailed {
|
||||
host: address.to_string(),
|
||||
@@ -1174,6 +1264,7 @@ async fn fetch_client_profile(
|
||||
client_id: u64,
|
||||
channels: &EventChannels,
|
||||
pending_moves: &mut PendingMoves,
|
||||
pending_downloads: &mut PendingDownloads,
|
||||
voice_activity: &mut HashMap<u64, Instant>,
|
||||
poke_limiter: &mut PokeLimiter,
|
||||
) -> Result<ClientProfile, ProtocolError> {
|
||||
@@ -1220,6 +1311,7 @@ async fn fetch_client_profile(
|
||||
build_command("servergrouplist", &[], &[]),
|
||||
channels,
|
||||
pending_moves,
|
||||
pending_downloads,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
@@ -1231,6 +1323,7 @@ async fn fetch_client_profile(
|
||||
build_command("channelgrouplist", &[], &[]),
|
||||
channels,
|
||||
pending_moves,
|
||||
pending_downloads,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
@@ -1246,6 +1339,7 @@ async fn fetch_client_profile(
|
||||
),
|
||||
channels,
|
||||
pending_moves,
|
||||
pending_downloads,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
@@ -1265,6 +1359,7 @@ async fn fetch_client_profile(
|
||||
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
|
||||
channels,
|
||||
pending_moves,
|
||||
pending_downloads,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
@@ -1285,6 +1380,7 @@ async fn fetch_client_profile(
|
||||
database_id,
|
||||
channels,
|
||||
pending_moves,
|
||||
pending_downloads,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
@@ -1448,6 +1544,7 @@ async fn request_messages(
|
||||
command: OutCommand,
|
||||
channels: &EventChannels,
|
||||
pending_moves: &mut PendingMoves,
|
||||
pending_downloads: &mut PendingDownloads,
|
||||
voice_activity: &mut HashMap<u64, Instant>,
|
||||
poke_limiter: &mut PokeLimiter,
|
||||
) -> Result<Vec<InMessage>, ProtocolError> {
|
||||
@@ -1485,6 +1582,12 @@ async fn request_messages(
|
||||
StreamItem::Audio(buf) => {
|
||||
handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await;
|
||||
}
|
||||
StreamItem::FileDownload(handle, result) => {
|
||||
handle_download_stream_item(pending_downloads, handle, result).await;
|
||||
}
|
||||
StreamItem::FiletransferFailed(handle, error) => {
|
||||
handle_download_failure(pending_downloads, handle, error);
|
||||
}
|
||||
other => handle_non_audio_stream_item(
|
||||
con,
|
||||
other,
|
||||
@@ -1503,6 +1606,7 @@ async fn request_client_db_info(
|
||||
dbid: tsclientlib::ClientDbId,
|
||||
channels: &EventChannels,
|
||||
pending_moves: &mut PendingMoves,
|
||||
pending_downloads: &mut PendingDownloads,
|
||||
voice_activity: &mut HashMap<u64, Instant>,
|
||||
poke_limiter: &mut PokeLimiter,
|
||||
) -> Result<InClientDbInfoPart, ProtocolError> {
|
||||
@@ -1511,6 +1615,7 @@ async fn request_client_db_info(
|
||||
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
|
||||
channels,
|
||||
pending_moves,
|
||||
pending_downloads,
|
||||
voice_activity,
|
||||
poke_limiter,
|
||||
)
|
||||
@@ -1568,6 +1673,14 @@ fn uid_to_avatar_path(uid_b64: &str) -> String {
|
||||
rendered
|
||||
}
|
||||
|
||||
fn avatar_download_path(client_uid: &str) -> String {
|
||||
format!("/avatar_{}", uid_to_avatar_path(client_uid))
|
||||
}
|
||||
|
||||
fn icon_download_path(icon_id: u64) -> String {
|
||||
format!("/icon_{icon_id}")
|
||||
}
|
||||
|
||||
fn find_client_by_id<'a>(
|
||||
clients: impl IntoIterator<Item = &'a Client>,
|
||||
client_id: u64,
|
||||
@@ -1951,10 +2064,11 @@ const _: () = {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
bounded_drain_stream, client_profile_refresh_plan, drain_voice_packets_for_tick,
|
||||
is_server_query_client_type, send_with_timeout, server_socket_from_config,
|
||||
sort_channels_tree_by, std_duration_millis, ConnectConfig, ProtocolClient, Request,
|
||||
SendTimeoutError, DISCONNECT_REPLY_TIMEOUT,
|
||||
avatar_download_path, bounded_drain_stream, client_profile_refresh_plan,
|
||||
drain_voice_packets_for_tick, icon_download_path, is_server_query_client_type,
|
||||
send_with_timeout, server_socket_from_config, sort_channels_tree_by,
|
||||
std_duration_millis, ConnectConfig, ProtocolClient, Request, SendTimeoutError,
|
||||
DISCONNECT_REPLY_TIMEOUT,
|
||||
};
|
||||
use futures::stream;
|
||||
use std::time::Duration;
|
||||
@@ -2052,6 +2166,16 @@ mod tests {
|
||||
assert!(plan.needs_channel_groups);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avatar_download_path_uses_uid_hex_encoding() {
|
||||
assert_eq!(avatar_download_path("AQID"), "/avatar_abacad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_download_path_uses_unsigned_icon_id() {
|
||||
assert_eq!(icon_download_path(42), "/icon_42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_sort_linked_list_under_one_parent() {
|
||||
// Server emits four root-level channels in arbitrary HashMap
|
||||
|
||||
@@ -115,4 +115,8 @@ pub enum ProtocolError {
|
||||
/// should never see this; if they do, it is a mapping bug here.
|
||||
#[error("protocol backend: {0}")]
|
||||
Backend(String),
|
||||
|
||||
/// A file transfer failed while downloading protocol-owned assets.
|
||||
#[error("file transfer failed: {0}")]
|
||||
FileTransfer(String),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,737 @@
|
||||
# File Transfer Design
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Draft for review
|
||||
**Scope:** Download files from TeamSpeak-compatible servers via the native client protocol, starting with avatars and icons.
|
||||
**Direct upstream source:** `docs/architecture/sad.md` (SAD-067, SDD-MOD-009)
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Chanora needs to download files stored on TeamSpeak-compatible servers. The most visible use cases are client avatars and server/channel/client icons. The file transfer mechanism is also used for channel file browser features, but this document scopes the initial design to avatar and icon retrieval only.
|
||||
|
||||
This document describes:
|
||||
|
||||
- How the TeamSpeak file transfer protocol works.
|
||||
- How `tsclientlib` exposes it.
|
||||
- How Chanora should integrate it following the existing protocol adapter pattern.
|
||||
- How the result flows through the bridge to the Flutter UI layer.
|
||||
|
||||
Upload, channel file browsing, and file deletion are explicitly out of scope for the initial implementation.
|
||||
|
||||
## 2. Protocol Background
|
||||
|
||||
### 2.1 Two-Phase Transfer
|
||||
|
||||
TeamSpeak file transfer is a two-phase process:
|
||||
|
||||
1. **Command phase** — The client sends a command over the main encrypted UDP connection to request a transfer token (`ftkey`).
|
||||
2. **Transfer phase** — The client opens a separate TCP connection to the server's file transfer port (default `30033`) and sends the `ftkey` to authenticate the transfer. Raw bytes flow over this TCP stream.
|
||||
|
||||
### 2.2 Relevant ServerQuery Commands
|
||||
|
||||
| Command | Direction | Purpose |
|
||||
|---|---|---|
|
||||
| `ftinitdownload` | Client → Server | Initialize a download. Returns `ftkey`, `port`, `size`. |
|
||||
| `ftgetfileinfo` | Client → Server | Get metadata for one or more files. |
|
||||
| `ftgetfilelist` | Client → Server | List files in a channel's file repository. |
|
||||
| `ftinitupload` | Client → Server | Initialize an upload. |
|
||||
| `ftlist` | Client → Server | List active file transfers. |
|
||||
| `ftstop` | Client → Server | Stop a running transfer. |
|
||||
| `ftdeletefile` | Client → Server | Delete a file. |
|
||||
| `ftcreatedir` | Client → Server | Create a directory. |
|
||||
| `ftrenamefile` | Client → Server | Rename or move a file. |
|
||||
|
||||
Initial scope uses only `ftinitdownload` and `ftgetfileinfo`.
|
||||
|
||||
### 2.3 File Paths
|
||||
|
||||
Files are addressed by a path scoped to a channel ID (`cid`):
|
||||
|
||||
- `cid=0` — Server-level file repository. Avatars and icons live here.
|
||||
- `cid=N` (non-zero) — Channel-specific file repository.
|
||||
|
||||
Avatar path: `/avatar_<hex>` where `<hex>` is derived from the client's unique identifier (UID). Each byte of the base64-decoded UID is split into two nibbles, and each nibble maps to a letter `a` through `p` (0→a, 1→b, ..., 15→p).
|
||||
|
||||
Icon path: `/icon_<id>` where `<id>` is the icon's signed 64-bit integer ID. If negative, treat as unsigned for the path.
|
||||
|
||||
### 2.4 `ftinitdownload` Command
|
||||
|
||||
```
|
||||
ftinitdownload clientftfid={id} name={path} cid={channelId} cpw={password} seekpos={seek} proto=0
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `clientftfid` | `u16` | Arbitrary client-side transfer ID. |
|
||||
| `name` | `string` | File path, e.g. `/avatar_abcdef`. |
|
||||
| `cid` | `ChannelId` | Channel scope (0 = server). |
|
||||
| `cpw` | `string` | Channel password. Empty for server-level. |
|
||||
| `seekpos` | `u64` | Resume offset. 0 for a fresh download. |
|
||||
| `proto` | `u8` | Protocol version. Always 0. |
|
||||
|
||||
Server response:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `clientftfid` | `u16` | Echo of the client transfer ID. |
|
||||
| `serverftfid` | `u16` | Server-side transfer ID. |
|
||||
| `ftkey` | `string` | One-time transfer key (hex). |
|
||||
| `port` | `u16` | File transfer TCP port (usually 30033). |
|
||||
| `size` | `u64` | File size in bytes. |
|
||||
| `proto` | `u8` | Protocol version echo. |
|
||||
| `ip` | `string` (optional) | Override IP for the TCP connection. |
|
||||
|
||||
### 2.5 TCP Transfer
|
||||
|
||||
After receiving the `ftkey`, the client:
|
||||
|
||||
1. Opens a TCP connection to `server_ip:port`.
|
||||
2. Sends `ftkey` followed by a newline.
|
||||
3. Reads exactly `size` bytes of raw file data.
|
||||
4. Closes the TCP connection.
|
||||
|
||||
### 2.6 Permissions
|
||||
|
||||
File transfer requires the following permissions on the server:
|
||||
|
||||
| Permission | Needed for |
|
||||
|---|---|
|
||||
| `i_ft_file_download_power` | Downloading files. |
|
||||
| `i_ft_needed_file_download_power` | Required download power on the channel/server. |
|
||||
| `b_ft_ignore_password` | Bypassing channel passwords (not needed for avatars). |
|
||||
|
||||
Avatar downloads typically require only basic download power because avatars are in the server-level repository (`cid=0`), which is generally accessible.
|
||||
|
||||
### 2.7 Avatar Detection
|
||||
|
||||
When a client connects or updates, the server sends `client_flag_avatar` as a string (the avatar hash). If non-empty, the client has an avatar. The avatar is downloaded from `/avatar_<hex>` where `<hex>` is computed from the client's UID (not from the hash string itself — the hash is just a presence indicator).
|
||||
|
||||
## 3. tsclientlib Support
|
||||
|
||||
`tsclientlib` implements file transfer natively. The library handles the entire command + TCP flow internally:
|
||||
|
||||
### 3.1 Public API
|
||||
|
||||
```rust
|
||||
// tsclientlib/src/lib.rs (relevant signatures)
|
||||
impl Connection {
|
||||
pub fn download_file(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
path: &str,
|
||||
channel_password: Option<&str>,
|
||||
seek_position: Option<u64>,
|
||||
) -> Result<FiletransferHandle>;
|
||||
|
||||
pub fn upload_file(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
path: &str,
|
||||
channel_password: Option<&str>,
|
||||
size: u64,
|
||||
overwrite: bool,
|
||||
resume: bool,
|
||||
) -> Result<FiletransferHandle>;
|
||||
}
|
||||
```
|
||||
|
||||
`download_file` sends the `ftinitdownload` command and returns a `FiletransferHandle(u16)` immediately. The actual transfer completes asynchronously.
|
||||
|
||||
### 3.2 Stream Items
|
||||
|
||||
The connection's event stream emits:
|
||||
|
||||
| StreamItem | When | Data |
|
||||
|---|---|---|
|
||||
| `StreamItem::FileDownload(FileDownloadResult)` | Server responds with `ftkey`; TCP connected and `ftkey` written | `{ size: u64, stream: TcpStream }` |
|
||||
| `StreamItem::FileUpload(FileUploadResult)` | Upload ready | `{ seek_position: u64, stream: TcpStream }` |
|
||||
| `StreamItem::FiletransferFailed(FiletransferHandle, Error)` | Transfer failed | Handle + error |
|
||||
|
||||
When `FileDownload` fires, tsclientlib has already:
|
||||
|
||||
1. Sent `ftinitdownload` over the encrypted UDP command channel.
|
||||
2. Received the `ftkey`, `port`, and `size` from the server.
|
||||
3. Opened a TCP connection to `server:port`.
|
||||
4. Written the `ftkey` to the TCP socket.
|
||||
|
||||
The `TcpStream` in `FileDownloadResult` is ready to read; Chanora only needs to read exactly `size` bytes.
|
||||
|
||||
### 3.3 Avatar Helper
|
||||
|
||||
`tsproto-types` provides `Uid::as_avatar()` which computes the avatar filename from a UID. Chanora's existing `uid_to_avatar_path()` in `adapter.rs` does the same thing independently.
|
||||
|
||||
### 3.4 Doc-Comment Examples
|
||||
|
||||
tsclientlib's source contains usage examples in doc comments:
|
||||
|
||||
```rust
|
||||
/// Download an icon:
|
||||
/// con.download_file(ChannelId(0), &format!("/icon_{}", icon_id), None, None)
|
||||
|
||||
/// Upload an avatar:
|
||||
/// con.upload_file(ChannelId(0), "/avatar", None, data.len() as u64, true, false)
|
||||
```
|
||||
|
||||
## 4. Architecture Integration
|
||||
|
||||
### 4.1 Existing Pattern
|
||||
|
||||
The protocol adapter (`crates/chanora_protocol`) uses a single tokio task that owns the `tsclientlib::Connection`. All operations follow this pattern:
|
||||
|
||||
1. Define a `Request` enum variant with parameters and a `oneshot::Sender` for the reply.
|
||||
2. Send the request through the `mpsc` channel to the connection task.
|
||||
3. The connection task calls tsclientlib and resolves the oneshot.
|
||||
|
||||
File transfer fits this pattern exactly. The only difference is that the result arrives asynchronously via `StreamItem::FileDownload` rather than immediately from the command call.
|
||||
|
||||
### 4.2 Design
|
||||
|
||||
The file transfer integration adds:
|
||||
|
||||
1. **`Request` variants** for file download.
|
||||
2. **A pending-downloads map** (`HashMap<FiletransferHandle, DownloadContext>`) in the connection task, mirroring the existing `pending_moves` pattern.
|
||||
3. **`StreamItem::FileDownload` and `StreamItem::FiletransferFailed`** handling in the event loop.
|
||||
4. **New DTOs** for file transfer results.
|
||||
5. **Convenience methods** on `ProtocolClient` for avatar and icon downloads.
|
||||
|
||||
### 4.3 Layer Responsibilities
|
||||
|
||||
| Layer | Responsibility |
|
||||
|---|---|
|
||||
| `chanora_protocol` | Call `tsclientlib::download_file`, track pending transfers, read `TcpStream`, return bytes. No tsclientlib types leak. |
|
||||
| `chanora_core` | Orchestrate when to download (e.g., on profile fetch or on avatar cache miss). |
|
||||
| `chanora_bridge` | Expose typed `download_avatar` / `download_icon` commands to Flutter. |
|
||||
| Flutter UI | Call bridge, display with `Image.memory()`. Cache in memory/image cache. |
|
||||
|
||||
### 4.4 Error Mapping
|
||||
|
||||
File transfer errors map to the existing `ProtocolError` variants:
|
||||
|
||||
| tsclientlib error | ProtocolError |
|
||||
|---|---|
|
||||
| Permission denied (TS3 error code) | `ServerRejected { code, message }` |
|
||||
| File not found | `ServerRejected { code, message }` |
|
||||
| Network/TCP failure | `Backend(String)` |
|
||||
| Timeout | `Timeout` |
|
||||
| Connection lost mid-transfer | `Lost(String)` |
|
||||
|
||||
## 5. Detailed Design
|
||||
|
||||
### 5.1 New Types in `dto.rs`
|
||||
|
||||
```rust
|
||||
/// A downloaded file's raw content and metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DownloadedFile {
|
||||
/// Raw file bytes.
|
||||
pub data: Vec<u8>,
|
||||
/// The server path that was requested.
|
||||
pub path: String,
|
||||
/// Channel ID the file was downloaded from.
|
||||
pub channel_id: u64,
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 New Request Variants in `adapter.rs`
|
||||
|
||||
```rust
|
||||
enum Request {
|
||||
// ... existing variants ...
|
||||
|
||||
/// Download a file from the server's file repository.
|
||||
DownloadFile {
|
||||
/// Channel ID. 0 for server-level (avatars, icons).
|
||||
channel_id: u64,
|
||||
/// File path, e.g. "/avatar_abcdef" or "/icon_12345".
|
||||
path: String,
|
||||
/// Channel password. None for server-level files.
|
||||
channel_password: Option<String>,
|
||||
/// Reply channel for the result.
|
||||
reply: oneshot::Sender<Result<DownloadedFile, ProtocolError>>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Pending Downloads Map
|
||||
|
||||
```rust
|
||||
type PendingDownloads = HashMap<tsclientlib::FiletransferHandle, PendingDownload>;
|
||||
|
||||
struct PendingDownload {
|
||||
path: String,
|
||||
channel_id: u64,
|
||||
reply: oneshot::Sender<Result<DownloadedFile, ProtocolError>>,
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Event Loop Handling
|
||||
|
||||
In the connection task's main loop, add handling for file transfer stream items:
|
||||
|
||||
```rust
|
||||
// In handle_non_audio_stream_item or in the main loop:
|
||||
StreamItem::FileDownload(result) => {
|
||||
// result: FileDownloadResult { size, stream }
|
||||
// Look up the handle in pending_downloads
|
||||
// Use tokio::io::AsyncReadExt::read_exact to read 'size' bytes
|
||||
// Resolve the oneshot with DownloadedFile
|
||||
}
|
||||
StreamItem::FiletransferFailed(handle, error) => {
|
||||
// Look up the handle in pending_downloads
|
||||
// Resolve the oneshot with ProtocolError::Backend
|
||||
}
|
||||
```
|
||||
|
||||
The TCP read from the `TcpStream` is an async operation. Since the connection task already runs in a tokio context, the read can be done inline. However, for large files this would block the main event loop. Two approaches:
|
||||
|
||||
**Option A: Read inline (simple, good for small files like avatars)**
|
||||
|
||||
Avatars are typically under 100 KB. Reading them inline in the event loop is acceptable and avoids complexity.
|
||||
|
||||
**Option B: Spawn a reader task**
|
||||
|
||||
For future channel-file-browser support with potentially large files, spawn a separate tokio task that reads the stream and sends the result back.
|
||||
|
||||
**Recommendation:** Start with Option A. The initial scope is avatars and icons (small files). Refactor to Option B when channel file browsing is implemented.
|
||||
|
||||
### 5.5 Request Handling
|
||||
|
||||
When the connection task receives `Request::DownloadFile`:
|
||||
|
||||
```rust
|
||||
Ok(Request::DownloadFile { channel_id, path, channel_password, reply }) => {
|
||||
let ts_channel_id = TsChannelId(channel_id);
|
||||
match con.download_file(ts_channel_id, &path, channel_password.as_deref(), None) {
|
||||
Ok(handle) => {
|
||||
pending_downloads.insert(handle, PendingDownload {
|
||||
path,
|
||||
channel_id,
|
||||
reply,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reply.send(Err(ProtocolError::Backend(
|
||||
format!("download_file init: {e}")
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.6 Public API on `ProtocolClient`
|
||||
|
||||
```rust
|
||||
impl ProtocolClient {
|
||||
/// Download a file from the server's file repository.
|
||||
/// `channel_id` 0 means server-level (avatars, icons).
|
||||
pub async fn download_file(
|
||||
&self,
|
||||
channel_id: u64,
|
||||
path: String,
|
||||
channel_password: Option<String>,
|
||||
) -> Result<DownloadedFile, ProtocolError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send(Request::DownloadFile { channel_id, path, channel_password, reply: tx })
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("download_file reply dropped".to_string()))?
|
||||
}
|
||||
|
||||
/// Download a client's avatar image. Returns raw image bytes.
|
||||
/// Pass the `avatar_path` from `ClientProfile`.
|
||||
pub async fn download_avatar(
|
||||
&self,
|
||||
avatar_path: String,
|
||||
) -> Result<DownloadedFile, ProtocolError> {
|
||||
self.download_file(0, avatar_path, None).await
|
||||
}
|
||||
|
||||
/// Download a server, channel, or client icon by its icon ID.
|
||||
pub async fn download_icon(
|
||||
&self,
|
||||
icon_id: i64,
|
||||
) -> Result<DownloadedFile, ProtocolError> {
|
||||
let unsigned_id = icon_id as u64;
|
||||
let path = format!("/icon_{}", unsigned_id);
|
||||
self.download_file(0, path, None).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.7 Exports in `lib.rs`
|
||||
|
||||
```rust
|
||||
pub use dto::DownloadedFile;
|
||||
```
|
||||
|
||||
### 5.8 Bridge Layer
|
||||
|
||||
In `crates/chanora_bridge/src/api.rs`, add:
|
||||
|
||||
```rust
|
||||
pub async fn download_avatar(&self, avatar_path: String) -> Result<Vec<u8>, BridgeError> {
|
||||
self.protocol
|
||||
.download_avatar(avatar_path)
|
||||
.await
|
||||
.map(|file| file.data)
|
||||
.map_err(BridgeError::Protocol)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.9 Flutter Integration
|
||||
|
||||
Flutter side:
|
||||
|
||||
1. Call `clientProfile()` to get `ClientProfile` (already exists).
|
||||
2. Check if `avatarPath` is non-empty.
|
||||
3. Call bridge `downloadAvatar(avatarPath)` to get `Uint8List`.
|
||||
4. Display with `Image.memory(bytes)`.
|
||||
|
||||
Caching strategy:
|
||||
|
||||
- In-memory: Use Flutter's standard `ImageCache` or a simple `Map<String, Uint8List>` keyed by avatar path.
|
||||
- Disk: Consider caching to local storage for offline display. This is a follow-up decision, not MVP scope.
|
||||
- The avatar path already encodes the UID, so it can serve as a cache key.
|
||||
|
||||
## 6. Avatar Path Computation
|
||||
|
||||
Chanora already has this implemented in `adapter.rs`:
|
||||
|
||||
```rust
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
This maps each nibble to `a` through `p` (0→a, 1→b, ..., 15→p), matching the canonical TeamSpeak implementation.
|
||||
|
||||
The full avatar path is constructed as:
|
||||
|
||||
```rust
|
||||
let avatar_path = if client.avatar_hash.is_empty() || unique_id.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("/avatar_{}", uid_to_avatar_path(&unique_id))
|
||||
};
|
||||
```
|
||||
|
||||
This is already correct and used in `ClientProfile.avatar_path`. No changes needed.
|
||||
|
||||
## 7. Threading and Concurrency
|
||||
|
||||
| Concern | Design |
|
||||
|---|---|
|
||||
| TCP read blocking the event loop | For avatar/icon sizes (< 100 KB typically), inline async read is acceptable. Spawn a reader task for larger files when channel file browsing is added. |
|
||||
| Multiple concurrent downloads | `pending_downloads` is a HashMap keyed by `FiletransferHandle`. Multiple downloads can be in flight simultaneously. tsclientlib assigns unique handles. |
|
||||
| Download timeout | Add a deadline to pending downloads (e.g., 30 seconds). Sweep expired entries similar to the existing `pending_moves` sweep. |
|
||||
| Cancellation on disconnect | When the connection task exits, all pending oneshot senders are dropped, which resolves the caller's await with a `RecvError`. The caller maps this to `ProtocolError::Lost`. |
|
||||
|
||||
## 8. Diagnostic and Security Considerations
|
||||
|
||||
### 8.1 Diagnostic Redaction
|
||||
|
||||
- File transfer paths may contain user-identifying information (UID-derived avatar names). These should be registered for diagnostic redaction if they appear in log output.
|
||||
- File contents (avatar images) must not appear in log output or diagnostic exports.
|
||||
|
||||
### 8.2 Security
|
||||
|
||||
- The `ftkey` is a one-time token and must not be logged.
|
||||
- TCP file transfer connections are not encrypted. This is a TeamSpeak protocol limitation, not a Chanora design choice. Avatar data is public (visible to anyone on the server), so the risk is acceptable.
|
||||
- File download does not require secrets beyond the existing authenticated connection.
|
||||
|
||||
### 8.3 Privacy
|
||||
|
||||
- Avatar downloads reveal to the server that the user is viewing a specific client's avatar. This is inherent in the protocol.
|
||||
- Chanora should not download avatars proactively for all clients. Download only when the UI needs to display a specific avatar (lazy/on-demand).
|
||||
|
||||
## 9. Out of Scope
|
||||
|
||||
The following are explicitly deferred:
|
||||
|
||||
- File upload (avatar upload, channel file upload).
|
||||
- Channel file browser (listing, creating directories, deleting, renaming).
|
||||
- Resumable downloads (seek position > 0).
|
||||
- File transfer progress reporting.
|
||||
- myTeamSpeak avatar resolution (the `client_myteamspeak_avatar` field).
|
||||
- In-memory hot cache in Rust (Flutter's `ImageCache` handles decoded image caching; add Rust-side layer only if profiling shows need).
|
||||
- Upload, file browser, and channel file management.
|
||||
|
||||
## 10. Cache Architecture
|
||||
|
||||
### 10.1 Layer Ownership
|
||||
|
||||
| Layer | Responsibility | Storage |
|
||||
|---|---|---|
|
||||
| `chanora_protocol` | Download raw bytes from server. No caching logic. | None |
|
||||
| `chanora_cache` | Content-addressed blob store backed by `cacache`: crash-safe writes, SSRI integrity verification, key validation, eviction, clear. Separate crate from `chanora_storage`. | Platform cache directory |
|
||||
| `chanora_core` | Session-aware cache orchestration: check freshness, coalesce requests, rate-limit downloads, persist to disk via `chanora_cache`. | Delegates to `chanora_cache` |
|
||||
| `chanora_bridge` | Expose typed `download_avatar` / `clear_file_cache` / `file_cache_size` to Flutter. | None |
|
||||
| Flutter | Display via `Image.memory`. Standard `ImageCache` for hot memory caching. Evict from `ImageCache` when hash changes. | In-memory only |
|
||||
|
||||
### 10.2 Why Separate `chanora_cache` Crate
|
||||
|
||||
`chanora_cache` is a separate crate from `chanora_storage` for three reasons:
|
||||
|
||||
1. **Different durability semantics.** `chanora_storage` holds identity, bookmarks, and connection profiles — data the user explicitly created. `chanora_cache` holds downloaded blobs that are fully reconstructible from the server. Losing the cache is an inconvenience, not data loss.
|
||||
2. **Different backup semantics.** Cache should be excluded from backups; persistent storage should be included. Platform conventions (iOS `Library/Caches/` vs `Library/Application Support/`) reflect this distinction.
|
||||
3. **Different directory placement.** Cache lives in the platform's cache directory (OS may evict under storage pressure on mobile). Persistent storage lives in the support directory.
|
||||
|
||||
The cache wraps the `cacache` crate for production-tested crash safety and integrity verification. It does not share `chanora_storage`'s crate or directory, and does not reimplement cacache's atomic write or content-addressing logic.
|
||||
|
||||
### 10.3 Why Hybrid (Rust Disk + Flutter Memory)
|
||||
|
||||
- Flutter's built-in `ImageCache` is an LRU in-memory cache (default 1000 images / 100 MiB). It handles hot display caching automatically when you use `MemoryImage`.
|
||||
- Flutter has no built-in disk cache. `cached_network_image` / `flutter_cache_manager` are designed for HTTP URLs, not custom binary protocol data.
|
||||
- Rust already owns the protocol, the connection state, and the anti-flood budget. Putting disk cache here avoids a feedback loop across the bridge.
|
||||
|
||||
### 10.4 Cache Storage
|
||||
|
||||
`chanora_cache` wraps the `cacache` crate for its on-disk storage. The physical layout is managed by `cacache`:
|
||||
|
||||
```
|
||||
<app_cache_dir>/chanora/
|
||||
blobs/ ← cacache content store root
|
||||
content-v2/ ← content-addressed by SHA-512
|
||||
<sha512-hex>/
|
||||
data ← raw blob bytes
|
||||
tmp/ ← temp files (in-flight writes)
|
||||
index-v2/ ← entry index (key → content mapping)
|
||||
```
|
||||
|
||||
Chanora's `BlobCache` maps protocol keys to `cacache` string keys:
|
||||
|
||||
| Protocol key | cacache key | Example |
|
||||
|---|---|---|
|
||||
| Avatar MD5 | `"av_<md5hex>"` | `"av_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"` |
|
||||
| Icon CRC32 | `"ic_<crc32u>"` | `"ic_123456789"` |
|
||||
|
||||
**Why `cacache`:**
|
||||
|
||||
1. **Crash safety.** Production-tested atomic writes (temp → rename). Handles partial writes, power loss, crash mid-write. No custom crash safety code to maintain.
|
||||
2. **Integrity verification.** SSRI integrity check on every `read()`. Detects corruption, bit rot, partial writes automatically. Better than custom "delete on read failure".
|
||||
3. **Content dedup.** Same bytes stored once regardless of key. Same avatar on two servers = stored once automatically.
|
||||
4. **Less code to maintain.** ~120 LOC wrapper vs ~200 LOC custom implementation. Crash safety and integrity are the hard parts — `cacache` owns them.
|
||||
|
||||
**Why no `<server_uid>` subdirectory:** The protocol uses content-addressed identifiers. `client_flag_avatar` is the MD5 of the avatar bytes — a given avatar hash always maps to the same bytes regardless of which server the user is on. Same avatar on two servers = same content = stored once by `cacache`. This is a deliberate dedup advantage over per-server namespacing.
|
||||
|
||||
**Why no metadata sidecars:** Content is immutable (a given hash always maps to the same bytes). `cacache` manages its own entry index with timestamps. No custom metadata files needed.
|
||||
|
||||
**Key validation rules:**
|
||||
|
||||
| Prefix | Key format | Validation |
|
||||
|---|---|---|
|
||||
| `av_` | `av_<32 hex chars>` | MD5 is exactly 32 hex characters |
|
||||
| `ic_` | `ic_<1-10 digit number>` | CRC32 unsigned, 0–4294967295 |
|
||||
|
||||
Keys failing validation are rejected at the `BlobCache` API boundary. This prevents path traversal or malformed filenames on disk.
|
||||
|
||||
**Platform paths** (Flutter passes the base directory into Rust at startup, matching the existing `initStorage` pattern):
|
||||
|
||||
| Platform | Cache directory |
|
||||
|---|---|
|
||||
| Android | `context.cacheDir/chanora/` (via `getCacheDir()`) |
|
||||
| iOS | `Library/Caches/chanora/` (via `getApplicationCacheDirectory()`) |
|
||||
| macOS | `~/Library/Caches/chanora/` |
|
||||
| Windows | `%LOCALAPPDATA%/chanora/cache/` |
|
||||
| Linux | `$XDG_CACHE_HOME/chanora/` or `~/.cache/chanora/` |
|
||||
|
||||
Flutter already resolves platform-specific paths. The same `getApplicationCacheDirectory()` call that is available in `path_provider` across all Chanora target platforms should be used. This follows the existing pattern where `app_bootstrap.dart` calls `getApplicationSupportDirectory()` for persistent storage; avatar/icon cache uses the cache-equivalent directory instead.
|
||||
|
||||
The cache init call is separate from storage init:
|
||||
|
||||
```rust
|
||||
// Bridge init (Flutter calls these at startup)
|
||||
pub fn init_storage(support_dir: String) -> Result<(), BridgeError>; // existing
|
||||
pub fn init_cache(cache_dir: String) -> Result<(), BridgeError>; // new
|
||||
```
|
||||
|
||||
### 10.5 Cache Freshness Strategy
|
||||
|
||||
The `client_flag_avatar` field on each client is the authoritative freshness signal:
|
||||
|
||||
```
|
||||
On connect / on client list update:
|
||||
For each visible client with non-empty avatar_hash:
|
||||
cache_key = "av_<avatar_hash>"
|
||||
if cacache entry exists for "av_<avatar_hash>":
|
||||
use cached file (zero downloads)
|
||||
else:
|
||||
enqueue download for avatar_path with expected hash = avatar_hash
|
||||
|
||||
On avatar_hash change for a client:
|
||||
The new hash produces a different cacache key.
|
||||
The old entry remains until eviction or manual clear.
|
||||
The new file is downloaded on demand.
|
||||
```
|
||||
|
||||
This means:
|
||||
|
||||
- **First connect:** No cache hits. Downloads happen lazily as the UI requests avatars.
|
||||
- **Reconnect to same server:** All avatars hit cache instantly (hashes match keys). Zero downloads.
|
||||
- **User changes avatar:** New hash = new cache key. Old entry becomes orphan. New file downloads on next UI request.
|
||||
- **Same user on different server:** Same avatar hash = same cached file. Cross-server dedup for free.
|
||||
|
||||
### 10.6 Anti-Flood and Download Timing
|
||||
|
||||
TeamSpeak servers enforce anti-flood rate limiting. Downloading all avatars eagerly on connect would trigger it on servers with many users.
|
||||
|
||||
**Strategy: lazy + throttled prefetch**
|
||||
|
||||
| Phase | What | Rate |
|
||||
|---|---|---|
|
||||
| Connect settle (first 2-5 s) | Do nothing. Let the initial state snapshot and channel tree arrive. | — |
|
||||
| After settle | UI requests avatars for visible clients in the current channel. These trigger downloads one at a time. | Max 1-2 concurrent downloads per server |
|
||||
| Channel switch | UI requests avatars for newly visible clients. | Same throttle |
|
||||
| Background prefetch (optional, future) | Low-priority downloads for clients in adjacent channels. | 1 request per 500 ms |
|
||||
|
||||
**Anti-flood handling:**
|
||||
|
||||
- If the server responds with an anti-flood error (TS3 error code `0x0701` = `client_could_not_be_banned` / flood-related), back off the download queue.
|
||||
- Implement a simple semaphore in `chanora_core`: max 1-2 concurrent downloads.
|
||||
- If a download gets a flood error, pause the queue for 5 seconds, then resume at reduced rate.
|
||||
|
||||
### 10.7 Retry on Failure
|
||||
|
||||
| Failure type | Strategy |
|
||||
|---|---|
|
||||
| Transient (network timeout, TCP reset) | Retry with exponential backoff: 5 s, 30 s, 2 min, 10 min. Cap at 10 min. |
|
||||
| Server flood limit hit | Pause queue 5 s, then resume at reduced rate. Do not count as a per-file retry. |
|
||||
| Permission denied (no download power) | Do not retry. Record negative cache entry. Only retry if hash changes. |
|
||||
| File not found (avatar removed) | Do not retry. Record negative cache entry. Clear when hash changes or becomes empty. |
|
||||
| Connection lost | All pending downloads fail. On reconnect, cache check runs fresh with current hashes. |
|
||||
|
||||
**Negative cache:** In-memory `HashMap<String, Instant>` with 5-minute TTL. Keys like `"av_<hash>"` or `"ic_<id>"` that received permanent errors are stored with an expiry. On lookup, expired entries are treated as absent. Cleared entirely on reconnect.
|
||||
|
||||
### 10.8 Request Coalescing
|
||||
|
||||
Multiple UI widgets may request the same avatar simultaneously (e.g., channel list + chat view + client info sheet).
|
||||
|
||||
**Pattern:** In `chanora_core`'s `FileTransferService`, maintain an in-flight map:
|
||||
|
||||
```rust
|
||||
HashMap<String, tokio::task::JoinHandle<Result<Vec<u8>, FileTransferError>>>
|
||||
```
|
||||
|
||||
- First request: start download, store handle.
|
||||
- Subsequent requests for same key: await the same handle.
|
||||
- When handle completes: write to cache, wake all waiters, remove from map.
|
||||
|
||||
### 10.9 Cache Eviction and Size Limits
|
||||
|
||||
**MVP approach:**
|
||||
|
||||
- No automatic size-based eviction in MVP. Avatars are small (typically 10-100 KB). Even 1000 avatars = ~50-100 MB.
|
||||
- Rely on platform cache directory semantics (OS may evict under storage pressure on mobile).
|
||||
- Old hash files accumulate but are harmless.
|
||||
|
||||
**Post-MVP:**
|
||||
|
||||
- `BlobCache::evict(max_bytes)` — walk `cacache::ls()` entries, sort by timestamp (oldest first), delete until total size < `max_bytes`. `cacache` manages timestamps internally. No metadata sidecars needed.
|
||||
- Or simpler: `BlobCache::evict_older_than(duration)` — delete entries with timestamp older than N days.
|
||||
- Call on startup and periodically (e.g., every 24 hours or on app resume).
|
||||
|
||||
### 10.10 User-Initiated Cache Clear
|
||||
|
||||
Add a bridge method:
|
||||
|
||||
```rust
|
||||
pub fn clear_file_cache(&self) -> Result<(), BridgeError> {
|
||||
// Delete the entire blobs/ directory contents
|
||||
// Flutter evicts all avatar/icon-related entries from ImageCache
|
||||
}
|
||||
|
||||
pub fn file_cache_size(&self) -> Result<u64, BridgeError> {
|
||||
// Walk blobs/ and sum file sizes
|
||||
}
|
||||
```
|
||||
|
||||
Flutter side:
|
||||
|
||||
```dart
|
||||
// In settings or storage management UI:
|
||||
onPressed: () async {
|
||||
await api.clearFileCache();
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
}
|
||||
```
|
||||
|
||||
This should be exposed in the app's settings UI under a "Clear cache" or "Storage management" section.
|
||||
|
||||
### 10.11 Storage Clear Across Servers
|
||||
|
||||
Since the cache is flat with content-addressed keys (no server namespacing):
|
||||
|
||||
- Connecting to a different server does not conflict — same avatar hash = same file.
|
||||
- Avatars unique to the old server remain cached. If a user on the new server has the same avatar (same hash), it hits cache instantly (cross-server dedup).
|
||||
- Cache clear removes all cached data regardless of which server it came from.
|
||||
|
||||
### 10.12 Flutter Display Strategy
|
||||
|
||||
**Option A: Bytes across bridge (simpler, recommended for MVP)**
|
||||
|
||||
Rust returns `Vec<u8>` across the bridge. Flutter uses `Image.memory(bytes)`.
|
||||
|
||||
```dart
|
||||
final bytes = await api.downloadAvatar(clientUid: uid);
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
return Image.memory(Uint8List.fromList(bytes));
|
||||
} else {
|
||||
return CircleAvatar(child: Text(initials)); // fallback
|
||||
}
|
||||
```
|
||||
|
||||
Flutter's `ImageCache` caches the decoded image in memory automatically. Same avatar bytes = cache hit in memory.
|
||||
|
||||
**Option B: File path across bridge (better for large images, future)**
|
||||
|
||||
Rust writes to disk and returns the file path. Flutter uses `FileImage`.
|
||||
|
||||
```dart
|
||||
final path = await api.getAvatarPath(avatarHash: hash);
|
||||
if (path != null) {
|
||||
return Image.file(File(path));
|
||||
} else {
|
||||
return CircleAvatar(child: Text(initials));
|
||||
}
|
||||
```
|
||||
|
||||
`FileImage` does not watch for file changes. When the hash changes, the UI must evict the old entry from `ImageCache` using `PaintingBinding.instance.imageCache.evict(key)`.
|
||||
|
||||
**Recommendation:** Start with Option A for MVP. It avoids file-path cross-platform complications and works well for small avatar files. The bridge already returns `Vec<u8>` for the download result.
|
||||
|
||||
## 11. Implementation Sequence
|
||||
|
||||
| Phase | Scope | What |
|
||||
|---|---|---|
|
||||
| Phase 1 | Protocol download | `Request::DownloadFile`, `StreamItem::FileDownload` handling, `ProtocolClient::download_avatar()` / `download_icon()`. No caching. |
|
||||
| Phase 2 | Bridge + Flutter display | Bridge `downloadAvatar()`, Flutter `Image.memory()`, initials fallback. Still no caching — every view re-downloads. |
|
||||
| Phase 3 | Rust disk cache | New `chanora_cache` crate: `cacache`-backed content-addressed blob store (`BlobCache`), key validation, mtime-based eviction, `init_cache` bridge call. |
|
||||
| Phase 4 | Session orchestration | `chanora_core` `FileTransferService`: request coalescing, rate limiter (semaphore), negative cache (5 min TTL), retry backoff. |
|
||||
| Phase 5 | Cache management | Bridge `clearFileCache()` + `fileCacheSize()`, Flutter settings UI, eviction on startup. |
|
||||
|
||||
Phase 1 and 2 deliver visible value (avatars in the UI). Phase 3-5 add robustness.
|
||||
|
||||
## 12. References
|
||||
|
||||
| Reference | Use |
|
||||
|---|---|
|
||||
| `ReSpeak/tsdeclarations` `Messages.toml` lines 828-830 | `ftinitdownload` command declaration |
|
||||
| `ReSpeak/tsdeclarations` `Messages.toml` lines 590 | `FileDownload` response structure |
|
||||
| `ReSpeak/tsdeclarations` `ts3protocol.md` | Low-level TeamSpeak protocol specification |
|
||||
| `ReSpeak/tsclientlib` `src/lib.rs` lines 956-1005 | `download_file` / `upload_file` public API |
|
||||
| `ReSpeak/tsclientlib` `src/lib.rs` lines 1371-1427 | `StreamItem::FileDownload` handling |
|
||||
| `ReSpeak/tsclientlib` `src/lib.rs` lines 1630-1672 | Outgoing init commands |
|
||||
| `Multivit4min/TS3-NodeJS-Library` `src/transport/FileTransfer.ts` | Reference TCP transfer implementation |
|
||||
| `Speckmops/ts3admin.class` `lib/ts3admin.class.php` lines 1352-1370 | Reference avatar download flow |
|
||||
| `docs/architecture/sad.md` SAD-067, SDD-MOD-009 | Protocol adapter boundary rules |
|
||||
| `crates/chanora_protocol/src/adapter.rs` lines 1561-1568 | Existing `uid_to_avatar_path` implementation |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,770 @@
|
||||
# File Transfer Cache Research
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Research complete, design implications noted (updated with TeaSpeak server findings)
|
||||
**Companion to:** `docs/architecture/file-transfer-design.md`
|
||||
**Purpose:** Factual findings from protocol analysis, existing client implementations, and cross-platform research that inform the cache architecture decision.
|
||||
|
||||
---
|
||||
|
||||
## 1. TS3 Protocol Identity Semantics
|
||||
|
||||
### 1.1 Avatar Identity
|
||||
|
||||
| Aspect | Value |
|
||||
|---|---|
|
||||
| Protocol field | `client_flag_avatar` |
|
||||
| Type | `TYPE_STRING` (TeaSpeakLibrary `PropertyDefinition.h:200`) |
|
||||
| Meaning | MD5 hash of the avatar file bytes |
|
||||
| Scope | Per-client per-server — a user can have different avatars on different servers |
|
||||
| Freshness | Automatically up-to-date for any client "in view" (`FLAG_CLIENT_VIEW`) |
|
||||
| Empty value | No avatar set |
|
||||
|
||||
**Key fact:** Identical avatar image bytes produce the **same** `client_flag_avatar` hash on any TS3 server. The hash is a content fingerprint, not a server-assigned identifier.
|
||||
|
||||
**Download path on server:** `/avatar_<base64HashClientUID>` — the filename is derived from the client's unique identifier (UID), not from the content hash. The content hash is communicated separately via `client_flag_avatar`.
|
||||
|
||||
**Sources:**
|
||||
- TeaSpeakLibrary `PropertyDefinition.h:200`: `PropertyDescription{CLIENT_FLAG_AVATAR, "client_flag_avatar", "", TYPE_STRING, FLAG_CLIENT_VIEW | FLAG_SAVE | FLAG_USER_EDITABLE}`
|
||||
- TS3AudioBot avatar upload: computes MD5 of image bytes, then sets `client_flag_avatar` to that hash ([`TS3AudioBot/TSLib/TsBaseFunctions.cs:324-341`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TSLib/TsBaseFunctions.cs#L324-L341))
|
||||
- TS3 NodeJS Library: avatar filename is `avatar_${clientBase64HashClientUID}` ([`TS3-NodeJS-Library/src/node/Client.ts:300-313`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/node/Client.ts#L300-L313))
|
||||
- TS3 PHP Framework: avatar name derivation from UID ([`ts3phpframework/src/Node/Client.php:288-307`](https://github.com/planetteamspeak/ts3phpframework/blob/87046b3d493c4d3d8064c639ea4269571192e476/src/Node/Client.php#L288-L307))
|
||||
|
||||
### 1.2 Icon Identity
|
||||
|
||||
| Aspect | Value |
|
||||
|---|---|
|
||||
| Protocol fields | `channel_icon_id`, `client_icon_id`, `virtualserver_icon_id` |
|
||||
| Type | `TYPE_UNSIGNED_NUMBER` (TeaSpeakLibrary `PropertyDefinition.h:83,146,217`) |
|
||||
| Meaning | CRC32 (unsigned) of the icon file bytes |
|
||||
| Scope | Per-entity per-server — but CRC32 is content-derived |
|
||||
| Download path | `/icon_<unsigned_crc32>` |
|
||||
|
||||
**Key fact:** Identical icon bytes produce the **same** CRC32 on any TS3 server. The icon ID is a content fingerprint. The upload process computes `crc32.unsigned(data)` and stores at `/icon_<id>`.
|
||||
|
||||
**CRC32 collision caveat:** CRC32 is only 32 bits. Different icon content can theoretically produce the same CRC32. Qint's `filecache.rs` explicitly notes this: "there could be collisions because only CRC-32 is used." ForChanora's purposes (small icons, not security-critical), this is acceptable.
|
||||
|
||||
**Sources:**
|
||||
- TeaSpeakLibrary `PropertyDefinition.h:83,146,217`: all icon IDs are `TYPE_UNSIGNED_NUMBER`
|
||||
- TS3 NodeJS Library `uploadIcon()`: computes `crc32.unsigned(data)`, uploads to `/icon_<id>` ([`TS3-NodeJS-Library/src/TeamSpeak.ts:2234-2241`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/TeamSpeak.ts#L2234-L2241))
|
||||
- TS3 PHP Framework: icon path uses `/icon_<unsigned id>` ([`ts3phpframework/src/Node/Node.php:137-146`](https://github.com/planetteamspeak/ts3phpframework/blob/87046b3d493c4d3d8064c639ea4269571192e476/src/Node/Node.php#L137-L146))
|
||||
- TS3 community forum: "The filename itself is the result of the CRC32 checksum" (TeamSpeak staff)
|
||||
|
||||
### 1.3 Implication for Cache Design
|
||||
|
||||
Both avatars and icons are **content-addressed by the protocol itself**:
|
||||
|
||||
| Asset | Content hash source | Same content across servers? |
|
||||
|---|---|---|
|
||||
| Avatar | `client_flag_avatar` = MD5 of bytes | Same bytes → same hash → same ID |
|
||||
| Icon | `icon_id` = CRC32 of bytes | Same bytes → same CRC32 → same ID |
|
||||
|
||||
This means a **flat content-addressed blob store** can achieve zero-duplication without any per-server directories, hardlinks, or ref-counting.
|
||||
|
||||
---
|
||||
|
||||
## 2. Virtual Server Identity
|
||||
|
||||
### 2.1 Server UID
|
||||
|
||||
| Aspect | Value |
|
||||
|---|---|
|
||||
| Protocol field | `virtualserver_unique_identifier` |
|
||||
| Type | `TYPE_STRING` (TeaSpeakLibrary `PropertyDefinition.h:22`) |
|
||||
| Generated by | The server instance, on creation |
|
||||
| Globally unique? | **Not guaranteed** — locally generated, no central registry |
|
||||
| Stable? | Yes — persists across restarts of the same virtual server |
|
||||
|
||||
**Key fact:** `virtualserver_unique_identifier` is generated by each TS3 server. Two physically different servers could theoretically produce the same UID. It is **not safe as a global cache key**.
|
||||
|
||||
### 2.2 What Chanora Currently Tracks
|
||||
|
||||
| Layer | Server identity fields | Source |
|
||||
|---|---|---|
|
||||
| Protocol adapter (`adapter.rs:1752-1755`) | `server_name`, `welcome_message`, `platform`, `version` | `state.server.*` from tsclientlib |
|
||||
| DTO (`ServerSnapshot`) | `server_name`, `welcome_message`, `platform`, `version` | No UID field |
|
||||
| Bridge (`BridgeSnapshot`) | Same as DTO | Same |
|
||||
| Storage (bookmarks) | Keyed by `host` (hostname:port) | SQLite `WHERE host = ?1` |
|
||||
| Core (recent servers) | `cfg.address` as host | Auto-saved on connect |
|
||||
|
||||
**Chanora does not currently plumb `virtualserver_unique_identifier` through the DTO stack.** The field exists in tsclientlib's state but is not extracted.
|
||||
|
||||
### 2.3 Implication for Cache Design
|
||||
|
||||
Using `virtualserver_unique_identifier` as the sole cache key is risky (not globally unique). Using connection address (`host:port`) is safe but duplicates cache entries when the same server is accessed via different addresses.
|
||||
|
||||
**Recommendation:** For a content-addressed blob store, server identity is only needed for per-server metadata (eviction, "clear cache for this server"), not for the blob key itself. The blob key is the content hash.
|
||||
|
||||
---
|
||||
|
||||
## 3. Existing TS3 Client Cache Implementations
|
||||
|
||||
### 3.1 Qint (tsclientlib-based, Tauri + Rust)
|
||||
|
||||
**Architecture:** Per-server directory with SQLite metadata.
|
||||
|
||||
```
|
||||
<cache>/files/<server-uid>/<channel-id>/<base64(path)>
|
||||
```
|
||||
|
||||
**Avatar handling:**
|
||||
- Avatar state stored per `(server, client)` row in SQLite
|
||||
- On avatar hash change, deletes the cached `/avatar_<uid>` file for that server
|
||||
- Avatar download path: `/avatar_<uid_base64>`
|
||||
|
||||
**Icon handling:**
|
||||
- Icons path-cached with CRC32
|
||||
- Code comments note CRC32 collisions and freshness by mtime
|
||||
- Qint explicitly deletes and re-downloads when icon mtime changes
|
||||
|
||||
**Dedup:** None. Same avatar on 5 servers = 5 stored copies.
|
||||
|
||||
**Sources:**
|
||||
- [`Qint/proxy/src/filecache.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/proxy/src/filecache.rs#L1-L6): "Stores files transferred via the TS3 file transfer protocol. This includes icons and avatars."
|
||||
- [`Qint/proxy/src/db/mod.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/proxy/src/db/mod.rs#L1158-L1176): avatar hash change triggers delete
|
||||
- [`Qint/src-tauri/src/cmd.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/src-tauri/src/cmd.rs#L524-L539): file download command
|
||||
|
||||
### 3.2 TS3 Official Client (closed source)
|
||||
|
||||
**Architecture:** Lazy cache with SDK callbacks.
|
||||
|
||||
- `getAvatar()` returns cached path if present; otherwise triggers download
|
||||
- `onAvatarUpdated` callback fires when avatar is downloaded or deleted
|
||||
- Cache paths (from community documentation):
|
||||
- Windows: `%LOCALAPPDATA%\TeamSpeak\Cache\Default`
|
||||
- Linux: `~/.cache/TeamSpeak/Default`
|
||||
- macOS: `~/Library/Caches/TeamSpeak/Default`
|
||||
- SDK also exposes `CLIENT_MYTS_AVATAR` / `client_myteamspeak_avatar` for cross-server myTeamSpeak avatars
|
||||
|
||||
**Sources:**
|
||||
- [`ts3client-pluginsdk/src/plugin.c`](https://github.com/teamspeak/ts3client-pluginsdk/blob/4aa90a53aa150cbf81e13bc97e68c0431b26499f/src/plugin.c#L384-L396): `getAvatar()` and `onAvatarUpdated`
|
||||
- [`ts3client-pluginsdk/public_rare_definitions.h`](https://github.com/teamspeak/ts3client-pluginsdk/blob/4aa90a53aa150cbf81e13bc97e68c0431b26499f/include/teamspeak/public_rare_definitions.h#L284-L313): `CLIENT_FLAG_AVATAR`, `CLIENT_MYTS_AVATAR`
|
||||
- Community: [clear cache](https://community.teamspeak.com/t/clear-cache/41511), [broken icons](https://community.teamspeak.com/t/server-icons-are-displaying-a-broken-image-issues-with-local-cache/58680)
|
||||
|
||||
### 3.3 TeaSpeak Client (TypeScript + C++ native)
|
||||
|
||||
**Architecture:** Browser Cache API for images, per-server own-avatar storage.
|
||||
|
||||
**Key components (from `.d.ts` type declarations):**
|
||||
|
||||
- `AvatarManager` — per-connection (`FileManager`) avatar handler
|
||||
- `cachedAvatars` (private) — in-memory cache of `ClientAvatar` objects
|
||||
- `updateCache(clientAvatarId, clientAvatarHash)` — updates cache when hash changes
|
||||
- `resolveAvatar(clientAvatarId, avatarHash?, cacheOnly?)` — resolves avatar by ID
|
||||
- `flush_cache()` — clears cache
|
||||
- `create_avatar_download(client_avatar_id)` — initiates file transfer
|
||||
|
||||
- `ClientAvatar` — tracks individual avatar state
|
||||
- `clientAvatarId` — derived from client UID via `uniqueId2AvatarId()`
|
||||
- `currentAvatarHash` — the `client_flag_avatar` value
|
||||
- State machine: `unset` → `loading` → `loaded` / `errored`
|
||||
- `loadingTimestamp` — when download started
|
||||
|
||||
- `ImageCache` — generic image cache using browser Cache API
|
||||
- `resolveCached(key, maxAge?)` — check if cached
|
||||
- `putCache(key, value, type?, headers?)` — store
|
||||
- `cleanup(maxAge)` — evict old entries
|
||||
- `reset()` — clear all
|
||||
- `isPersistent()` — whether cache persists to disk
|
||||
|
||||
- `OwnAvatarStorage` — user's own avatar, keyed by `serverUniqueId + mode`
|
||||
- `loadAvatarImage(serverUniqueId, mode)` — load own avatar for a server
|
||||
- `updateAvatar(serverUniqueId, mode, target)` — update own avatar
|
||||
- `avatarUploadSucceeded(serverUniqueId)` — move from "uploading" to "server" state
|
||||
- Stores `LocalAvatarInfo`: fileName, fileSize, **fileHashMD5**, timestamps, contentType
|
||||
|
||||
- `FileManager` — per-connection file transfer manager
|
||||
- `MAX_CONCURRENT_TRANSFERS` — transfer concurrency limit
|
||||
- `avatars: AvatarManager` — avatar subsystem
|
||||
- `initializeFileDownload(options)` — start download (path, name, channel, target)
|
||||
- `deleteIcon(iconId: number)` — delete icon by ID
|
||||
|
||||
- `FileTransfer` — transfer state machine
|
||||
- States: `PENDING → INITIALIZING → CONNECTING → RUNNING → FINISHED / ERRORED / CANCELED`
|
||||
- `InitializedTransferProperties`: serverTransferId, transferKey, **addresses[]**, protocol, seekOffset, fileSize
|
||||
- Multiple addresses returned by server for file transfer (failover)
|
||||
|
||||
- `localIconCache: ImageCache` — global icon cache (singleton)
|
||||
|
||||
**Sources:**
|
||||
- TeaSpeak-Client `imports/shared-app/file/Avatars.d.ts` — ClientAvatar, AbstractAvatarManager
|
||||
- TeaSpeak-Client `imports/shared-app/file/LocalAvatars.d.ts` — AvatarManager
|
||||
- TeaSpeak-Client `imports/shared-app/file/LocalIcons.d.ts` — localIconCache
|
||||
- TeaSpeak-Client `imports/shared-app/file/ImageCache.d.ts` — ImageCache (browser Cache API)
|
||||
- TeaSpeak-Client `imports/shared-app/file/FileManager.d.ts` — FileManager, transfer API
|
||||
- TeaSpeak-Client `imports/shared-app/file/Transfer.d.ts` — FileTransfer, state machine, error types
|
||||
- TeaSpeak-Client `imports/shared-app/file/OwnAvatarStorage.d.ts` — own avatar per-server storage
|
||||
- TeaSpeak-Client `native/serverconnection/test/js/ft.ts` — file transfer test (TCP + ftkey protocol)
|
||||
|
||||
### 3.4 TS3AudioBot (C#)
|
||||
|
||||
**Architecture:** No local avatar cache. Avatar upload is hash-driven.
|
||||
|
||||
- Uploads avatar bytes to `/avatar`, computes MD5, sets `client_flag_avatar` to that hash
|
||||
- Bot avatar selection reads local files from an `avatars/` directory
|
||||
- No caching of other users' avatars
|
||||
|
||||
**Sources:**
|
||||
- [`TS3AudioBot/TSLib/TsBaseFunctions.cs:324-341`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TSLib/TsBaseFunctions.cs#L324-L341)
|
||||
- [`TS3AudioBot/Bot.cs:420-470`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TS3AudioBot/Bot.cs#L420-L470)
|
||||
|
||||
### 3.5 TS3 NodeJS Library
|
||||
|
||||
**Architecture:** No local cache. Downloads on demand.
|
||||
|
||||
- Avatar filename: `avatar_${clientBase64HashClientUID}`
|
||||
- `getAvatar()` downloads directly — no caching layer
|
||||
- Tests assert the exact `/avatar_<base64uid>` path
|
||||
|
||||
**Sources:**
|
||||
- [`TS3-NodeJS-Library/src/node/Client.ts:300-313`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/node/Client.ts#L300-L313)
|
||||
- [`TS3-NodeJS-Library/tests/Client.spec.ts:342-358`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/tests/Client.spec.ts#L342-L358)
|
||||
|
||||
### 3.6 Summary Table
|
||||
|
||||
| Client | Cache Key Strategy | Dedup Across Servers? | Icon Cache |
|
||||
|---|---|---|---|
|
||||
| **Qint** | `<server-uid>/<channel-id>/<path>` | No | Yes (CRC32, mtime freshness) |
|
||||
| **TS3 Official** | Lazy cache (path-based) | Unknown | Yes |
|
||||
| **TeaSpeak** | UID-derived avatar ID + browser Cache API | Implicit (same hash = same cache) | Yes (global ImageCache) |
|
||||
| **TS3AudioBot** | None | N/A | No |
|
||||
| **TS3 NodeLib** | None | N/A | No |
|
||||
| **Chanora (decided)** | Content hash (MD5/CRC32), flat `blobs/` in `chanora_cache` crate | **Yes** | Yes |
|
||||
|
||||
---
|
||||
|
||||
## 4. TeaSpeak Protocol Definitions (Authoritative)
|
||||
|
||||
From TeaSpeakLibrary `src/PropertyDefinition.h` — the most complete open-source reference for TS3 protocol property types:
|
||||
|
||||
### 4.1 Avatar Properties
|
||||
|
||||
```cpp
|
||||
// Line 200
|
||||
PropertyDescription{CLIENT_FLAG_AVATAR, "client_flag_avatar", "",
|
||||
TYPE_STRING, FLAG_CLIENT_VIEW | FLAG_SAVE | FLAG_USER_EDITABLE}
|
||||
// "automatically up-to-date for any manager 'in view', this manager got an avatar"
|
||||
```
|
||||
|
||||
### 4.2 Icon Properties
|
||||
|
||||
```cpp
|
||||
// Line 83 — server icon
|
||||
PropertyDescription{VIRTUALSERVER_ICON_ID, "virtualserver_icon_id", "0",
|
||||
TYPE_UNSIGNED_NUMBER, FLAG_SERVER_VVSS | FLAG_USER_EDITABLE}
|
||||
|
||||
// Line 146 — channel icon
|
||||
PropertyDescription{CHANNEL_ICON_ID, "channel_icon_id", "0",
|
||||
TYPE_UNSIGNED_NUMBER, FLAG_CHANNEL_VIEW | FLAG_SS | FLAG_USER_EDITABLE}
|
||||
|
||||
// Line 217 — client icon
|
||||
PropertyDescription{CLIENT_ICON_ID, "client_icon_id", "0",
|
||||
TYPE_UNSIGNED_NUMBER, FLAG_CLIENT_VIEW | FLAG_CLIENT_VARIABLE}
|
||||
```
|
||||
|
||||
### 4.3 Server Identity
|
||||
|
||||
```cpp
|
||||
// Line 22
|
||||
PropertyDescription{VIRTUALSERVER_UNIQUE_IDENTIFIER,
|
||||
"virtualserver_unique_identifier", "",
|
||||
TYPE_STRING, FLAG_SERVER_VV | FLAG_SNAPSHOT}
|
||||
```
|
||||
|
||||
### 4.4 File Transfer Permissions
|
||||
|
||||
```cpp
|
||||
// From PermissionManager.cpp
|
||||
PermissionType::i_client_max_avatar_filesize // "Max avatar filesize in bytes"
|
||||
PermissionType::b_client_avatar_delete_other // "Allow deletion of avatars from other clients"
|
||||
PermissionType::b_ft_transfer_list // "Retrieve list of running filetransfers"
|
||||
```
|
||||
|
||||
### 4.5 File Transfer Error Codes
|
||||
|
||||
```cpp
|
||||
// From Error.h
|
||||
channel_no_filetransfer_supported = 0x30C
|
||||
file_transfer_connection_timeout = 0x80E
|
||||
file_transfer_complete = 0x811
|
||||
file_transfer_canceled = 0x812
|
||||
file_transfer_interrupted = 0x813
|
||||
file_transfer_server_quota_exceeded = 0x814
|
||||
file_transfer_client_quota_exceeded = 0x815
|
||||
file_transfer_reset = 0x816
|
||||
file_transfer_limit_reached = 0x817
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-Platform Filesystem Research
|
||||
|
||||
### 5.1 Hardlink Support
|
||||
|
||||
| Platform | Filesystem | Hardlinks in App-Private Storage? | Gotcha |
|
||||
|---|---|---|---|
|
||||
| Android (API 28+) | ext4 / f2fs | **Yes** | Rust uses `libc::link`; not FUSE-mounted; same-filesystem only |
|
||||
| iOS | APFS | **Yes** (writable sandbox dirs) | App bundle is read-only; avoid hardlinks to bundle assets |
|
||||
| macOS | APFS | **Yes** | — |
|
||||
| Linux | ext4 / btrfs / xfs | **Yes** | — |
|
||||
| Windows | NTFS | **Yes** | Rust uses `CreateHardLinkW` |
|
||||
|
||||
**`std::fs::hard_link` gotchas (all platforms):**
|
||||
- Same filesystem required
|
||||
- Destination must not exist (returns error)
|
||||
- Symlink behavior is platform-specific
|
||||
- All hardlinks share the same inode — modifying one modifies all
|
||||
- Files must be treated as **immutable** for hardlink safety
|
||||
|
||||
**Sources:**
|
||||
- Rust stdlib: `hard_link` maps to `libc::link` (Unix), `CreateHardLinkW` (Windows) ([Rust source](https://github.com/rust-lang/rust/blob/beae781308e9ddef13074a03faf57ca2fac59a5b/library/std/src/fs.rs#L2898-L2900))
|
||||
- Android: internal storage uses ext4/f2fs, not FUSE ([Android scoped storage docs](https://source.android.com/docs/core/storage/scoped))
|
||||
- iOS: APFS supports hardlinks; writable sandbox directories work ([Apple FileSystem basics](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html))
|
||||
|
||||
### 5.2 Rust Cache Libraries
|
||||
|
||||
**`cacache`** (MIT licensed, production-ready):
|
||||
- Content-addressed disk cache
|
||||
- Automatic dedup, atomic writes, integrity verification
|
||||
- Exposes `hard_link`, `copy`, and `reflink` paths for retrieval
|
||||
- On-disk layout: `content-v2/sha512/...`
|
||||
- Could replace a custom implementation, but adds a dependency
|
||||
|
||||
**Sources:**
|
||||
- [`cacache-rs` README](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/README.md#L39-L60)
|
||||
- [`cacache-rs` content path](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/src/content/path.rs#L6-L19)
|
||||
- [`cacache-rs` hard_link impl](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/src/content/read.rs#L257-L285)
|
||||
|
||||
### 5.3 Flutter Cache Patterns
|
||||
|
||||
Common Flutter packages use **cache-dir + metadata DB**, not hardlink dedup:
|
||||
- `flutter_cache_manager`: files in cache dir + `sqflite` metadata
|
||||
- `super_cache_disk`: file-per-entry (`.dat` + `.meta`) in app cache dir
|
||||
|
||||
**Sources:**
|
||||
- [flutter_cache_manager on pub.dev](https://pub.dev/packages/flutter_cache_manager)
|
||||
- [super_cache_disk on pub.dev](https://pub.dev/packages/super_cache_disk/versions/1.0.0)
|
||||
|
||||
---
|
||||
|
||||
## 6. Chanora Codebase Context
|
||||
|
||||
### 6.1 Storage Patterns
|
||||
|
||||
| Component | Pattern | Location |
|
||||
|---|---|---|
|
||||
| Identity storage | Atomic write (temp + `sync_all` + `rename`), mode 0600 on Unix | `chanora_storage/src/lib.rs:446-476` |
|
||||
| Metadata | Same atomic write pattern | `chanora_storage/src/lib.rs:480-515` |
|
||||
| Bookmarks | SQLite at `<storage_dir>/chanora.db`, keyed by `host` | `chanora_storage/src/lib.rs:782-928` |
|
||||
| Storage root | `getApplicationSupportDirectory()` from Flutter | `app_bootstrap.dart:19-24` |
|
||||
| Bridge init | `rust.initStorage(dir: dir)` | `app_bootstrap.dart:23-25,108-133` |
|
||||
|
||||
### 6.2 Existing Avatar Handling
|
||||
|
||||
| Component | What | Location |
|
||||
|---|---|---|
|
||||
| UID to avatar path | `uid_to_avatar_path()` — base64 decode UID, encode each byte as 2 chars (a-p) | `adapter.rs:1561-1568` |
|
||||
| Client profile DTO | `avatar_path` field — set when `client.avatar_hash` and `unique_id` non-empty | `dto.rs:135-136`, `adapter.rs:1323-1332` |
|
||||
| No download | Currently no file download implementation exists | — |
|
||||
| No icon handling | No icon field/path in protocol DTO or adapter | — |
|
||||
|
||||
### 6.3 Server Identity in Chanora
|
||||
|
||||
Chanora currently tracks servers by **connection address** (`host:port`), not by server UID:
|
||||
|
||||
- Bookmarks: `WHERE host = ?1`
|
||||
- Recent servers: auto-saved by `cfg.address`
|
||||
- Prefetch cache: keyed by normalized host
|
||||
- ServerSnapshot: has `server_name` but no `server_uid`
|
||||
|
||||
The `virtualserver_unique_identifier` field is available from tsclientlib's state but is **not extracted** by the adapter.
|
||||
|
||||
### 6.4 tsclientlib File Transfer API
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Download method | `Connection::download_file()` |
|
||||
| Stream items | `StreamItem::FileDownload(FileDownloadResult { size, stream })` |
|
||||
| Failure | `StreamItem::FiletransferFailed(handle, error)` |
|
||||
| TCP handling | tsclientlib handles TCP connection + ftkey writing automatically |
|
||||
| Chanora's job | Read `size` bytes from the returned `TcpStream` |
|
||||
| Async behavior | `StreamItem::FileDownload` fires asynchronously, not inline with the request |
|
||||
|
||||
### 6.5 ts-bookkeeping Generated Fields
|
||||
|
||||
From the generated parser in `target/debug/build/ts-bookkeeping-*/out/`:
|
||||
|
||||
- `virtual_server_id: u64` — numeric, per-virtual-server, may change across restarts
|
||||
- `virtual_server_uid` — string, the `virtualserver_unique_identifier`
|
||||
|
||||
Both are available in the `InInitServer` struct from the init handshake but are not currently plumbed through.
|
||||
|
||||
---
|
||||
|
||||
## 7. TeaSpeak Server Internals (Authoritative)
|
||||
|
||||
Source: TeaSpeak Server at `https://git.did.science/TeaSpeak/Server/Server` (branch `new-groups`, commit `b54c6d4e`).
|
||||
|
||||
### 7.1 Avatar ID Derivation — Server Side
|
||||
|
||||
The server derives the avatar filename from the **client UID**, not from the avatar content:
|
||||
|
||||
```cpp
|
||||
// DataClient.cpp:242-244
|
||||
std::string DataClient::getAvatarId() {
|
||||
return hex::hex(base64::validate(this->getUid()) ? base64::decode(this->getUid()) : this->getUid(), 'a', 'q');
|
||||
}
|
||||
```
|
||||
|
||||
The same transform produces `client_base64HashClientUID` (shown to other clients):
|
||||
|
||||
```cpp
|
||||
// client.cpp:1113-1114
|
||||
bulk.put_unchecked("client_base64HashClientUID",
|
||||
hex::hex(base64::validate(info->client_unique_id) ? base64::decode(info->client_unique_id) : info->client_unique_id, 'a', 'q'));
|
||||
```
|
||||
|
||||
**This matches Chanora's existing `uid_to_avatar_path()` in `adapter.rs:1561-1568`.**
|
||||
|
||||
### 7.2 Avatar Upload Path
|
||||
|
||||
When a client uploads an avatar, the server stores it as `/avatar_<avatarId>`:
|
||||
|
||||
```cpp
|
||||
// file.cpp:696-702
|
||||
} else if (cmd["path"].as<std::string>().empty() && cmd["name"].string() == "/avatar") {
|
||||
...
|
||||
info.file_path = "/avatar_" + this->getAvatarId();
|
||||
transfer_response = file::server()->file_transfer().initialize_avatar_transfer(...);
|
||||
}
|
||||
```
|
||||
|
||||
The avatar file path is identity-based (from UID), not content-based.
|
||||
|
||||
### 7.3 `client_flag_avatar` — Who Computes the Hash?
|
||||
|
||||
**The CLIENT computes the MD5 and sends it to the server during upload.** The server stores it as a string property (`FLAG_USER_EDITABLE`). The server does NOT compute or verify the hash.
|
||||
|
||||
This means `client_flag_avatar` is:
|
||||
- Set by the uploading client
|
||||
- Stored verbatim by the server
|
||||
- Broadcast to other clients as part of the client properties
|
||||
- A reliable content fingerprint: same avatar bytes → same MD5 → same `client_flag_avatar` on any server
|
||||
|
||||
### 7.4 Icon IDs — Server Does NOT Compute CRC32
|
||||
|
||||
Icon IDs are **permission values**, not content hashes computed by the server:
|
||||
|
||||
```cpp
|
||||
// ConnectedClient.cpp:186-210 — client icon ID from permissions
|
||||
auto permission_flags = local_permissions->permission_flags(permission::i_icon_id);
|
||||
new_icon_id = value.value;
|
||||
updated_client_properties.emplace_back(property::CLIENT_ICON_ID);
|
||||
```
|
||||
|
||||
```cpp
|
||||
// channel.cpp:1495-1504 — channel icon ID
|
||||
if(key == property::CHANNEL_ICON_ID) {
|
||||
auto icon_id = converter<uint32_t>::from_string_view(value);
|
||||
channel->permissions()->set_permission(permission::i_icon_id, { ... icon_id ... });
|
||||
}
|
||||
```
|
||||
|
||||
```cpp
|
||||
// server.cpp:76-89 — server icon ID
|
||||
SERVEREDIT_CHK_PROP_CACHED("virtualserver_icon_id", permission::b_virtualserver_modify_icon_id, int64_t)
|
||||
```
|
||||
|
||||
**The CLIENT computes the CRC32 during upload and uses it as the filename `/icon_<crc32>`.** The server stores the file and records the ID as a permission value. No server-side CRC32 or MD5 computation exists.
|
||||
|
||||
### 7.5 Per-Server Storage Layout
|
||||
|
||||
Avatars and icons are stored **per virtual server** on the server's filesystem:
|
||||
|
||||
```cpp
|
||||
// LocalFileSystem.cpp:39-45
|
||||
fs::path LocalFileSystem::server_path(const std::shared_ptr<VirtualFileServer> &server) {
|
||||
return fs::u8path(this->root_path_) / fs::u8path("server_" + std::to_string(server->server_id()));
|
||||
}
|
||||
// target_path = this->server_path(server) / "icons" / path;
|
||||
// target_path = this->server_path(server) / "avatars" / path;
|
||||
```
|
||||
|
||||
```
|
||||
<server_root>/
|
||||
server_<sid>/
|
||||
avatars/
|
||||
/avatar_<avatarId> ← one per client who uploaded
|
||||
icons/
|
||||
/icon_<id> ← one per unique icon
|
||||
```
|
||||
|
||||
### 7.6 File Transfer Protocol (Server Side)
|
||||
|
||||
Upload/delete/query routing:
|
||||
|
||||
```cpp
|
||||
// file.cpp:273-341 — delete routing
|
||||
if (first_entry_name.find("/icon_") == 0 && file_path.empty()) { ... delete_icons(...); }
|
||||
else if (first_entry_name.starts_with("/avatar_") && file_path.empty()) { ... delete_avatars(...); }
|
||||
```
|
||||
|
||||
```cpp
|
||||
// file.cpp:483-523 — query routing
|
||||
if (first_entry_name.find("/icon_") == 0 && file_path.empty()) { ... query_icon_info(...); }
|
||||
else if (first_entry_name.starts_with("/avatar_") && file_path.empty()) { ... query_avatar_info(...); }
|
||||
```
|
||||
|
||||
Transfer initialization returns ftkey and metadata:
|
||||
|
||||
```cpp
|
||||
// file.cpp:759-761
|
||||
result.put_unchecked(0, "ftkey", transfer->transfer_key);
|
||||
result.put_unchecked(0, "seekpos", transfer->file_offset);
|
||||
```
|
||||
|
||||
```cpp
|
||||
// file.cpp:887-899
|
||||
result.put_unchecked(0, "ftkey", transfer->transfer_key);
|
||||
result.put_unchecked(0, "proto", "1");
|
||||
result.put_unchecked(0, "size", transfer->expected_file_size);
|
||||
```
|
||||
|
||||
### 7.7 Key Takeaway for Chanora
|
||||
|
||||
| Who does what | Avatar | Icon |
|
||||
|---|---|---|
|
||||
| **Uploader (client)** computes | MD5 of avatar bytes → sets `client_flag_avatar` | CRC32 of icon bytes → filename `/icon_<crc32>` |
|
||||
| **Server** does | Stores file as `/avatar_<uid>`, saves property | Stores file as `/icon_<id>`, saves permission |
|
||||
| **Other clients** receive | `client_flag_avatar` (MD5) as a property update | `icon_id` (CRC32) as a property update |
|
||||
| **Chanora cache key** | `av_<md5>.dat` — content fingerprint | `ic_<crc32>.dat` — content fingerprint |
|
||||
|
||||
The content hash is computed once (by the uploader) and then broadcast as a property. Chanora never needs to hash anything — it just uses the protocol-provided values as cache keys.
|
||||
|
||||
---
|
||||
|
||||
## 8. Design Implications
|
||||
|
||||
### 8.1 The Core Insight
|
||||
|
||||
The TS3 protocol provides content hashes as part of normal server-to-client updates:
|
||||
|
||||
| Event | Data provided by server | What Chanora gets for free |
|
||||
|---|---|---|
|
||||
| Client enters view | `client_flag_avatar` = MD5 of avatar bytes | Content key for blob store |
|
||||
| Channel update | `channel_icon_id` = CRC32 of icon bytes | Content key for blob store |
|
||||
| Server update | `virtualserver_icon_id` = CRC32 of icon bytes | Content key for blob store |
|
||||
|
||||
No hashing needed on the client side. The protocol is **already content-addressed**.
|
||||
|
||||
### 8.2 Recommended Cache Architecture
|
||||
|
||||
```
|
||||
<app_cache_dir>/chanora/
|
||||
blobs/ ← cacache content store root
|
||||
content-v2/ ← content-addressed by SHA-512
|
||||
<sha512-hex>/data ← raw blob bytes
|
||||
index-v2/ ← key → content mapping
|
||||
```
|
||||
|
||||
Where `<app_cache_dir>` is the platform cache directory (not the support directory used by `chanora_storage`). Chanora's `BlobCache` maps protocol keys (`av_<md5>`, `ic_<crc32>`) to `cacache` string keys. Physical layout is managed by `cacache`.
|
||||
|
||||
**Lookup flow:**
|
||||
1. Server sends `client_flag_avatar = "a1b2c3d4..."` for user X
|
||||
2. Check: does `blobs/av_a1b2c3d4....dat` exist?
|
||||
3. Yes → use it, zero downloads (works for ANY server)
|
||||
4. No → download from `/avatar_<uid_base64>` → save as `blobs/av_a1b2c3d4....dat`
|
||||
|
||||
**Same for icons with `ic_<crc32>.dat`.**
|
||||
|
||||
### 8.3 Why This Beats Alternatives
|
||||
|
||||
| Approach | Dedup | Globally unique key | Needs server UID plumbing | Needs hardlinks | Complexity |
|
||||
|---|---|---|---|---|---|
|
||||
| `<server_uid>/<hash>.dat` | No | No (UID not guaranteed unique) | Yes | Optional | Medium |
|
||||
| `<host>_<port>/<hash>.dat` | No | Yes | No | Optional | Medium |
|
||||
| `<host>_<port>/<hash>.dat` + hardlinks | Yes | Yes | No | Yes | Medium-High |
|
||||
| **`blobs/av_<hash>.dat` (flat)** | **Yes** | **Yes (content hash)** | **No** | **No** | **Low** |
|
||||
|
||||
### 8.4 Trade-offs
|
||||
|
||||
| Pro | Con |
|
||||
|---|---|
|
||||
| Zero duplication across all servers | "Clear cache for server X only" requires metadata layer (Phase 3+) |
|
||||
| No hardlinks needed | Orphan cleanup requires scanning for unreferenced blobs |
|
||||
| No server UID plumbing needed | Cannot distinguish same-hash-different-content for icons (CRC32 collision) |
|
||||
| Simplest possible implementation | — |
|
||||
| Freshness = hash change = different filename (automatic) | — |
|
||||
| Cross-platform (just file I/O) | — |
|
||||
|
||||
### 8.5 Phased Implementation
|
||||
|
||||
| Phase | What | Delivers |
|
||||
|---|---|---|
|
||||
| 1 | Protocol download (raw bytes via adapter, no cache) | Working download pipeline |
|
||||
| 2 | Bridge + Flutter display (`Image.memory()`) | Visible avatars in UI |
|
||||
| 3 | `chanora_cache` crate: cacache-backed blob cache, separate crate, cache dir, mtime eviction | Zero re-downloads, zero duplication |
|
||||
| 4 | Session orchestration (coalescing, rate limiting, negative cache) | Anti-flood, robustness |
|
||||
| 5 | Cache management (clear all, orphan cleanup, optional per-server metadata) | User control |
|
||||
|
||||
---
|
||||
|
||||
## 9. Resolved Questions
|
||||
|
||||
### Q1: Icon CRC32 Collisions — Accept with Size Guard
|
||||
|
||||
**Risk assessment:** CRC32 produces a 32-bit hash. For N unique icons, the Birthday paradox gives collision probability ≈ N² / (2 × 2³²).
|
||||
|
||||
| Icons (N) | Collision probability |
|
||||
|---|---|
|
||||
| 100 | ~0.0001% (negligible) |
|
||||
| 1,000 | ~0.01% (negligible) |
|
||||
| 10,000 | ~1.2% (marginal) |
|
||||
| 65,536 | ~50% (likely) |
|
||||
|
||||
A single user typically encounters fewer than 1,000 unique icons across all servers. The practical collision risk is negligible.
|
||||
|
||||
**What happens on collision:** Wrong icon displayed for a channel/client/server. This is a visual glitch, not a security issue. The icon will appear incorrect until the cache is cleared.
|
||||
|
||||
**Existing practice:** Qint explicitly notes CRC32 collisions (`filecache.rs:4`) but does NOT guard against them — they only refresh icons by mtime. No other TS3 client guards against CRC32 collisions.
|
||||
|
||||
**Recommendation:** Accept CRC32 as the cache key. Add a lightweight **file size guard**: when downloading an icon, if `ic_<crc32>.dat` already exists but has a different size than the `ftinitdownload` response reported, re-download. File size is available from the protocol (`msg.size` in `InFileDownloadPart`). This catches most collisions (different content = different size with high probability) without computing a secondary hash.
|
||||
|
||||
**Decision:** CRC32 + file size guard. No SHA256 overhead needed.
|
||||
|
||||
---
|
||||
|
||||
### Q2: `client_myteamspeak_avatar` — Defer Indefinitely
|
||||
|
||||
**What it is:** A string property (`Option<String>` in ts-bookkeeping) broadcast alongside `client_flag_avatar`. It represents a myTeamSpeak cross-server avatar — a user linked to a myTeamSpeak account can set a global avatar that follows them across all servers.
|
||||
|
||||
**Current state in Chanora's dependency chain:**
|
||||
- ts-bookkeeping exposes it: `InInitServer` has `my_team_speak_avatar: Option<String>`
|
||||
- TeaSpeakLibrary tracks `client_myteamspeak_id` but not the avatar
|
||||
- tsclientlib exposes it as a property on client state
|
||||
|
||||
**Value for Chanora:**
|
||||
- myTeamSpeak is a TeamSpeak-specific cloud service (account sync, cross-server features)
|
||||
- Chanora is an independent client — no myTeamSpeak account integration is planned
|
||||
- The property may contain a URL or identifier that requires myTeamSpeak API access to resolve
|
||||
- Without myTeamSpeak integration, the avatar cannot be fetched
|
||||
|
||||
**Recommendation:** Defer indefinitely. If Chanora ever integrates myTeamSpeak accounts, this can be handled as a separate avatar source (URL-based HTTP download) alongside the existing protocol-based avatar download. The cache architecture supports this — just add a different blob prefix (e.g., `mt_<hash>.dat`).
|
||||
|
||||
**Decision:** Out of scope for MVP and foreseeable roadmap.
|
||||
|
||||
---
|
||||
|
||||
### Q3: Cache Backing Store — `cacache` Wrapper in Separate `chanora_cache` Crate
|
||||
|
||||
**Decision:** Use `cacache` as the backing store inside `chanora_cache`. Not a custom flat-file implementation.
|
||||
|
||||
**Why cacache won over custom:**
|
||||
|
||||
1. **Crash safety is production-tested.** `cacache` handles partial writes, power loss, crash mid-write. A custom implementation would need to get `sync_all` + atomic rename right — one bug = corrupted cache. Even though cache data is disposable (reconstructible from server), `cacache` eliminates this entire class of bugs.
|
||||
|
||||
2. **Less code to maintain.** ~120 LOC wrapper vs ~200 LOC custom implementation. The hard parts (atomic writes, integrity, content dedup) are owned by `cacache`, tested by the npm ecosystem.
|
||||
|
||||
3. **Integrity verification on every read.** SSRI verification detects corruption, bit rot, partial writes automatically. A custom impl would need to add this separately or accept silent corruption.
|
||||
|
||||
4. **Content dedup by SHA-512.** Same avatar on two servers = stored once automatically. The protocol's MD5/CRC32 keys map to `cacache` string keys; content dedup happens at the SHA-512 layer underneath.
|
||||
|
||||
**What about the downsides:**
|
||||
|
||||
| Concern | Assessment |
|
||||
|---|---|
|
||||
| ~6 transitive deps | `sha2` already in tree via `chacha20poly1305`. `serde_json`, `tempfile`, `digest` are lightweight. Acceptable for the safety benefit. |
|
||||
| SHA-512 overhead on every write/read | For <100KB avatars, SHA-512 takes ~0.1ms. Negligible. |
|
||||
| Opaque on-disk format | `cacache` provides `ls()` API for enumeration and inspection. Not as simple as `ls blobs/` but adequate. |
|
||||
| `cacache` has no built-in LRU eviction | We write a custom eviction pass using `cacache::ls()` + timestamp sort. ~20 lines. Same complexity as custom impl's eviction. |
|
||||
|
||||
**Separate crate rationale:**
|
||||
|
||||
- `chanora_cache` is separate from `chanora_storage` because cache data has different durability semantics (disposable vs persistent), different backup semantics (excluded vs included), and different directory placement (cache dir vs support dir).
|
||||
- `chanora_cache` lives in the platform's cache directory (`getApplicationCacheDirectory()`). `chanora_storage` lives in the support directory (`getApplicationSupportDirectory()`).
|
||||
- Bridge init is separate: `init_cache(cache_dir)` vs `init_storage(support_dir)`.
|
||||
|
||||
**API design:**
|
||||
|
||||
```rust
|
||||
pub struct BlobCache { cache_dir: PathBuf, max_bytes: u64 }
|
||||
impl BlobCache {
|
||||
pub fn new(cache_dir: impl AsRef<Path>, max_bytes: u64) -> Result<Self, BlobCacheError>;
|
||||
pub async fn put(&self, prefix: &str, key: &str, data: &[u8]) -> Result<(), BlobCacheError>;
|
||||
pub async fn get(&self, prefix: &str, key: &str) -> Result<Option<Vec<u8>>, BlobCacheError>;
|
||||
pub async fn remove(&self, prefix: &str, key: &str) -> Result<(), BlobCacheError>;
|
||||
pub async fn clear(&self) -> Result<(), BlobCacheError>;
|
||||
pub async fn total_size(&self) -> Result<u64, BlobCacheError>;
|
||||
pub async fn evict(&self) -> Result<(), BlobCacheError>;
|
||||
}
|
||||
```
|
||||
|
||||
All methods are async (cacache is async-native). Key validation at API boundary (`av_` = 32 hex chars, `ic_` = decimal digits).
|
||||
|
||||
---
|
||||
|
||||
### Q4: Per-Blob Metadata — No Metadata Sidecars (Resolved)
|
||||
|
||||
**Original options:**
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
|---|---|---|
|
||||
| SQLite (chanora.db) | ACID, queryable, already in use | Schema migration, couples cache to bookmark DB |
|
||||
| JSON sidecar files | Simple, self-contained, easy to debug | Write amplification (2 files per blob), concurrent write risk |
|
||||
| In-memory only | Simplest | Lost on restart, can't do orphan cleanup offline |
|
||||
| **No metadata (mtime-based)** | **Simplest, zero write amplification, 1 file per blob** | **No per-blob metadata beyond mtime** |
|
||||
|
||||
**Why no metadata is sufficient:**
|
||||
|
||||
1. **Content is immutable.** A given hash (MD5 or CRC32) always maps to the same bytes. There is no "stale content" problem — if the hash changes, it's a new file with a new name. No invalidation needed.
|
||||
|
||||
2. **mtime = insertion time.** Since content is never modified after write, the filesystem mtime equals the time the blob was cached. This is sufficient for "delete oldest files first" eviction.
|
||||
|
||||
3. **Write amplification avoided.** One file per blob (just the data) instead of two (data + JSON sidecar). For a cache that may hold thousands of small files, this matters.
|
||||
|
||||
4. **Eviction is simple.** `walk dir → stat → sort by mtime → delete oldest`. No JSON parsing, no schema, no migration.
|
||||
|
||||
5. **Per-server metadata deferred.** "Clear cache for server X only" and orphan cleanup are post-MVP features. If needed, a refs-layer can be added later without changing the blob layout.
|
||||
|
||||
**Oracle consultation:** Oracle recommended this approach explicitly — no metadata files, mtime-based eviction, separate crate. The immutability guarantee makes metadata redundant.
|
||||
|
||||
**Decision:** No metadata sidecars. One file per blob. Mtime-based eviction. Per-server metadata deferred to post-MVP.
|
||||
|
||||
---
|
||||
|
||||
### Q5: File Transfer Address Failover — Not Needed
|
||||
|
||||
**What the protocol provides:**
|
||||
|
||||
The TeaSpeak client's `InitializedTransferProperties` returns `addresses[]` — an array of `{serverAddress, serverPort}`. The official TS3 client can try multiple addresses for failover.
|
||||
|
||||
**What tsclientlib provides:**
|
||||
|
||||
```rust
|
||||
// tsclientlib/src/lib.rs:1373-1375
|
||||
let ip = msg.ip.unwrap_or_else(|| self.client.address.ip());
|
||||
let addr = SocketAddr::new(ip, msg.port);
|
||||
TcpStream::connect(&addr).await
|
||||
```
|
||||
|
||||
tsclientlib's `InFileDownloadPart` has `ip: Option<IpAddr>` — **single IP only**, not an array. If the server provides an IP, it uses that. Otherwise, it falls back to the connection address. **No multi-address failover.**
|
||||
|
||||
**What ts-bookkeeping parses:**
|
||||
|
||||
```rust
|
||||
pub struct InFileDownloadPart {
|
||||
pub client_filetransfer_id: u16,
|
||||
pub server_filetransfer_id: u16,
|
||||
pub filetransfer_key: String,
|
||||
pub port: u16,
|
||||
pub size: u64,
|
||||
pub protocol: u8,
|
||||
pub ip: Option<IpAddr>, // ← single optional IP
|
||||
}
|
||||
```
|
||||
|
||||
**The server's `notifystartdownload` response** sends `ip` as an optional single value, not an array. The TeaSpeak client's `addresses[]` is a higher-level abstraction (likely the client's own fallback logic), not a protocol feature.
|
||||
|
||||
**Recommendation:** Chanora follows tsclientlib's existing behavior — use `msg.ip` or fallback to connection address. No custom failover logic needed. If the TCP connection fails, the download fails and retries follow the exponential backoff strategy from the design doc.
|
||||
|
||||
**Decision:** Single address (from tsclientlib). No failover needed.
|
||||
@@ -123,7 +123,7 @@ Runtime event or error
|
||||
| Bridge command DTOs | Flutter generated API | `chanora_bridge`, Rust core | Stable typed DTOs; no raw protocol-library types cross to Flutter |
|
||||
| Bridge event DTOs | Rust core / bridge | Flutter services/widgets | User-safe errors and capability fields are explicit |
|
||||
| Protocol DTOs | `chanora_protocol` | Rust core, state sync | Protocol adapter isolates `tsclientlib` |
|
||||
| Audio configuration | Flutter settings / Rust core | `chanora_audio` | Voice modes and processing flags are explicit; VAD remains disabled/deferred |
|
||||
| Audio configuration | Flutter settings / Rust core | `chanora_audio` | Voice modes and processing flags are explicit; Windows/Linux desktop VAD-backed `VoiceActivity` is enabled only where runtime evidence exists, with unsupported platforms disabled/deferred |
|
||||
| Storage records | Storage crate | Rust core / Flutter UI via bridge | Secrets stay behind secure-storage abstraction |
|
||||
| Diagnostic bundles | Diagnostics crate | Flutter diagnostics UI | Redaction runs before export or display |
|
||||
| Platform capability records | Platform adapters/audio/PTT backends | UI and release record | UI/release wording must not over-claim capability |
|
||||
@@ -159,7 +159,7 @@ Runtime event or error
|
||||
| Secure storage abstraction | Platform storage details do not leak into UI or unrelated crates |
|
||||
| Advisory audio benchmarks | Performance regressions are surfaced without making CI a hard release gate at this stage |
|
||||
| PTT capability levels | Platform PTT support is represented as capability data and must match release wording |
|
||||
| VoiceActivity deferral | `VoiceActivity` remains reserved/disabled until a later baseline allocates implementation |
|
||||
| VoiceActivity platform scope | Windows/Linux desktop `VoiceActivity` is implemented through the capture VAD path; unsupported platforms remain disabled/deferred until backend allocation and runtime verification exist |
|
||||
| No automatic diagnostic upload in MVP | Diagnostics are local and user-initiated unless future approved requirements change policy |
|
||||
|
||||
## 11. Verification Handoff
|
||||
|
||||
@@ -63,7 +63,7 @@ Design rules:
|
||||
| Capture/playback | Platform-specific units handle Android, iOS, desktop/fallback paths behind Rust audio abstractions |
|
||||
| Codec | Opus encode/decode lives in `opus_voice.rs` and associated audio modules |
|
||||
| DSP chain | High-pass filter, noise suppression, echo cancellation, and AGC are represented by audio processing modules/backends |
|
||||
| Transmit control | `TransmitMode` supports `Ptt`, `Continuous`, and reserved `VoiceActivity`; `VoiceActivity` has no active MVP implementation |
|
||||
| Transmit control | `TransmitMode` supports `Ptt`, `Continuous`, and `VoiceActivity`; `VoiceActivity` is active for Windows/Linux desktop capture when VAD is configured, while mobile, macOS, and unverified-platform enablement remain deferred |
|
||||
| VoiceActivity gate (capture-side) | `voice_activity::VoiceActivityStateMachine` is the 10 ms-cadence gate for `TransmitMode::VoiceActivity`; open-after 40 ms (debounce), hangover 500 ms (anti-chatter), min-tx 200 ms (anti-flicker), weak-hold 30-100 frames (anti-stale-VAD); live `configure()` re-clamps existing timers on settings change without resetting state; 9 unit tests cover the main paths |
|
||||
| PTT | Desktop/mobile backends expose capability level and active backend; missed-key-up watchdog prevents stuck transmit |
|
||||
| Release tail | Tail handling prevents abrupt cutoffs after PTT release where configured |
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| Android Keystore-backed DEK deferred | Secure-storage claim limited | Storage design carries fallback limitation | Platform audit required | Waiver required |
|
||||
| Full reducer tests incomplete | State verification incomplete | State design remains valid but evidence partial | SWE.4/SWE.5 partial | Blocks full state-sync claim |
|
||||
| Desktop/iOS artifacts not release-ready | Platform packaging requirements partial | Release design remains source-build/unsigned | SYS.4 evidence partial | Public binary release No-Go |
|
||||
| VAD deferred | VoiceActivity not active | UI must show disabled/coming-soon | No VAD pass claim | No VAD marketing claim |
|
||||
| VAD platform-scoped | VoiceActivity active only for verified Windows/Linux desktop paths | UI must show disabled/unavailable on unsupported platforms | VAD pass claim must name verified platform/runtime evidence | No broad VAD marketing claim without platform scope |
|
||||
|
||||
## 2. Conclusion
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ This record captures the current maintainability review so implementation, verif
|
||||
| Android Keystore-backed DEK remains deferred | Android identity/bookmark encryption has weaker fail-safe properties than final target secure-storage design. | Android secure-storage audit or waiver; explicit release-readiness limitation. |
|
||||
| Android permission/audio lifecycle needs deeper route exercise | Build/install/launch smoke now passes on the emulator, but full permission-flow and audio-route lifecycle behavior still need an interactive scenario or device test before release. | Device/emulator scenario covering permission request/denial/grant, voice controls, foreground service, audio focus, and route/SCO transitions. |
|
||||
| iOS device runtime verification not executed in this review | The iOS audio-session error path is hardened, but VoiceProcessingIO/session ordering and runtime audio behavior still need device evidence. | iOS device or simulator build/run plus audio-session smoke evidence before iOS runtime success is claimed. |
|
||||
| VAD / VoiceActivity wording drift | VAD assets, tests, and scaffolding exist, but product-enabled `VoiceActivity` remains reserved/disabled per DEC-030. | Release, README, and verification wording must distinguish scaffolding/assets/tests from shipped product behavior. |
|
||||
| VAD / VoiceActivity wording drift | VAD assets, tests, and Windows/Linux desktop runtime wiring exist, but product-enabled `VoiceActivity` must remain platform-scoped per DEC-030. | Release, README, and verification wording must distinguish verified desktop behavior from unsupported mobile/macOS/unverified-platform behavior. |
|
||||
| Protocol voice packet re-export is an intentional exception | Future maintainers may assume complete protocol isolation and accidentally widen the Seam. | Architecture note in SAD/SDD or a decision-register entry. |
|
||||
| Bridge DTO mirror drift | Field additions can be missed across Core, Bridge, and Dart generated DTOs. | Bridge generation check plus Flutter analyze/test after bridge DTO changes. |
|
||||
| Full live reducer integration remains separate from reducer unit coverage | State reducer tests are strong, but runtime UI still has snapshot/probe paths. | SWE.5 integration run proving live protocol events fold through the intended state path, or explicit P1 deferral. |
|
||||
@@ -91,7 +91,7 @@ adb -s emulator-5554 shell pidof app.chanora.chanora_flutter
|
||||
|
||||
- Android minimum runtime baseline is API 28 (Android 9.0) per SysRS-288, SRS-187, DEC-004, and the Gradle `minSdk = 28` configuration. Documents must not revive the older API 24 baseline.
|
||||
- Flutter app version/build is `0.3.0+100` in `apps/chanora_flutter/pubspec.yaml`. Rust workspace package version remains `0.2.0-beta.1`. Release documents must distinguish these values instead of treating them as one candidate version.
|
||||
- The v0.3.0 changelog entry may mention VAD assets/backends only as implementation scaffolding; product-enabled `VoiceActivity` remains disabled/coming-soon until DEC-030 is superseded and runtime verification exists.
|
||||
- The v0.3.0 changelog entry may mention Windows/Linux desktop VAD-backed `VoiceActivity` only with matching runtime evidence; mobile, macOS, and unverified-platform `VoiceActivity` remain disabled/unavailable until DEC-030 is superseded and runtime verification exists.
|
||||
- README wording must describe the existing Flutter/Rust workspace and app scaffold, not a future scaffold that has not been created.
|
||||
|
||||
## 8. Git Policy
|
||||
|
||||
@@ -15,7 +15,7 @@ This register records product and engineering decisions referenced by the DV doc
|
||||
| DEC-012 legal/trademark/OSS review | Open | Blocks public/store release |
|
||||
| DEC-020 dual license MIT OR Apache-2.0 | Accepted per README | Supports license posture; dependency notices still require review |
|
||||
| DEC-027 desktop mouse side-button PTT | Accepted by requirements baseline | Verification must not over-claim unsupported platform input classes |
|
||||
| DEC-030 VAD deferral | Accepted as deferral | VAD scaffolding, assets, and tests may exist, but product `VoiceActivity` remains disabled/coming-soon until a later baseline enables and verifies it |
|
||||
| DEC-030 VAD platform scope | Partially superseded by desktop enablement | Windows/Linux desktop `VoiceActivity` is enabled through the audio capture VAD path and must be claimed only with matching runtime evidence; mobile, macOS, and unverified-platform `VoiceActivity` remain disabled/deferred until a later baseline enables and verifies them |
|
||||
| DEC-032 Android CMake patch exit path | Active tracking | Patched dependency requires reevaluation |
|
||||
| DEC-033 macOS VPIO ducking configuration | Accepted | Write `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` with `mEnableAdvancedDucking=0` (disables dynamic voice-activity-driven ducking) and `mDuckingLevel=Min` (= 10) to minimise the ducking of other apps' audio during a voice session; property is macOS 14+ only, the macOS 13 set fails silently (debug log) and VPIO uses its default behaviour; matches the iOS `.voiceChat` baseline on macOS 14+ |
|
||||
| DEC-034 Android runtime verification gate | Active tracking | Android target compilation, install, and runtime smoke are blocked locally until `aarch64-linux-android-clang` is available and `adb devices -l` shows an authorized target; release docs must not claim Android runtime success |
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
| Protocol adapter | `chanora_protocol` — `tsclientlib` isolated behind `ProtocolClient`, typed DTOs, `ProtocolError` catalogue |
|
||||
| Connection lifecycle | `chanora_core` — supervisor task, exponential backoff reconnect (1s→60s), user-disconnect suppresses reconnect; branch `simplify-project-review` has started splitting the previous large `lib.rs` into focused internal Modules (`events.rs`, `network_diagnostics.rs`) while preserving public re-exports |
|
||||
| State sync reducer unit | `chanora_state` — `ConnectionState`, `channel_join`, snapshot/delta reducers, reconnect handling, deterministic ordering, malformed duplicate normalization, channel-delete/client cleanup, and reducer unit tests. Runtime core integration still uses snapshot/probe refresh paths and remains separate validation work. |
|
||||
| Audio subsystem | `chanora_audio` — Opus encode/decode, HPF/NS/AEC3/AGC2 DSP, PTT backends (Windows/macOS/Linux/focused), iOS VoiceProcessingIO, Android Oboe, jitter buffer via `tsclientlib::audio::AudioHandler`, mixer, mute/deaf gates, release-tail timer, and VAD scaffolding/assets. Product `VoiceActivity` remains disabled per DEC-030. |
|
||||
| Audio subsystem | `chanora_audio` — Opus encode/decode, HPF/NS/AEC3/AGC2 DSP, PTT backends (Windows/macOS/Linux/focused), iOS VoiceProcessingIO, Android Oboe, jitter buffer via `tsclientlib::audio::AudioHandler`, mixer, mute/deaf gates, release-tail timer, and Windows/Linux desktop `VoiceActivity` through the capture VAD path. Mobile, macOS, and unverified-platform `VoiceActivity` remain deferred per DEC-030. |
|
||||
| Push-to-talk | Per-platform backends: Windows Raw Input + hook fallback, macOS Event Tap, Linux freedesktop portal, focused fallback; `PttCapabilityLevel` (L0–L3); missed-key-up watchdog |
|
||||
| Voice controls UI | `voice_bar`, `voice_compact`, `voice_haptics`, `voice_level_meter`, `voice_platform`, `ptt_capability_badge`, `talk_power_warning` |
|
||||
| Storage (non-secret) | `chanora_storage` — `BookmarkRepository` (SQLite/rusqlite bundled, schema v2), ChaCha20-Poly1305 encrypted passwords |
|
||||
@@ -54,7 +54,7 @@
|
||||
|---|---|
|
||||
| Event replay tooling | Reducer tests cover the state-sync contract, but standalone replay-file tooling remains a P1 verification gap. |
|
||||
| Reducer runtime integration evidence | The standalone reducer is unit-tested, but `chanora_core` still refreshes UI state through snapshot/probe paths rather than folding all live protocol events through `chanora_state::reduce`. |
|
||||
| Silero VAD | `assets/models/silero_vad.onnx` bundled but DEC-030 defers VAD to P1; `TransmitMode::VoiceActivity` is reserved and disabled in this baseline. |
|
||||
| Mobile/macOS VoiceActivity | `assets/models/silero_vad.onnx` is bundled and used by the desktop VAD path where runtime evidence supports it; mobile, macOS, and unverified-platform `TransmitMode::VoiceActivity` remain disabled/deferred until a later baseline supplies backend enablement and verification evidence. |
|
||||
| macOS build | Source-buildable only; no public release artifact is approved. |
|
||||
| Windows build | Source-buildable only; no public release artifact is approved. |
|
||||
| iOS build | Source-buildable/unsigned validation only; no TestFlight/App Store release artifact is approved. |
|
||||
|
||||
@@ -18,7 +18,7 @@ A waiver records a known gap that reviewers may accept for a limited decision sc
|
||||
| DV-WVR-004 | Standalone event replay and runtime reducer-integration evidence not yet complete | `docs/implementation-status-2026-05-28.md`, local `cargo test -p chanora_state --locked` evidence | Reducer unit coverage supports state synchronization, but file-based replay evidence and live-event integration evidence remain open | DV documentation pass and internal validation | Event replay tooling implemented or requirement reprioritized; runtime reducer integration evidence attached |
|
||||
| DV-WVR-005 | Event replay tool not found | `docs/implementation-status-2026-05-28.md` | Limits P1 state verification hooks SRS-061/SRS-098 | Accepted as P1 deferral | Event replay tooling implemented or requirement reprioritized |
|
||||
| DV-WVR-006 | Desktop and iOS artifacts are source-buildable or unsigned only | `docs/implementation-status-2026-05-28.md`, `docs/release/ios-build.md` | Blocks packaged public release claims | Internal validation from source/unsigned builds only | Signed/notarized/package artifacts exist and hashes are recorded |
|
||||
| DV-WVR-007 | Silero VAD asset bundled while `VoiceActivity` is deferred | `docs/implementation-status-2026-05-28.md` | Risk that UI/release wording overstates VAD availability | DV may pass if VoiceActivity remains disabled/coming-soon | VAD implementation allocated in a later baseline or asset/wording reconciled |
|
||||
| DV-WVR-007 | Silero VAD asset bundled while `VoiceActivity` is platform-scoped | `docs/implementation-status-2026-05-28.md` | Risk that UI/release wording overstates VAD availability beyond verified Windows/Linux desktop paths | DV may pass if VoiceActivity claims are limited to verified desktop evidence and unsupported platforms remain disabled/unavailable | Mobile/macOS implementation allocated in a later baseline or asset/wording reconciled |
|
||||
| DV-WVR-008 | Artifact hashes, tag, and candidate run IDs are not recorded in release record | `docs/release/release-readiness-go-nogo-record.md` | Blocks final release approval and reproducibility | DV documentation review only | Candidate build run records, tag, commit SHA, and artifact hashes are recorded |
|
||||
| DV-WVR-009 | Android target compile and runtime smoke blocked locally | `docs/governance/maintainability-review-2026-06-08.md`, `docs/release/release-readiness-go-nogo-record.md` | Blocks Android runtime, permission-flow, and audio-lifecycle success claims | Documentation review only; internal validation must keep Android limitation stated | Android NDK compiler `aarch64-linux-android-clang` is available, `adb devices -l` shows an authorized target, and Android build/install/smoke evidence is attached |
|
||||
|
||||
|
||||
@@ -10967,3 +10967,4 @@ cargo about generate --output-file docs/security/license-inventory.html about.hb
|
||||
|
||||
This artefact supports the DEC-012 legal review handoff at
|
||||
`docs/governance/legal-review-readiness.md`.
|
||||
|
||||
|
||||
+2
-2
@@ -2597,7 +2597,7 @@ This section extends the ASPICE SWE.1 Software Requirements Specification. The s
|
||||
- Source SysDes: SysDes-150
|
||||
- Verification method: Integration Test, UI Review
|
||||
|
||||
**SRS-205**: The software shall represent the user's voice transmit mode as a `TransmitMode` enum with variants `Ptt`, `Continuous`, and `VoiceActivity` (the last reserved with no v1 implementation per DEC-030). The setting shall be persisted per identity via the identity store. The default value for a fresh install shall be `Ptt`. The UI shall render `VoiceActivity` as a disabled "coming soon" option until an implementation is allocated in a later baseline.
|
||||
**SRS-205**: The software shall represent the user's voice transmit mode as a `TransmitMode` enum with variants `Ptt`, `Continuous`, and `VoiceActivity`. `VoiceActivity` shall be selectable only on platforms with an implemented and verified VAD capture path in this baseline (currently Windows/Linux desktop); mobile, macOS, and unverified-platform enablement remain deferred per DEC-030. The setting shall be persisted per identity via the identity store where the selected platform supports it. The default value for a fresh install shall be `Ptt`. The UI shall render `VoiceActivity` as disabled/unavailable on unsupported platforms rather than claiming runtime support.
|
||||
|
||||
- Status: Baseline Candidate
|
||||
- Type: Software Interface Requirement
|
||||
@@ -2837,7 +2837,7 @@ Consistent with the SysRS-307 / SysRS-308 / SysRS-309 deferrals propagated throu
|
||||
|
||||
| Version | Date | Description |
|
||||
|---|---|---|
|
||||
| 0.9.5 | 2026-05-15 | Added v1 audio + PTT lifecycle software requirements SRS-204 through SRS-207 sourced from SysDes-149..151: bridge surface drops `start_audio` / `stop_audio` and adds `voice_join(channel_id)` / `voice_leave()`, audio engine opens streams on first voice-channel join and closes on last leave with output independent of mic-permission state, `TransmitMode` enum (`Ptt` default, `Continuous`, reserved `VoiceActivity` per DEC-030) persisted per identity, `release_tail_ms` (default 200, range 0–500) gate on the `true → false` transition of `transmit_active` with re-press cancellation, Voice Bar hard-mute override of `transmit_active`. Strict layered sourcing preserved (`SRS -> SysDes` only). |
|
||||
| 0.9.5 | 2026-05-15 | Added v1 audio + PTT lifecycle software requirements SRS-204 through SRS-207 sourced from SysDes-149..151: bridge surface drops `start_audio` / `stop_audio` and adds `voice_join(channel_id)` / `voice_leave()`, audio engine opens streams on first voice-channel join and closes on last leave with output independent of mic-permission state, `TransmitMode` enum (`Ptt` default, `Continuous`, platform-scoped `VoiceActivity` per DEC-030) persisted per identity where supported, `release_tail_ms` (default 200, range 0–500) gate on the `true → false` transition of `transmit_active` with re-press cancellation, Voice Bar hard-mute override of `transmit_active`. Strict layered sourcing preserved (`SRS -> SysDes` only). |
|
||||
|
||||
|
||||
## Baseline Candidate 0.9.6 Update
|
||||
|
||||
+2
-2
@@ -2852,7 +2852,7 @@ This SysDes version covers all known SysRS requirements from `SysRS-001` through
|
||||
- ASPICE SYS.3 alignment: Architecture constraints
|
||||
- Allocated SysRS: SysRS-298
|
||||
|
||||
**SysDes-149**: The system architecture shall define a `TransmitMode` element carried as an enum at the audio + bridge + UI boundary with variants `Ptt`, `Continuous`, and a reserved `VoiceActivity` placeholder that has no allocated implementation in this baseline (deferred per DEC-030). The active mode shall be persisted in the identity store; the UI shall surface `VoiceActivity` as a disabled "coming soon" option until an implementation is allocated in a later baseline.
|
||||
**SysDes-149**: The system architecture shall define a `TransmitMode` element carried as an enum at the audio + bridge + UI boundary with variants `Ptt`, `Continuous`, and `VoiceActivity`. `VoiceActivity` shall be surfaced only when the current platform has an allocated, implemented, and verified VAD capture path in this baseline (currently Windows/Linux desktop); mobile, macOS, and unverified-platform enablement remain deferred per DEC-030. The active mode shall be persisted in the identity store where the selected platform supports it; unsupported platforms shall surface `VoiceActivity` as disabled/unavailable rather than claiming runtime support.
|
||||
|
||||
- Status: Baseline Candidate
|
||||
- Type: Subsystem Interface
|
||||
@@ -3029,7 +3029,7 @@ This SysDes version covers all known SysRS requirements from `SysRS-001` through
|
||||
|
||||
| Version | Date | Description |
|
||||
|---|---|---|
|
||||
| 0.9.5 | 2026-05-15 | Added v1 audio + PTT lifecycle allocation SysDes-149 through SysDes-151 sourced from SysRS-303 / SysRS-304: `TransmitMode` enum element (`Ptt` / `Continuous` / reserved `VoiceActivity` per DEC-030) at the audio + bridge + UI boundary, audio engine lifecycle bound to voice-channel membership with no manual start affordance and a listen-only path independent of mic permission, hard-mute override element, and the release-tail timer adapter (default 200 ms, range 0–500 ms) on `transmit_active`. Strict layered sourcing preserved (`SysDes -> SysRS` only). |
|
||||
| 0.9.5 | 2026-05-15 | Added v1 audio + PTT lifecycle allocation SysDes-149 through SysDes-151 sourced from SysRS-303 / SysRS-304: `TransmitMode` enum element (`Ptt` / `Continuous` / platform-scoped `VoiceActivity` per DEC-030) at the audio + bridge + UI boundary, audio engine lifecycle bound to voice-channel membership with no manual start affordance and a listen-only path independent of mic permission, hard-mute override element, and the release-tail timer adapter (default 200 ms, range 0–500 ms) on `transmit_active`. Strict layered sourcing preserved (`SysDes -> SysRS` only). |
|
||||
|
||||
|
||||
## Baseline Candidate 0.9.6 Update
|
||||
|
||||
+2
-2
@@ -1924,7 +1924,7 @@ This section converts the baseline product decisions into auditable system-level
|
||||
- Priority: P0
|
||||
- Verification: Privacy Review, Security Audit, Diagnostic Inspection
|
||||
|
||||
**SysRS-303**: The Chanora application system shall define the v1 voice transmit mode set as `Ptt` and `Continuous`, with `VoiceActivity` reserved on the enum surface but unimplemented in this baseline (deferred per DEC-030). The default mode on a fresh install shall be `Ptt`; the user-selected mode shall be persisted per identity. The audio engine lifecycle shall be bound to voice-channel membership: input and output streams shall open on the user's first voice-channel join of the session and shall close on the last voice-channel leave, with no manual start affordance exposed at any system interface. The output stream shall open regardless of microphone-permission state so listen-only is a first-class flow. A user-facing hard-mute toggle shall force the transmit gate closed and shall override the active transmit mode, the PTT key state, and every other internal signal.
|
||||
**SysRS-303**: The Chanora application system shall define the v1 voice transmit mode set as `Ptt`, `Continuous`, and `VoiceActivity`. `VoiceActivity` shall be enabled only on platforms with an implemented and verified VAD capture path in this baseline (currently Windows/Linux desktop); mobile, macOS, and unverified-platform enablement remain deferred per DEC-030. The default mode on a fresh install shall be `Ptt`; the user-selected mode shall be persisted per identity where the selected platform supports it. The audio engine lifecycle shall be bound to voice-channel membership: input and output streams shall open on the user's first voice-channel join of the session and shall close on the last voice-channel leave, with no manual start affordance exposed at any system interface. The output stream shall open regardless of microphone-permission state so listen-only is a first-class flow. A user-facing hard-mute toggle shall force the transmit gate closed and shall override the active transmit mode, the PTT key state, and every other internal signal.
|
||||
|
||||
- Priority: P0
|
||||
- Verification: Functional Test, UX Review
|
||||
@@ -1989,7 +1989,7 @@ This section converts the baseline product decisions into auditable system-level
|
||||
|
||||
| Version | Date | Description |
|
||||
|---|---|---|
|
||||
| 0.9.5 | 2026-05-15 | Added v1 audio + PTT lifecycle requirements SysRS-303 and SysRS-304 capturing the no-manual-start audio engine bound to voice-channel membership, the v1 transmit-mode set (`Ptt` default + `Continuous`, with `VoiceActivity` reserved per DEC-030), the listen-only flow (output independent of mic permission), the hard-mute override, and the 200 ms (0–500 ms) PTT release tail for word-boundary anti-clipping. |
|
||||
| 0.9.5 | 2026-05-15 | Added v1 audio + PTT lifecycle requirements SysRS-303 and SysRS-304 capturing the no-manual-start audio engine bound to voice-channel membership, the v1 transmit-mode set (`Ptt` default, `Continuous`, and platform-scoped `VoiceActivity` per DEC-030), the listen-only flow (output independent of mic permission), the hard-mute override, and the 200 ms (0–500 ms) PTT release tail for word-boundary anti-clipping. |
|
||||
|
||||
|
||||
## Baseline Candidate 0.9.9 Update
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
@echo off
|
||||
rem Wrapper around cmake.exe that injects policy/cache variables to force
|
||||
rem the release dynamic CRT (/MD) for Debug configurations. audiopus_sys
|
||||
rem calls cmake::build(opus_path), which invokes CMake configure; the
|
||||
rem upstream Opus CMakeLists.txt does not set CMP0091 or
|
||||
rem CMAKE_MSVC_RUNTIME_LIBRARY, so CMake defaults to /MDd in Debug
|
||||
rem builds. That pulls in __imp__CrtDbgReportW which is unresolved at
|
||||
rem test link time because the rest of the Rust/Cargo graph uses /MD.
|
||||
rem
|
||||
rem This wrapper is only invoked on Windows MSVC targets (see
|
||||
rem .cargo/config.toml [env] CMAKE_<target-triple> entries). Non-Windows
|
||||
rem hosts are unaffected.
|
||||
rem
|
||||
rem cmake-rs invokes this as: cmake-msvc-release-crt.cmd <configure-args>
|
||||
rem and also: cmake-msvc-release-crt.cmd --build ...
|
||||
rem and also: cmake-msvc-release-crt.cmd --version
|
||||
rem and also: cmake-msvc-release-crt.cmd -E ...
|
||||
rem
|
||||
rem Echoes a marker to stdout so the cargo build log proves the wrapper
|
||||
rem was actually invoked. cmake-rs forwards cmake stdout to the build
|
||||
rem script log, so this surfaces in `cargo build -vv` output.
|
||||
setlocal
|
||||
|
||||
echo cmake-msvc-release-crt.cmd: invoked with %*
|
||||
|
||||
rem Pass through non-configure invocations unchanged.
|
||||
if "%~1"=="--build" goto :passthrough
|
||||
if "%~1"=="--version" goto :passthrough
|
||||
if "%~1"=="-E" goto :passthrough
|
||||
if "%~1"=="--install" goto :passthrough
|
||||
if "%~1"=="--open" goto :passthrough
|
||||
|
||||
rem Configure invocation: inject -D flags before user args.
|
||||
echo cmake-msvc-release-crt.cmd: injecting CMP0091=NEW and CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL
|
||||
cmake.exe -DCMAKE_POLICY_DEFAULT_CMP0091=NEW -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL %*
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
:passthrough
|
||||
cmake.exe %*
|
||||
exit /b %ERRORLEVEL%
|
||||
Reference in New Issue
Block a user