feat(voice): add iOS VAD runtime support
This commit is contained in:
@@ -106,4 +106,19 @@ jobs:
|
|||||||
run: flutter analyze
|
run: flutter analyze
|
||||||
- name: flutter test (unit only)
|
- name: flutter test (unit only)
|
||||||
working-directory: apps/chanora_flutter
|
working-directory: apps/chanora_flutter
|
||||||
run: flutter test --exclude-tags e2e || true
|
run: flutter test --exclude-tags e2e
|
||||||
|
|
||||||
|
flutter-ios-release-build:
|
||||||
|
name: flutter iOS unsigned release build
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
- name: flutter pub get
|
||||||
|
working-directory: apps/chanora_flutter
|
||||||
|
run: flutter pub get
|
||||||
|
- name: flutter build ios --no-codesign
|
||||||
|
working-directory: apps/chanora_flutter
|
||||||
|
run: flutter build ios --release --no-codesign
|
||||||
|
|||||||
Generated
+100
-8
@@ -410,10 +410,13 @@ dependencies = [
|
|||||||
"cpal",
|
"cpal",
|
||||||
"criterion",
|
"criterion",
|
||||||
"dhat",
|
"dhat",
|
||||||
|
"dispatch2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
|
"ndarray",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"oboe",
|
"oboe",
|
||||||
|
"ort",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"sdl2",
|
"sdl2",
|
||||||
@@ -1004,6 +1007,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1615,7 +1620,7 @@ dependencies = [
|
|||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"resolv-conf",
|
"resolv-conf",
|
||||||
"smallvec",
|
"smallvec 1.15.1",
|
||||||
"system-configuration",
|
"system-configuration",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1695,7 +1700,7 @@ dependencies = [
|
|||||||
"httparse",
|
"httparse",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"smallvec",
|
"smallvec 1.15.1",
|
||||||
"tokio",
|
"tokio",
|
||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
@@ -1791,7 +1796,7 @@ dependencies = [
|
|||||||
"icu_normalizer_data",
|
"icu_normalizer_data",
|
||||||
"icu_properties",
|
"icu_properties",
|
||||||
"icu_provider",
|
"icu_provider",
|
||||||
"smallvec",
|
"smallvec 1.15.1",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1849,7 +1854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"idna_adapter",
|
"idna_adapter",
|
||||||
"smallvec",
|
"smallvec 1.15.1",
|
||||||
"utf8_iter",
|
"utf8_iter",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2080,6 +2085,16 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libloading"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
version = "0.30.1"
|
version = "0.30.1"
|
||||||
@@ -2152,6 +2167,16 @@ dependencies = [
|
|||||||
"regex-automata",
|
"regex-automata",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matrixmultiply"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"rawpointer",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -2227,7 +2252,7 @@ dependencies = [
|
|||||||
"equivalent",
|
"equivalent",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"smallvec",
|
"smallvec 1.15.1",
|
||||||
"tagptr",
|
"tagptr",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -2249,6 +2274,21 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndarray"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
|
||||||
|
dependencies = [
|
||||||
|
"matrixmultiply",
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
"portable-atomic",
|
||||||
|
"portable-atomic-util",
|
||||||
|
"rawpointer",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2307,6 +2347,15 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-complex"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2576,6 +2625,28 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ort"
|
||||||
|
version = "2.0.0-rc.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721"
|
||||||
|
dependencies = [
|
||||||
|
"libloading",
|
||||||
|
"ndarray",
|
||||||
|
"ort-sys",
|
||||||
|
"smallvec 2.0.0-alpha.10",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ort-sys"
|
||||||
|
version = "2.0.0-rc.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e2aba9f5c7c479925205799216e7e5d07cc1d4fa76ea8058c60a9a30f6a4e890"
|
||||||
|
dependencies = [
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oslog"
|
name = "oslog"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2624,7 +2695,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"redox_syscall",
|
"redox_syscall",
|
||||||
"smallvec",
|
"smallvec 1.15.1",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2716,6 +2787,15 @@ version = "1.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic-util"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||||
|
dependencies = [
|
||||||
|
"portable-atomic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -2952,6 +3032,12 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rawpointer"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -3112,7 +3198,7 @@ dependencies = [
|
|||||||
"fallible-streaming-iterator",
|
"fallible-streaming-iterator",
|
||||||
"hashlink",
|
"hashlink",
|
||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
"smallvec",
|
"smallvec 1.15.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3511,6 +3597,12 @@ version = "1.15.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "2.0.0-alpha.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -3995,7 +4087,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"regex-automata",
|
"regex-automata",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec 1.15.1",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ Direct Flutter / Dart dependencies of the MVP product code:
|
|||||||
* path_provider — BSD-3-Clause
|
* path_provider — BSD-3-Clause
|
||||||
* intl — BSD-3-Clause
|
* intl — BSD-3-Clause
|
||||||
* cupertino_icons — MIT
|
* cupertino_icons — MIT
|
||||||
|
* haptic_kit — MIT
|
||||||
|
https://github.com/erykkruk/flutter_vibration_animation
|
||||||
* freezed_annotation — MIT
|
* freezed_annotation — MIT
|
||||||
* flutter_lints (dev) — BSD-3-Clause
|
* flutter_lints (dev) — BSD-3-Clause
|
||||||
* build_runner (dev) — BSD-3-Clause
|
* build_runner (dev) — BSD-3-Clause
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>app-store</string>
|
||||||
|
<key>destination</key>
|
||||||
|
<string>export</string>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>automatic</string>
|
||||||
|
<key>stripSwiftSymbols</key>
|
||||||
|
<true/>
|
||||||
|
<key>uploadBitcode</key>
|
||||||
|
<false/>
|
||||||
|
<key>uploadSymbols</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# Uncomment this line to define a global platform for your project
|
||||||
platform :ios, '13.0'
|
platform :ios, '15.1'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
@@ -39,6 +39,7 @@ target 'Runner' do
|
|||||||
# flutter_rust_bridge can dlopen() it at runtime via FRB's
|
# flutter_rust_bridge can dlopen() it at runtime via FRB's
|
||||||
# default `chanora_bridge.framework/chanora_bridge` lookup path.
|
# default `chanora_bridge.framework/chanora_bridge` lookup path.
|
||||||
pod 'chanora_bridge', :path => '.'
|
pod 'chanora_bridge', :path => '.'
|
||||||
|
pod 'onnxruntime-c', '1.22.0'
|
||||||
|
|
||||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||||
target 'RunnerTests' do
|
target 'RunnerTests' do
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ PODS:
|
|||||||
- connectivity_plus (0.0.1):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
|
- haptic_kit (1.0.0):
|
||||||
|
- Flutter
|
||||||
|
- onnxruntime-c (1.22.0)
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
|
||||||
@@ -13,8 +16,14 @@ DEPENDENCIES:
|
|||||||
- chanora_bridge (from `.`)
|
- chanora_bridge (from `.`)
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
|
- haptic_kit (from `.symlinks/plugins/haptic_kit/ios`)
|
||||||
|
- onnxruntime-c (= 1.22.0)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
|
|
||||||
|
SPEC REPOS:
|
||||||
|
trunk:
|
||||||
|
- onnxruntime-c
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
audio_session:
|
audio_session:
|
||||||
:path: ".symlinks/plugins/audio_session/ios"
|
:path: ".symlinks/plugins/audio_session/ios"
|
||||||
@@ -24,16 +33,20 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
haptic_kit:
|
||||||
|
:path: ".symlinks/plugins/haptic_kit/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||||
chanora_bridge: af821d2c0507cb3199c91be12996bf0eb6b8bf5d
|
chanora_bridge: 0289413733edf8b7c937c50c3c3424b3319b94b5
|
||||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
||||||
|
onnxruntime-c: 7f778680e96145956c0a31945f260321eed2611a
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
|
|
||||||
PODFILE CHECKSUM: 15f58b0363434f244766f3301e00b9b1cdee096a
|
PODFILE CHECKSUM: a3abe93db2fc91b90387b399576e9c42a54226e0
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -463,7 +463,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -596,7 +596,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -647,7 +647,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import AVFoundation
|
|||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
||||||
|
private var iosPlatformChannel: FlutterMethodChannel?
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
@@ -134,32 +135,12 @@ import AVFoundation
|
|||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request microphone access on first launch rather than waiting
|
NotificationCenter.default.addObserver(
|
||||||
// for the user's first voice-channel join. The latter is
|
self,
|
||||||
// surprising: the user has only tapped "connect to server" and
|
selector: #selector(handleMediaServicesReset(_:)),
|
||||||
// suddenly iOS pops the permission prompt because joining a
|
name: AVAudioSession.mediaServicesWereResetNotification,
|
||||||
// text channel happens to trigger audio engine startup. Asking
|
object: nil
|
||||||
// up-front matches user expectations for a voice-chat client.
|
)
|
||||||
//
|
|
||||||
// The request is asynchronous and non-blocking. If the user
|
|
||||||
// denies, voice_join will surface a clearer error later when
|
|
||||||
// the audio engine fails to open the input device. The
|
|
||||||
// permission state is cached by iOS so subsequent launches
|
|
||||||
// skip the prompt.
|
|
||||||
//
|
|
||||||
// Deferred ~1 s so iOS finishes initialising the keyboard /
|
|
||||||
// text-input subsystem before the permission alert appears.
|
|
||||||
// Firing the alert too early steals focus from the not-yet-
|
|
||||||
// ready text-input layer, with the symptom that the first tap
|
|
||||||
// on a TextField does nothing (the second tap works because
|
|
||||||
// by then iOS has caught up). DispatchQueue.main.asyncAfter
|
|
||||||
// keeps everything on the main thread; the permission API
|
|
||||||
// itself must be called there too.
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
||||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
||||||
NSLog("chanora_flutter: microphone permission granted=\(granted)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
@@ -182,7 +163,7 @@ import AVFoundation
|
|||||||
// wrong rate, causing pitch + timing artifacts).
|
// wrong rate, causing pitch + timing artifacts).
|
||||||
logAudioSessionState(context: "setActive")
|
logAudioSessionState(context: "setActive")
|
||||||
let s = AVAudioSession.sharedInstance()
|
let s = AVAudioSession.sharedInstance()
|
||||||
let ins = s.currentRoute.inputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",")
|
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||||
NSLog(
|
NSLog(
|
||||||
"chanora_flutter: AVAudioSession actual: " +
|
"chanora_flutter: AVAudioSession actual: " +
|
||||||
"sampleRate=\(s.sampleRate) " +
|
"sampleRate=\(s.sampleRate) " +
|
||||||
@@ -221,9 +202,18 @@ import AVFoundation
|
|||||||
let outputs = routeDescription.outputs.map { $0.portType.rawValue }.joined(separator: ",")
|
let outputs = routeDescription.outputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||||
NSLog("chanora_flutter: route change reason=\(reason.rawValue) outputs=\(outputs)")
|
NSLog("chanora_flutter: route change reason=\(reason.rawValue) outputs=\(outputs)")
|
||||||
|
|
||||||
if reason == .oldDeviceUnavailable || reason == .newDeviceAvailable {
|
// P1: Send the detailed route class to Rust on every route change,
|
||||||
iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: nil)
|
// not just device plug/unplug. This covers:
|
||||||
}
|
// - .newDeviceAvailable / .oldDeviceUnavailable (headset plug/unplug)
|
||||||
|
// - .override (speaker/earpiece toggle)
|
||||||
|
// - .categoryChange (session category changed)
|
||||||
|
// - .wakeFromSleep (device woke from sleep)
|
||||||
|
// - .routeConfigurationChange (BT HFP connect/disconnect)
|
||||||
|
// The Rust side uses the route class to recompute the processing
|
||||||
|
// policy (route_policy.rs) and reset AEC delay state if needed.
|
||||||
|
let routeClass = classifyAudioRoute(routeDescription)
|
||||||
|
NSLog("chanora_flutter: route class=\(routeClass) reason=\(reason.rawValue)")
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: routeClass)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleInterruption(_ notification: Notification) {
|
@objc private func handleInterruption(_ notification: Notification) {
|
||||||
@@ -249,11 +239,115 @@ import AVFoundation
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
||||||
|
NSLog("chanora_flutter: media services reset")
|
||||||
|
do {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .default,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||||
|
)
|
||||||
|
try session.setPreferredIOBufferDuration(0.02)
|
||||||
|
try session.setPreferredSampleRate(48000.0)
|
||||||
|
try session.setActive(true, options: [])
|
||||||
|
logAudioSessionState(context: "mediaServicesWereReset")
|
||||||
|
} catch {
|
||||||
|
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
||||||
|
}
|
||||||
|
// P1: After rebuilding the session, send the current route class to
|
||||||
|
// Rust so it can recompute the processing policy and reset the
|
||||||
|
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
|
||||||
|
// which calls ios_restart_voice_unit.
|
||||||
|
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
||||||
|
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func applicationWillResignActive(_ application: UIApplication) {
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleWillResignActive", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleDidEnterBackground", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func applicationWillEnterForeground(_ application: UIApplication) {
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleWillEnterForeground", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
iosAudioLifecycleChannel?.invokeMethod("handleWillTerminate", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
iosAudioLifecycleChannel = FlutterMethodChannel(
|
iosAudioLifecycleChannel = FlutterMethodChannel(
|
||||||
name: "chanora/ios_audio_lifecycle",
|
name: "chanora/ios_audio_lifecycle",
|
||||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||||
)
|
)
|
||||||
|
iosPlatformChannel = FlutterMethodChannel(
|
||||||
|
name: "chanora/ios_platform",
|
||||||
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||||
|
)
|
||||||
|
iosPlatformChannel?.setMethodCallHandler { call, result in
|
||||||
|
switch call.method {
|
||||||
|
case "getMicrophonePermissionState":
|
||||||
|
result(self.microphonePermissionStateString())
|
||||||
|
case "requestMicrophonePermission":
|
||||||
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
result(granted ? "Granted" : self.microphonePermissionStateString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "openAppSettings":
|
||||||
|
guard let url = URL(string: UIApplication.openSettingsURLString) else {
|
||||||
|
result(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
UIApplication.shared.open(url, options: [:]) { opened in
|
||||||
|
result(opened)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func classifyAudioRoute(_ route: AVAudioSessionRouteDescription) -> String {
|
||||||
|
for output in route.outputs {
|
||||||
|
switch output.portType {
|
||||||
|
case .builtInReceiver:
|
||||||
|
return "Earpiece"
|
||||||
|
case .builtInSpeaker:
|
||||||
|
return "Speaker"
|
||||||
|
case .headphones, .usbAudio:
|
||||||
|
return "WiredHeadset"
|
||||||
|
case .bluetoothHFP:
|
||||||
|
return "BluetoothHfp"
|
||||||
|
case .bluetoothA2DP:
|
||||||
|
return "BluetoothA2dp"
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func microphonePermissionStateString() -> String {
|
||||||
|
switch AVAudioSession.sharedInstance().recordPermission {
|
||||||
|
case .granted:
|
||||||
|
return "Granted"
|
||||||
|
case .denied:
|
||||||
|
return "Denied"
|
||||||
|
case .undetermined:
|
||||||
|
return "NotDetermined"
|
||||||
|
@unknown default:
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<!-- Opt into ProMotion / high-refresh-rate CADisplayLink ranges on
|
||||||
|
supported iPhones. Flutter's iOS embedder reads this key; no
|
||||||
|
additional Flutter package is required for dynamic refresh. -->
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -74,11 +77,6 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<!-- Make Chanora's Documents folder visible to the Files.app and
|
|
||||||
accessible via iTunes / Finder file-sharing. We write
|
|
||||||
diagnostic logs (chanora.log) into Documents/ so users can
|
|
||||||
export them for support. Both keys are required for the
|
|
||||||
"On My iPhone -> Chanora" listing to appear in Files.app. -->
|
|
||||||
<key>UIFileSharingEnabled</key>
|
<key>UIFileSharingEnabled</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Pod::Spec.new do |s|
|
|||||||
s.license = { :type => 'Apache-2.0 OR MIT', :text => 'See LICENSE-APACHE / LICENSE-MIT at the repo root' }
|
s.license = { :type => 'Apache-2.0 OR MIT', :text => 'See LICENSE-APACHE / LICENSE-MIT at the repo root' }
|
||||||
s.author = { 'EdisonJwa' => 'me@edison.network' }
|
s.author = { 'EdisonJwa' => 'me@edison.network' }
|
||||||
s.source = { :path => '.' }
|
s.source = { :path => '.' }
|
||||||
s.platform = :ios, '13.0'
|
s.platform = :ios, '15.1'
|
||||||
|
|
||||||
# Build the Rust bridge on `pod install`. The script runs under
|
# Build the Rust bridge on `pod install`. The script runs under
|
||||||
# bash; we use `set -e` so any failure (cargo missing, target not
|
# bash; we use `set -e` so any failure (cargo missing, target not
|
||||||
@@ -51,15 +51,59 @@ Pod::Spec.new do |s|
|
|||||||
s.prepare_command = <<-SCRIPT
|
s.prepare_command = <<-SCRIPT
|
||||||
set -e
|
set -e
|
||||||
REPO_ROOT="$(cd ../../.. && pwd)"
|
REPO_ROOT="$(cd ../../.. && pwd)"
|
||||||
|
USER_NAME="$(id -un)"
|
||||||
|
USER_HOME="$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
|
||||||
|
if [ -z "$USER_HOME" ]; then
|
||||||
|
USER_HOME="$(cd ~ && pwd)"
|
||||||
|
fi
|
||||||
BRIDGE="$REPO_ROOT/target/aarch64-apple-ios/release/libchanora_bridge.dylib"
|
BRIDGE="$REPO_ROOT/target/aarch64-apple-ios/release/libchanora_bridge.dylib"
|
||||||
|
find_cargo() {
|
||||||
|
for candidate in \
|
||||||
|
"$USER_HOME/.cargo/bin/cargo" \
|
||||||
|
"/opt/homebrew/opt/rustup/bin/cargo" \
|
||||||
|
"/usr/local/opt/rustup/bin/cargo"
|
||||||
|
do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
command -v cargo
|
||||||
|
}
|
||||||
|
find_rustc() {
|
||||||
|
for candidate in \
|
||||||
|
"$USER_HOME/.cargo/bin/rustc" \
|
||||||
|
"/opt/homebrew/opt/rustup/bin/rustc" \
|
||||||
|
"/usr/local/opt/rustup/bin/rustc"
|
||||||
|
do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
command -v rustc
|
||||||
|
}
|
||||||
|
CARGO_BIN="$(find_cargo)"
|
||||||
|
RUSTC_BIN="$(find_rustc)"
|
||||||
|
ORT_FRAMEWORK="$REPO_ROOT/apps/chanora_flutter/ios/Pods/onnxruntime-c/onnxruntime.xcframework/ios-arm64/onnxruntime.framework"
|
||||||
|
ORT_LINK_DIR="$REPO_ROOT/target/onnxruntime-ios-device"
|
||||||
|
if [ -f "$ORT_FRAMEWORK/onnxruntime" ]; then
|
||||||
|
mkdir -p "$ORT_LINK_DIR"
|
||||||
|
lipo "$ORT_FRAMEWORK/onnxruntime" -thin arm64 -output "$ORT_LINK_DIR/libonnxruntime.a"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[chanora_bridge.podspec] cargo build aarch64-apple-ios"
|
echo "[chanora_bridge.podspec] cargo build aarch64-apple-ios"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
PATH="$HOME/.cargo/bin:$PATH" \\
|
HOME="$USER_HOME" \\
|
||||||
IPHONEOS_DEPLOYMENT_TARGET=13.0 \\
|
CARGO_HOME="$USER_HOME/.cargo" \\
|
||||||
|
RUSTUP_HOME="$USER_HOME/.rustup" \\
|
||||||
|
RUSTUP_TOOLCHAIN="stable-aarch64-apple-darwin" \\
|
||||||
|
RUSTC="$RUSTC_BIN" \\
|
||||||
|
ORT_LIB_LOCATION="$ORT_LINK_DIR" \\
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET=15.1 \\
|
||||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||||
CMAKE_OSX_DEPLOYMENT_TARGET=13.0 \\
|
CMAKE_OSX_DEPLOYMENT_TARGET=15.1 \\
|
||||||
cargo build --release --target aarch64-apple-ios -p chanora_bridge
|
"$CARGO_BIN" build --release --target aarch64-apple-ios -p chanora_bridge
|
||||||
|
|
||||||
if [ ! -f "$BRIDGE" ]; then
|
if [ ! -f "$BRIDGE" ]; then
|
||||||
echo "ERROR: bridge dylib not found at $BRIDGE" >&2
|
echo "ERROR: bridge dylib not found at $BRIDGE" >&2
|
||||||
@@ -86,7 +130,7 @@ Pod::Spec.new do |s|
|
|||||||
<key>CFBundleShortVersionString</key><string>1.0.0</string>
|
<key>CFBundleShortVersionString</key><string>1.0.0</string>
|
||||||
<key>CFBundleVersion</key><string>1</string>
|
<key>CFBundleVersion</key><string>1</string>
|
||||||
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
|
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
|
||||||
<key>MinimumOSVersion</key><string>13.0</string>
|
<key>MinimumOSVersion</key><string>15.1</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
@@ -116,15 +160,70 @@ PLIST
|
|||||||
:script => <<-SCRIPT,
|
:script => <<-SCRIPT,
|
||||||
set -e
|
set -e
|
||||||
REPO_ROOT="$(cd "${PODS_TARGET_SRCROOT}/../../.." && pwd)"
|
REPO_ROOT="$(cd "${PODS_TARGET_SRCROOT}/../../.." && pwd)"
|
||||||
BRIDGE="$REPO_ROOT/target/aarch64-apple-ios/release/libchanora_bridge.dylib"
|
USER_NAME="$(id -un)"
|
||||||
|
USER_HOME="$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
|
||||||
|
if [ -z "$USER_HOME" ]; then
|
||||||
|
USER_HOME="$(cd ~ && pwd)"
|
||||||
|
fi
|
||||||
|
find_cargo() {
|
||||||
|
for candidate in \
|
||||||
|
"$USER_HOME/.cargo/bin/cargo" \
|
||||||
|
"/opt/homebrew/opt/rustup/bin/cargo" \
|
||||||
|
"/usr/local/opt/rustup/bin/cargo"
|
||||||
|
do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
command -v cargo
|
||||||
|
}
|
||||||
|
find_rustc() {
|
||||||
|
for candidate in \
|
||||||
|
"$USER_HOME/.cargo/bin/rustc" \
|
||||||
|
"/opt/homebrew/opt/rustup/bin/rustc" \
|
||||||
|
"/usr/local/opt/rustup/bin/rustc"
|
||||||
|
do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
command -v rustc
|
||||||
|
}
|
||||||
|
CARGO_BIN="$(find_cargo)"
|
||||||
|
RUSTC_BIN="$(find_rustc)"
|
||||||
|
if [ "${PLATFORM_NAME:-iphoneos}" = "iphonesimulator" ]; then
|
||||||
|
RUST_TARGET="aarch64-apple-ios-sim"
|
||||||
|
SUPPORTED_PLATFORM="iPhoneSimulator"
|
||||||
|
ORT_SLICE="ios-arm64_x86_64-simulator"
|
||||||
|
else
|
||||||
|
RUST_TARGET="aarch64-apple-ios"
|
||||||
|
SUPPORTED_PLATFORM="iPhoneOS"
|
||||||
|
ORT_SLICE="ios-arm64"
|
||||||
|
fi
|
||||||
|
BRIDGE="$REPO_ROOT/target/$RUST_TARGET/release/libchanora_bridge.dylib"
|
||||||
|
ORT_FRAMEWORK="$REPO_ROOT/apps/chanora_flutter/ios/Pods/onnxruntime-c/onnxruntime.xcframework/$ORT_SLICE/onnxruntime.framework"
|
||||||
|
ORT_LINK_DIR="$REPO_ROOT/target/onnxruntime-$RUST_TARGET"
|
||||||
|
if [ ! -f "$ORT_FRAMEWORK/onnxruntime" ]; then
|
||||||
|
echo "ERROR: ONNX Runtime framework not found at $ORT_FRAMEWORK" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$ORT_LINK_DIR"
|
||||||
|
lipo "$ORT_FRAMEWORK/onnxruntime" -thin arm64 -output "$ORT_LINK_DIR/libonnxruntime.a"
|
||||||
|
|
||||||
echo "[chanora_bridge script_phase] cargo build aarch64-apple-ios"
|
echo "[chanora_bridge script_phase] cargo build $RUST_TARGET"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
PATH="$HOME/.cargo/bin:$PATH" \\
|
HOME="$USER_HOME" \\
|
||||||
IPHONEOS_DEPLOYMENT_TARGET=13.0 \\
|
CARGO_HOME="$USER_HOME/.cargo" \\
|
||||||
|
RUSTUP_HOME="$USER_HOME/.rustup" \\
|
||||||
|
RUSTUP_TOOLCHAIN="stable-aarch64-apple-darwin" \\
|
||||||
|
RUSTC="$RUSTC_BIN" \\
|
||||||
|
ORT_LIB_LOCATION="$ORT_LINK_DIR" \\
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET=15.1 \\
|
||||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||||
CMAKE_OSX_DEPLOYMENT_TARGET=13.0 \\
|
CMAKE_OSX_DEPLOYMENT_TARGET=15.1 \\
|
||||||
cargo build --release --target aarch64-apple-ios -p chanora_bridge
|
"$CARGO_BIN" build --release --target "$RUST_TARGET" -p chanora_bridge
|
||||||
|
|
||||||
cd "$REPO_ROOT/apps/chanora_flutter/ios"
|
cd "$REPO_ROOT/apps/chanora_flutter/ios"
|
||||||
FW=Frameworks/chanora_bridge.framework
|
FW=Frameworks/chanora_bridge.framework
|
||||||
@@ -139,6 +238,22 @@ PLIST
|
|||||||
|
|
||||||
mkdir -p "$FW"
|
mkdir -p "$FW"
|
||||||
cp "$BRIDGE" "$FW/chanora_bridge"
|
cp "$BRIDGE" "$FW/chanora_bridge"
|
||||||
|
cat > "$FW/Info.plist" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key><string>chanora_bridge</string>
|
||||||
|
<key>CFBundleIdentifier</key><string>app.chanora.bridge</string>
|
||||||
|
<key>CFBundleName</key><string>chanora_bridge</string>
|
||||||
|
<key>CFBundlePackageType</key><string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>1.0.0</string>
|
||||||
|
<key>CFBundleVersion</key><string>1</string>
|
||||||
|
<key>CFBundleSupportedPlatforms</key><array><string>$SUPPORTED_PLATFORM</string></array>
|
||||||
|
<key>MinimumOSVersion</key><string>15.1</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
||||||
"$FW/chanora_bridge"
|
"$FW/chanora_bridge"
|
||||||
echo "[chanora_bridge script_phase] framework refreshed"
|
echo "[chanora_bridge script_phase] framework refreshed"
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
"disconnectAction": "Disconnect",
|
"disconnectAction": "Disconnect",
|
||||||
"refreshAction": "Refresh",
|
"refreshAction": "Refresh",
|
||||||
"diagnosticsAction": "Diagnostics",
|
"diagnosticsAction": "Diagnostics",
|
||||||
|
"diagnosticsSaveAction": "Save export",
|
||||||
|
"diagnosticsSaved": "Diagnostic export saved to {path}",
|
||||||
|
"@diagnosticsSaved": {
|
||||||
|
"placeholders": { "path": { "type": "String" } }
|
||||||
|
},
|
||||||
"aboutAction": "About",
|
"aboutAction": "About",
|
||||||
"aboutVersion": "Version {version}",
|
"aboutVersion": "Version {version}",
|
||||||
"@aboutVersion": {
|
"@aboutVersion": {
|
||||||
@@ -161,5 +166,16 @@
|
|||||||
"networkPermissionOpenSettings": "Open System Settings",
|
"networkPermissionOpenSettings": "Open System Settings",
|
||||||
"microphonePermissionTitle": "Microphone Permission Required",
|
"microphonePermissionTitle": "Microphone Permission Required",
|
||||||
"microphonePermissionBody": "Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.",
|
"microphonePermissionBody": "Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.",
|
||||||
|
"microphonePermissionRequiredForVoice": "Microphone permission is required for voice transmission.",
|
||||||
|
"permissionGrantAction": "Grant",
|
||||||
|
"audioRouteSystemDefault": "System default",
|
||||||
|
"audioRouteEarpiece": "Earpiece",
|
||||||
|
"audioRouteUsbHeadset": "USB headset",
|
||||||
|
"audioRouteOtherDevice": "Other device",
|
||||||
|
"audioRouteRefreshDevices": "Refresh audio devices",
|
||||||
|
"audioRouteCannotSelect": "This output cannot be selected.",
|
||||||
|
"audioRouteChangeFailed": "Could not change audio output.",
|
||||||
|
"iosAudioInterrupted": "Audio interrupted by system (phone call)",
|
||||||
|
"iosAudioResuming": "Audio resuming",
|
||||||
"permissionDenied": "Permission Denied"
|
"permissionDenied": "Permission Denied"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
"disconnectAction": "断开连接",
|
"disconnectAction": "断开连接",
|
||||||
"refreshAction": "刷新",
|
"refreshAction": "刷新",
|
||||||
"diagnosticsAction": "诊断信息",
|
"diagnosticsAction": "诊断信息",
|
||||||
|
"diagnosticsSaveAction": "保存导出",
|
||||||
|
"diagnosticsSaved": "诊断导出已保存到 {path}",
|
||||||
"aboutAction": "关于",
|
"aboutAction": "关于",
|
||||||
"aboutVersion": "版本 {version}",
|
"aboutVersion": "版本 {version}",
|
||||||
"aboutAuthor": "作者: Edison Jwa",
|
"aboutAuthor": "作者: Edison Jwa",
|
||||||
@@ -118,5 +120,16 @@
|
|||||||
"networkPermissionOpenSettings": "打开系统设置",
|
"networkPermissionOpenSettings": "打开系统设置",
|
||||||
"microphonePermissionTitle": "需要麦克风权限",
|
"microphonePermissionTitle": "需要麦克风权限",
|
||||||
"microphonePermissionBody": "Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。",
|
"microphonePermissionBody": "Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。",
|
||||||
|
"microphonePermissionRequiredForVoice": "语音发送需要麦克风权限。",
|
||||||
|
"permissionGrantAction": "授权",
|
||||||
|
"audioRouteSystemDefault": "系统默认",
|
||||||
|
"audioRouteEarpiece": "听筒",
|
||||||
|
"audioRouteUsbHeadset": "USB 耳机",
|
||||||
|
"audioRouteOtherDevice": "其他设备",
|
||||||
|
"audioRouteRefreshDevices": "刷新音频设备",
|
||||||
|
"audioRouteCannotSelect": "无法选择此输出设备。",
|
||||||
|
"audioRouteChangeFailed": "无法切换音频输出。",
|
||||||
|
"iosAudioInterrupted": "系统已中断音频(电话通话)",
|
||||||
|
"iosAudioResuming": "音频正在恢复",
|
||||||
"permissionDenied": "权限被拒绝"
|
"permissionDenied": "权限被拒绝"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,18 @@ abstract class AppL10n {
|
|||||||
/// **'Diagnostics'**
|
/// **'Diagnostics'**
|
||||||
String get diagnosticsAction;
|
String get diagnosticsAction;
|
||||||
|
|
||||||
|
/// No description provided for @diagnosticsSaveAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save export'**
|
||||||
|
String get diagnosticsSaveAction;
|
||||||
|
|
||||||
|
/// No description provided for @diagnosticsSaved.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Diagnostic export saved to {path}'**
|
||||||
|
String diagnosticsSaved(String path);
|
||||||
|
|
||||||
/// No description provided for @aboutAction.
|
/// No description provided for @aboutAction.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -721,6 +733,72 @@ abstract class AppL10n {
|
|||||||
/// **'Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.'**
|
/// **'Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.'**
|
||||||
String get microphonePermissionBody;
|
String get microphonePermissionBody;
|
||||||
|
|
||||||
|
/// No description provided for @microphonePermissionRequiredForVoice.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Microphone permission is required for voice transmission.'**
|
||||||
|
String get microphonePermissionRequiredForVoice;
|
||||||
|
|
||||||
|
/// No description provided for @permissionGrantAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Grant'**
|
||||||
|
String get permissionGrantAction;
|
||||||
|
|
||||||
|
/// No description provided for @audioRouteSystemDefault.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'System default'**
|
||||||
|
String get audioRouteSystemDefault;
|
||||||
|
|
||||||
|
/// No description provided for @audioRouteEarpiece.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Earpiece'**
|
||||||
|
String get audioRouteEarpiece;
|
||||||
|
|
||||||
|
/// No description provided for @audioRouteUsbHeadset.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'USB headset'**
|
||||||
|
String get audioRouteUsbHeadset;
|
||||||
|
|
||||||
|
/// No description provided for @audioRouteOtherDevice.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Other device'**
|
||||||
|
String get audioRouteOtherDevice;
|
||||||
|
|
||||||
|
/// No description provided for @audioRouteRefreshDevices.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Refresh audio devices'**
|
||||||
|
String get audioRouteRefreshDevices;
|
||||||
|
|
||||||
|
/// No description provided for @audioRouteCannotSelect.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This output cannot be selected.'**
|
||||||
|
String get audioRouteCannotSelect;
|
||||||
|
|
||||||
|
/// No description provided for @audioRouteChangeFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not change audio output.'**
|
||||||
|
String get audioRouteChangeFailed;
|
||||||
|
|
||||||
|
/// No description provided for @iosAudioInterrupted.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Audio interrupted by system (phone call)'**
|
||||||
|
String get iosAudioInterrupted;
|
||||||
|
|
||||||
|
/// No description provided for @iosAudioResuming.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Audio resuming'**
|
||||||
|
String get iosAudioResuming;
|
||||||
|
|
||||||
/// No description provided for @permissionDenied.
|
/// No description provided for @permissionDenied.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ class AppL10nEn extends AppL10n {
|
|||||||
@override
|
@override
|
||||||
String get diagnosticsAction => 'Diagnostics';
|
String get diagnosticsAction => 'Diagnostics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsSaveAction => 'Save export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagnosticsSaved(String path) {
|
||||||
|
return 'Diagnostic export saved to $path';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAction => 'About';
|
String get aboutAction => 'About';
|
||||||
|
|
||||||
@@ -360,6 +368,40 @@ class AppL10nEn extends AppL10n {
|
|||||||
String get microphonePermissionBody =>
|
String get microphonePermissionBody =>
|
||||||
'Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.';
|
'Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get microphonePermissionRequiredForVoice =>
|
||||||
|
'Microphone permission is required for voice transmission.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get permissionGrantAction => 'Grant';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteSystemDefault => 'System default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteEarpiece => 'Earpiece';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteUsbHeadset => 'USB headset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteOtherDevice => 'Other device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteRefreshDevices => 'Refresh audio devices';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteCannotSelect => 'This output cannot be selected.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteChangeFailed => 'Could not change audio output.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get iosAudioInterrupted => 'Audio interrupted by system (phone call)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get iosAudioResuming => 'Audio resuming';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get permissionDenied => 'Permission Denied';
|
String get permissionDenied => 'Permission Denied';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ class AppL10nZh extends AppL10n {
|
|||||||
@override
|
@override
|
||||||
String get diagnosticsAction => '诊断信息';
|
String get diagnosticsAction => '诊断信息';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsSaveAction => '保存导出';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagnosticsSaved(String path) {
|
||||||
|
return '诊断导出已保存到 $path';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAction => '关于';
|
String get aboutAction => '关于';
|
||||||
|
|
||||||
@@ -352,6 +360,39 @@ class AppL10nZh extends AppL10n {
|
|||||||
String get microphonePermissionBody =>
|
String get microphonePermissionBody =>
|
||||||
'Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。';
|
'Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get microphonePermissionRequiredForVoice => '语音发送需要麦克风权限。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get permissionGrantAction => '授权';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteSystemDefault => '系统默认';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteEarpiece => '听筒';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteUsbHeadset => 'USB 耳机';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteOtherDevice => '其他设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteRefreshDevices => '刷新音频设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteCannotSelect => '无法选择此输出设备。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioRouteChangeFailed => '无法切换音频输出。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get iosAudioInterrupted => '系统已中断音频(电话通话)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get iosAudioResuming => '音频正在恢复';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get permissionDenied => '权限被拒绝';
|
String get permissionDenied => '权限被拒绝';
|
||||||
}
|
}
|
||||||
|
|||||||
+239
-171
@@ -9,9 +9,9 @@
|
|||||||
// (all carried over from v0.3.0-beta.1)
|
// (all carried over from v0.3.0-beta.1)
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io' show File, Platform, Process;
|
||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'dart:io' show Platform, Process;
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -21,18 +21,54 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
|
|
||||||
import 'l10n/generated/app_localizations.dart';
|
import 'l10n/generated/app_localizations.dart';
|
||||||
import 'services/android_permissions_service.dart';
|
import 'services/android_permissions_service.dart';
|
||||||
|
import 'services/ios_permissions_service.dart';
|
||||||
import 'src/rust/api.dart' as rust;
|
import 'src/rust/api.dart' as rust;
|
||||||
import 'src/rust/lib.dart' as rust_err;
|
import 'src/rust/lib.dart' as rust_err;
|
||||||
import 'src/rust/frb_generated.dart';
|
import 'src/rust/frb_generated.dart';
|
||||||
import 'widgets/permission_state_banner.dart';
|
import 'widgets/permission_state_banner.dart';
|
||||||
|
import 'widgets/voice_platform.dart';
|
||||||
import 'widgets/voice_bar.dart';
|
import 'widgets/voice_bar.dart';
|
||||||
import 'widgets/voice_compact.dart';
|
import 'widgets/voice_compact.dart';
|
||||||
import 'widgets/voice_settings.dart';
|
import 'widgets/voice_settings.dart';
|
||||||
|
|
||||||
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
|
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
|
||||||
|
|
||||||
|
const MethodChannel _iosPlatformChannel = MethodChannel('chanora/ios_platform');
|
||||||
|
|
||||||
const Color _appSurfaceColor = Color(0xFFFFFBFE);
|
const Color _appSurfaceColor = Color(0xFFFFFBFE);
|
||||||
|
|
||||||
|
const String _sileroVadAsset = 'assets/models/silero_vad.onnx';
|
||||||
|
const String _tenVadAsset = 'assets/models/ten_vad.onnx';
|
||||||
|
|
||||||
|
Future<File> _copyBundledAssetToDocuments({
|
||||||
|
required String assetPath,
|
||||||
|
required String fileName,
|
||||||
|
}) async {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final file = File('${dir.path}/$fileName');
|
||||||
|
final data = await rootBundle.load(assetPath);
|
||||||
|
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||||
|
|
||||||
|
if (await file.exists() && await file.length() == bytes.length) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
await file.writeAsBytes(bytes, flush: true);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _configureBundledVadModels() async {
|
||||||
|
final silero = await _copyBundledAssetToDocuments(
|
||||||
|
assetPath: _sileroVadAsset,
|
||||||
|
fileName: 'silero_vad.onnx',
|
||||||
|
);
|
||||||
|
await _copyBundledAssetToDocuments(
|
||||||
|
assetPath: _tenVadAsset,
|
||||||
|
fileName: 'ten_vad.onnx',
|
||||||
|
);
|
||||||
|
await rust.setVadModelPath(path: silero.path);
|
||||||
|
}
|
||||||
|
|
||||||
/// Top padding for macOS to clear traffic-light buttons.
|
/// Top padding for macOS to clear traffic-light buttons.
|
||||||
const double _macOSTrafficLightPad = 56.0;
|
const double _macOSTrafficLightPad = 56.0;
|
||||||
|
|
||||||
@@ -78,15 +114,6 @@ String? _pttDisplayLabelForKey(LogicalKeyboardKey k) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the host is a touch-only mobile platform without a
|
|
||||||
/// hardware keyboard. Mirrors the helpers in widgets/voice_bar.dart
|
|
||||||
/// and widgets/voice_settings.dart so the AppBar + narrow-mode
|
|
||||||
/// layout in main.dart can branch consistently.
|
|
||||||
bool get _isTouchOnlyPttHost {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isIOS || Platform.isAndroid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Public version string shown in the About dialog. Resolved at
|
/// Public version string shown in the About dialog. Resolved at
|
||||||
/// app init by combining a hardcoded semver baseline (kept in sync
|
/// app init by combining a hardcoded semver baseline (kept in sync
|
||||||
/// with the git tag and pubspec.yaml's `version:` field) with the
|
/// with the git tag and pubspec.yaml's `version:` field) with the
|
||||||
@@ -124,6 +151,23 @@ Future<void> main() async {
|
|||||||
|
|
||||||
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
|
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
|
||||||
///
|
///
|
||||||
|
rust.BridgeAudioRoute _parseBridgeAudioRoute(String s) {
|
||||||
|
switch (s) {
|
||||||
|
case 'Earpiece':
|
||||||
|
return rust.BridgeAudioRoute.earpiece;
|
||||||
|
case 'Speaker':
|
||||||
|
return rust.BridgeAudioRoute.speaker;
|
||||||
|
case 'WiredHeadset':
|
||||||
|
return rust.BridgeAudioRoute.wiredHeadset;
|
||||||
|
case 'BluetoothHfp':
|
||||||
|
return rust.BridgeAudioRoute.bluetoothHfp;
|
||||||
|
case 'BluetoothA2dp':
|
||||||
|
return rust.BridgeAudioRoute.bluetoothA2Dp;
|
||||||
|
default:
|
||||||
|
return rust.BridgeAudioRoute.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Swift side (AppDelegate) posts route-change and interruption
|
/// Swift side (AppDelegate) posts route-change and interruption
|
||||||
/// events through `FlutterMethodChannel` named
|
/// events through `FlutterMethodChannel` named
|
||||||
/// `"chanora/ios_audio_lifecycle"`. This handler dispatches them to
|
/// `"chanora/ios_audio_lifecycle"`. This handler dispatches them to
|
||||||
@@ -134,7 +178,12 @@ void _wireIosAudioLifecycle() {
|
|||||||
try {
|
try {
|
||||||
switch (call.method) {
|
switch (call.method) {
|
||||||
case 'handleRouteChange':
|
case 'handleRouteChange':
|
||||||
rust.handleRouteChange();
|
final routeStr = call.arguments as String? ?? 'Unknown';
|
||||||
|
final route = _parseBridgeAudioRoute(routeStr);
|
||||||
|
rust.handleRouteChange(route: route);
|
||||||
|
break;
|
||||||
|
case 'handleMediaServicesReset':
|
||||||
|
rust.handleMediaServicesReset();
|
||||||
break;
|
break;
|
||||||
case 'handleInterruptionBegan':
|
case 'handleInterruptionBegan':
|
||||||
rust.handleInterruptionBegan();
|
rust.handleInterruptionBegan();
|
||||||
@@ -144,6 +193,16 @@ void _wireIosAudioLifecycle() {
|
|||||||
final shouldResume = call.arguments as bool? ?? false;
|
final shouldResume = call.arguments as bool? ?? false;
|
||||||
rust.handleInterruptionEnded(shouldResume: shouldResume);
|
rust.handleInterruptionEnded(shouldResume: shouldResume);
|
||||||
break;
|
break;
|
||||||
|
case 'handleWillResignActive':
|
||||||
|
case 'handleDidEnterBackground':
|
||||||
|
rust.handleInterruptionBegan();
|
||||||
|
break;
|
||||||
|
case 'handleWillEnterForeground':
|
||||||
|
rust.handleInterruptionEnded(shouldResume: true);
|
||||||
|
break;
|
||||||
|
case 'handleWillTerminate':
|
||||||
|
rust.handleInterruptionBegan();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// Unknown method — ignore gracefully rather than crashing.
|
// Unknown method — ignore gracefully rather than crashing.
|
||||||
break;
|
break;
|
||||||
@@ -315,6 +374,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
// (see AndroidPermissionsService for the platform branch).
|
// (see AndroidPermissionsService for the platform branch).
|
||||||
final AndroidPermissionsService _androidPermissions =
|
final AndroidPermissionsService _androidPermissions =
|
||||||
AndroidPermissionsService();
|
AndroidPermissionsService();
|
||||||
|
final IosPermissionsService _iosPermissions = IosPermissionsService();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -325,9 +385,13 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
// events as early as possible so the listen-only banner reflects
|
// events as early as possible so the listen-only banner reflects
|
||||||
// the system state on first frame.
|
// the system state on first frame.
|
||||||
_androidPermissions.start();
|
_androidPermissions.start();
|
||||||
|
unawaited(_iosPermissions.start());
|
||||||
_androidPermissions.recordAudioState.addListener(
|
_androidPermissions.recordAudioState.addListener(
|
||||||
_onRecordAudioPermissionChanged,
|
_onRecordAudioPermissionChanged,
|
||||||
);
|
);
|
||||||
|
_iosPermissions.recordAudioState.addListener(
|
||||||
|
_onRecordAudioPermissionChanged,
|
||||||
|
);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
unawaited(_requestRecordAudioOnStartup());
|
unawaited(_requestRecordAudioOnStartup());
|
||||||
});
|
});
|
||||||
@@ -337,15 +401,34 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
|
|
||||||
Future<void> _requestRecordAudioOnStartup() async {
|
Future<void> _requestRecordAudioOnStartup() async {
|
||||||
try {
|
try {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
await _androidPermissions.ensureRecordAudio();
|
await _androidPermissions.ensureRecordAudio();
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Best-effort startup prompt only. The join path still gates on
|
// Best-effort startup prompt only. The join path still gates on
|
||||||
// ensureRecordAudio() and applies the listen-only hard-mute policy.
|
// ensureRecordAudio() and applies the listen-only hard-mute policy.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ValueListenable<AndroidRecordAudioPermissionState>
|
||||||
|
get _activeRecordAudioState => Platform.isIOS
|
||||||
|
? _iosPermissions.recordAudioState
|
||||||
|
: _androidPermissions.recordAudioState;
|
||||||
|
|
||||||
|
Future<AndroidRecordAudioPermissionState> _ensureActiveRecordAudio() {
|
||||||
|
return Platform.isIOS
|
||||||
|
? _iosPermissions.ensureRecordAudio()
|
||||||
|
: _androidPermissions.ensureRecordAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openActivePermissionSettings() {
|
||||||
|
return Platform.isIOS
|
||||||
|
? _iosPermissions.openAppSettings()
|
||||||
|
: _androidPermissions.openAppSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void _onRecordAudioPermissionChanged() {
|
void _onRecordAudioPermissionChanged() {
|
||||||
if (_androidPermissions.recordAudioState.value ==
|
if (_activeRecordAudioState.value ==
|
||||||
AndroidRecordAudioPermissionState.granted) {
|
AndroidRecordAudioPermissionState.granted) {
|
||||||
unawaited(_clearPermissionHardMute());
|
unawaited(_clearPermissionHardMute());
|
||||||
}
|
}
|
||||||
@@ -528,16 +611,16 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
if (began) {
|
if (began) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Audio interrupted by system (phone call)'),
|
content: Text(AppL10n.of(context).iosAudioInterrupted),
|
||||||
duration: Duration(seconds: 3),
|
duration: Duration(seconds: 3),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: Colors.orange,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (shouldResume) {
|
} else if (shouldResume) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Audio resuming'),
|
content: Text(AppL10n.of(context).iosAudioResuming),
|
||||||
duration: Duration(seconds: 2),
|
duration: Duration(seconds: 2),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
),
|
||||||
@@ -612,10 +695,14 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
_androidPermissions.recordAudioState.removeListener(
|
_androidPermissions.recordAudioState.removeListener(
|
||||||
_onRecordAudioPermissionChanged,
|
_onRecordAudioPermissionChanged,
|
||||||
);
|
);
|
||||||
|
_iosPermissions.recordAudioState.removeListener(
|
||||||
|
_onRecordAudioPermissionChanged,
|
||||||
|
);
|
||||||
// SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
|
// SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
|
||||||
// late invokeMethod from the platform side cannot land on this
|
// late invokeMethod from the platform side cannot land on this
|
||||||
// disposed state.
|
// disposed state.
|
||||||
_androidPermissions.stop();
|
_androidPermissions.stop();
|
||||||
|
_iosPermissions.stop();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,6 +757,14 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
: l10n.microphonePermissionBody,
|
: l10n.microphonePermissionBody,
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
if (Platform.isIOS && !isNetwork)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
unawaited(_openIosAppSettings());
|
||||||
|
},
|
||||||
|
child: Text(l10n.networkPermissionOpenSettings),
|
||||||
|
),
|
||||||
if (Platform.isMacOS)
|
if (Platform.isMacOS)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -691,6 +786,15 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openIosAppSettings() async {
|
||||||
|
try {
|
||||||
|
await _iosPlatformChannel.invokeMethod<bool>('openAppSettings');
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort affordance only; if iOS refuses the URL, the
|
||||||
|
// dialog still explained the missing microphone permission.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
Future<void> _setPtt(bool active, {bool reportError = true}) async {
|
Future<void> _setPtt(bool active, {bool reportError = true}) async {
|
||||||
try {
|
try {
|
||||||
@@ -718,13 +822,9 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
final next = !_outputMuted;
|
final next = !_outputMuted;
|
||||||
try {
|
try {
|
||||||
await rust.setOutputMuted(muted: next);
|
await rust.setOutputMuted(muted: next);
|
||||||
await rust.setHardMute(
|
|
||||||
muted: next || _inputMuted || _hardMuteByPermission,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_outputMuted = next;
|
_outputMuted = next;
|
||||||
_hardMute = next || _inputMuted || _hardMuteByPermission;
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -770,7 +870,11 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
// Trace: SDD-106 §1 (request timing), §2 (listen-only on denial),
|
// Trace: SDD-106 §1 (request timing), §2 (listen-only on denial),
|
||||||
// §3 (path to settings on permanent denial), §6
|
// §3 (path to settings on permanent denial), §6
|
||||||
// (TransmitModeSelector clamp); SRS-209.
|
// (TransmitModeSelector clamp); SRS-209.
|
||||||
final permState = await _androidPermissions.ensureRecordAudio();
|
final permState = Platform.isAndroid
|
||||||
|
? await _androidPermissions.ensureRecordAudio()
|
||||||
|
: Platform.isIOS
|
||||||
|
? await _iosPermissions.ensureRecordAudio()
|
||||||
|
: AndroidRecordAudioPermissionState.granted;
|
||||||
if (permState != AndroidRecordAudioPermissionState.granted) {
|
if (permState != AndroidRecordAudioPermissionState.granted) {
|
||||||
// Listen-only: clamp hard-mute. The permission_state_banner
|
// Listen-only: clamp hard-mute. The permission_state_banner
|
||||||
// surfaces the path-to-grant; the user can re-attempt at any
|
// surfaces the path-to-grant; the user can re-attempt at any
|
||||||
@@ -800,6 +904,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await _configureBundledVadModels();
|
||||||
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
|
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
unawaited(_onRefresh());
|
unawaited(_onRefresh());
|
||||||
@@ -930,19 +1035,42 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
/// Narrow-mode voice controls modal sheet (Plan E status chip
|
/// Narrow-mode voice controls modal sheet (Plan E status chip
|
||||||
/// trigger). On mobile this is the **single** voice-controls
|
/// trigger). On mobile this is the **single** voice-controls
|
||||||
/// surface: route picker + inline mode radio + inline release-tail
|
/// surface: route picker + inline mode radio + inline release-tail
|
||||||
/// slider + level meter + stats + (desktop-only) capability badge.
|
/// slider + level meter + stats + audio processing + (desktop-only)
|
||||||
/// Zero navigation depth \u2014 no nested dialog.
|
/// capability badge. Zero navigation depth — no nested dialog.
|
||||||
Future<void> _onOpenVoiceDetailsSheet() async {
|
Future<void> _onOpenVoiceDetailsSheet() async {
|
||||||
|
// Load current audio processing config for the sheet.
|
||||||
|
rust.BridgeAudioProcessingConfig audioConfig;
|
||||||
|
try {
|
||||||
|
audioConfig = await rust.getAudioProcessingConfig();
|
||||||
|
} catch (_) {
|
||||||
|
audioConfig = const rust.BridgeAudioProcessingConfig(
|
||||||
|
route: rust.BridgeAudioRoute.unknown,
|
||||||
|
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||||
|
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||||
|
vadBackend: rust.BridgeVadBackend.sileroOnnx,
|
||||||
|
aec: rust.BridgeEffectOwner.platform,
|
||||||
|
ns: rust.BridgeEffectOwner.platform,
|
||||||
|
agc: rust.BridgeEffectOwner.platform,
|
||||||
|
hpfEnabled: true,
|
||||||
|
limiterEnabled: true,
|
||||||
|
vadHangoverMs: 500,
|
||||||
|
vadPreRollMs: 160,
|
||||||
|
vadMinTxMs: 200,
|
||||||
|
debugWavDumpEnabled: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
await showVoiceDetailsSheet(
|
await showVoiceDetailsSheet(
|
||||||
context,
|
context,
|
||||||
audioStats: _audioStats,
|
|
||||||
transmitMode: _transmitMode,
|
transmitMode: _transmitMode,
|
||||||
releaseTailMs: _releaseTailMs,
|
releaseTailMs: _releaseTailMs,
|
||||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||||
pttLevel: _pttLevel,
|
pttLevel: _pttLevel,
|
||||||
pttBackendId: _pttBackendId,
|
pttBackendId: _pttBackendId,
|
||||||
pttBoundInputClass: _pttBoundInputClass,
|
pttBoundInputClass: _pttBoundInputClass,
|
||||||
isTouchOnly: _isTouchOnlyPttHost,
|
isTouchOnly: isTouchOnlyPttHost,
|
||||||
|
initialAudioConfig: audioConfig,
|
||||||
onModeChanged: (mode) async {
|
onModeChanged: (mode) async {
|
||||||
try {
|
try {
|
||||||
await rust.setTransmitMode(mode: mode);
|
await rust.setTransmitMode(mode: mode);
|
||||||
@@ -963,21 +1091,55 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
setState(() => _error = e.toString());
|
setState(() => _error = e.toString());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onAudioConfigChanged: (config) async {
|
||||||
|
try {
|
||||||
|
await rust.setAudioProcessingConfig(config: config);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _error = e.toString());
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onOpenVoiceSettings() async {
|
Future<void> _onOpenVoiceSettings() async {
|
||||||
|
// Load the current audio processing config before opening the dialog.
|
||||||
|
rust.BridgeAudioProcessingConfig audioConfig;
|
||||||
|
try {
|
||||||
|
audioConfig = await rust.getAudioProcessingConfig();
|
||||||
|
} catch (_) {
|
||||||
|
// If not connected yet, use a sensible default.
|
||||||
|
audioConfig = const rust.BridgeAudioProcessingConfig(
|
||||||
|
route: rust.BridgeAudioRoute.unknown,
|
||||||
|
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||||
|
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||||
|
vadBackend: rust.BridgeVadBackend.sileroOnnx,
|
||||||
|
aec: rust.BridgeEffectOwner.platform,
|
||||||
|
ns: rust.BridgeEffectOwner.platform,
|
||||||
|
agc: rust.BridgeEffectOwner.platform,
|
||||||
|
hpfEnabled: true,
|
||||||
|
limiterEnabled: true,
|
||||||
|
vadHangoverMs: 500,
|
||||||
|
vadPreRollMs: 160,
|
||||||
|
vadMinTxMs: 200,
|
||||||
|
debugWavDumpEnabled: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
final result = await showDialog<VoiceSettingsResult>(
|
final result = await showDialog<VoiceSettingsResult>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => VoiceSettingsDialog(
|
builder: (ctx) => VoiceSettingsDialog(
|
||||||
initialMode: _transmitMode,
|
initialMode: _transmitMode,
|
||||||
initialReleaseTailMs: _releaseTailMs,
|
initialReleaseTailMs: _releaseTailMs,
|
||||||
|
initialAudioConfig: audioConfig,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (result == null) return;
|
if (result == null) return;
|
||||||
try {
|
try {
|
||||||
await rust.setTransmitMode(mode: result.mode);
|
await rust.setTransmitMode(mode: result.mode);
|
||||||
await rust.setReleaseTailMs(ms: result.releaseTailMs);
|
await rust.setReleaseTailMs(ms: result.releaseTailMs);
|
||||||
|
await rust.setAudioProcessingConfig(config: result.audioConfig);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _error = e.toString());
|
setState(() => _error = e.toString());
|
||||||
@@ -1083,6 +1245,27 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
final path = await _writeDiagnosticExport(text);
|
||||||
|
if (!ctx.mounted) return;
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l10n.diagnosticsSaved(path))),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!ctx.mounted) return;
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l10n.statusError(e.toString()))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(l10n.diagnosticsSaveAction),
|
||||||
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await Clipboard.setData(ClipboardData(text: text));
|
await Clipboard.setData(ClipboardData(text: text));
|
||||||
@@ -1100,6 +1283,18 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> _writeDiagnosticExport(String text) async {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final stamp = DateTime.now()
|
||||||
|
.toUtc()
|
||||||
|
.toIso8601String()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replaceAll('.', '-');
|
||||||
|
final file = File('${dir.path}/chanora-diagnostics-$stamp.txt');
|
||||||
|
await file.writeAsString(text, flush: true);
|
||||||
|
return file.path;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onConfigurePtt(BuildContext context) async {
|
Future<void> _onConfigurePtt(BuildContext context) async {
|
||||||
// On the Linux GNOME-Wayland portal backend, the portal hosts
|
// On the Linux GNOME-Wayland portal backend, the portal hosts
|
||||||
// its own system-managed binding dialog (gen2 v0.9.3 / Q3a).
|
// its own system-managed binding dialog (gen2 v0.9.3 / Q3a).
|
||||||
@@ -1399,6 +1594,12 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (_phase == _Phase.idle) ...[
|
if (_phase == _Phase.idle) ...[
|
||||||
Expanded(
|
Expanded(
|
||||||
|
child: AnimatedPadding(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
|
||||||
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
keyboardDismissBehavior:
|
keyboardDismissBehavior:
|
||||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
@@ -1422,6 +1623,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
] else if (_phase == _Phase.connecting) ...[
|
] else if (_phase == _Phase.connecting) ...[
|
||||||
const Center(
|
const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -1450,10 +1652,13 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
onConfigure: _onOpenVoiceSettings,
|
onConfigure: _onOpenVoiceSettings,
|
||||||
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
||||||
);
|
);
|
||||||
// SDD-106 §2/§3 + SRS-209: listen-only banner.
|
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
|
||||||
// Self-hides on granted / unknown / non-Android.
|
// banner. Self-hides on granted / unknown.
|
||||||
final permissionBanner = PermissionStateBanner(
|
final permissionBanner =
|
||||||
service: _androidPermissions,
|
PermissionStateBanner.fromCallbacks(
|
||||||
|
recordAudioState: _activeRecordAudioState,
|
||||||
|
ensureRecordAudio: _ensureActiveRecordAudio,
|
||||||
|
openAppSettings: _openActivePermissionSettings,
|
||||||
);
|
);
|
||||||
final snapshotView = _SnapshotView(
|
final snapshotView = _SnapshotView(
|
||||||
snapshot: _snapshot!,
|
snapshot: _snapshot!,
|
||||||
@@ -1501,7 +1706,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
releaseTailMs: _releaseTailMs,
|
releaseTailMs: _releaseTailMs,
|
||||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||||
audioStats: _audioStats,
|
audioStats: _audioStats,
|
||||||
isTouchOnly: _isTouchOnlyPttHost,
|
isTouchOnly: isTouchOnlyPttHost,
|
||||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
onTap: () => _onOpenVoiceDetailsSheet(),
|
||||||
),
|
),
|
||||||
if (_inChannel &&
|
if (_inChannel &&
|
||||||
@@ -1552,7 +1757,10 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: headerTitle, actions: headerActions),
|
appBar: AppBar(title: headerTitle, actions: headerActions),
|
||||||
body: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1961,146 +2169,6 @@ class _BookmarkList extends StatelessWidget {
|
|||||||
/// Driven by the `BridgeEvent::PttCapability` stream published by
|
/// Driven by the `BridgeEvent::PttCapability` stream published by
|
||||||
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
|
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
|
||||||
/// updates the props on each transition.
|
/// updates the props on each transition.
|
||||||
class PttCapabilityBadge extends StatelessWidget {
|
|
||||||
/// Construct a badge.
|
|
||||||
const PttCapabilityBadge({
|
|
||||||
super.key,
|
|
||||||
required this.level,
|
|
||||||
required this.backendId,
|
|
||||||
required this.boundInputClass,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Resolved capability level as the bridge emits it
|
|
||||||
/// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
|
|
||||||
/// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
|
|
||||||
final String level;
|
|
||||||
|
|
||||||
/// Stable backend identifier (`focused`, `windows-raw-input`, …).
|
|
||||||
final String backendId;
|
|
||||||
|
|
||||||
/// Privacy-safe input class (`keyboard`, `mouse-side-button`,
|
|
||||||
/// or empty when no binding is set).
|
|
||||||
final String boundInputClass;
|
|
||||||
|
|
||||||
bool get _isFocused => level == 'L0Focused';
|
|
||||||
|
|
||||||
String _explainBodyForPlatform(AppL10n l10n) {
|
|
||||||
// Use `defaultTargetPlatform` rather than `Theme.of(context).platform`
|
|
||||||
// because the latter is influenced by debug platform overrides
|
|
||||||
// that callers may toggle in dev mode. We want the badge's
|
|
||||||
// explanation to match the actual host OS.
|
|
||||||
switch (defaultTargetPlatform) {
|
|
||||||
case TargetPlatform.windows:
|
|
||||||
return l10n.pttCapabilityExplainGoGlobalWindows;
|
|
||||||
case TargetPlatform.macOS:
|
|
||||||
return l10n.pttCapabilityExplainGoGlobalMacos;
|
|
||||||
case TargetPlatform.linux:
|
|
||||||
return l10n.pttCapabilityExplainGoGlobalLinux;
|
|
||||||
case TargetPlatform.iOS:
|
|
||||||
return l10n.pttCapabilityExplainGoGlobalIos;
|
|
||||||
default:
|
|
||||||
return l10n.pttCapabilityExplainGoGlobalGeneric;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openExplanationSheet(BuildContext context) {
|
|
||||||
final l10n = AppL10n.of(context);
|
|
||||||
showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
showDragHandle: true,
|
|
||||||
builder: (sheetContext) {
|
|
||||||
final theme = Theme.of(sheetContext);
|
|
||||||
return SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
l10n.pttCapabilityExplainTitle,
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
l10n.pttCapabilityExplainFocusedHeading,
|
|
||||||
style: theme.textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
l10n.pttCapabilityExplainFocusedBody,
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
_explainBodyForPlatform(l10n),
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Align(
|
|
||||||
alignment: AlignmentDirectional.centerEnd,
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
|
||||||
child: Text(l10n.closeAction),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppL10n.of(context);
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
|
|
||||||
final tooltipMessage = boundInputClass.isEmpty
|
|
||||||
? badgeLabel
|
|
||||||
: '$badgeLabel\n($boundInputClass)';
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
|
||||||
child: Tooltip(
|
|
||||||
message: tooltipMessage,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_isFocused ? Icons.crop_free : Icons.public,
|
|
||||||
size: 14,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
badgeLabel,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Info icon only for L0Focused — the explanation sheet
|
|
||||||
// tells the user why their PTT may not work outside the
|
|
||||||
// app window and how to grant the permission. There is
|
|
||||||
// intentionally NO 'Configure' button here: the single
|
|
||||||
// configuration entry point is the Voice Bar's
|
|
||||||
// settings gear (onConfigure on `VoiceBar`). Having two
|
|
||||||
// identical bind-key entry points just confuses users.
|
|
||||||
if (_isFocused)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.info_outline, size: 16),
|
|
||||||
tooltip: l10n.pttCapabilityExplainTitle,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
onPressed: () => _openExplanationSheet(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SnapshotView extends StatelessWidget {
|
class _SnapshotView extends StatelessWidget {
|
||||||
const _SnapshotView({
|
const _SnapshotView({
|
||||||
required this.snapshot,
|
required this.snapshot,
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/// iOS microphone permission integration for AVAudioSession.
|
||||||
|
///
|
||||||
|
/// Trace:
|
||||||
|
/// - SRS-164 (iOS system permission presentation and settings path).
|
||||||
|
/// - SRS-114 / SRS-138 (iOS platform-service behaviour for audio).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'android_permissions_service.dart';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String iosPlatformChannelName = 'chanora/ios_platform';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodGetMicrophonePermissionState =
|
||||||
|
'getMicrophonePermissionState';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodRequestMicrophonePermission = 'requestMicrophonePermission';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodIosOpenAppSettings = 'openAppSettings';
|
||||||
|
|
||||||
|
/// Dart-side integration for iOS microphone permission state.
|
||||||
|
class IosPermissionsService {
|
||||||
|
IosPermissionsService({MethodChannel? channel})
|
||||||
|
: _channel =
|
||||||
|
channel ??
|
||||||
|
(_isIOS ? const MethodChannel(iosPlatformChannelName) : null);
|
||||||
|
|
||||||
|
static bool get _isIOS {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
return Platform.isIOS;
|
||||||
|
}
|
||||||
|
|
||||||
|
final MethodChannel? _channel;
|
||||||
|
|
||||||
|
final ValueNotifier<AndroidRecordAudioPermissionState> _state =
|
||||||
|
ValueNotifier<AndroidRecordAudioPermissionState>(
|
||||||
|
_isIOS
|
||||||
|
? AndroidRecordAudioPermissionState.unknown
|
||||||
|
: AndroidRecordAudioPermissionState.granted,
|
||||||
|
);
|
||||||
|
|
||||||
|
ValueListenable<AndroidRecordAudioPermissionState> get recordAudioState =>
|
||||||
|
_state;
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
try {
|
||||||
|
_state.value = _parseState(
|
||||||
|
await ch.invokeMethod<String>(methodGetMicrophonePermissionState),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
_state.value = AndroidRecordAudioPermissionState.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {}
|
||||||
|
|
||||||
|
Future<AndroidRecordAudioPermissionState> ensureRecordAudio() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return AndroidRecordAudioPermissionState.granted;
|
||||||
|
try {
|
||||||
|
final state = _parseState(
|
||||||
|
await ch.invokeMethod<String>(methodRequestMicrophonePermission),
|
||||||
|
);
|
||||||
|
_state.value = state;
|
||||||
|
return state;
|
||||||
|
} catch (_) {
|
||||||
|
return _state.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openAppSettings() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
try {
|
||||||
|
await ch.invokeMethod<bool>(methodIosOpenAppSettings);
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'openAppSettings failed',
|
||||||
|
name: 'IosPermissionsService',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void dispose() {
|
||||||
|
_state.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidRecordAudioPermissionState _parseState(String? raw) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'Granted':
|
||||||
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
|
case 'Denied':
|
||||||
|
return AndroidRecordAudioPermissionState.permanentlyDenied;
|
||||||
|
case 'NotDetermined':
|
||||||
|
return AndroidRecordAudioPermissionState.denied;
|
||||||
|
default:
|
||||||
|
return AndroidRecordAudioPermissionState.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
|||||||
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
|
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
|
||||||
part 'api.freezed.dart';
|
part 'api.freezed.dart';
|
||||||
|
|
||||||
// These functions are ignored because they are not marked as `pub`: `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `transmit_mode_from_u8`
|
// These functions are ignored because they are not marked as `pub`: `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8`
|
||||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
|
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
|
||||||
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
|
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
|
||||||
|
|
||||||
/// Return the platform-conventional log-file path as a string, or
|
/// Return the platform-conventional log-file path as a string, or
|
||||||
@@ -43,7 +43,12 @@ Future<void> disconnect() => RustLib.instance.api.crateApiDisconnect();
|
|||||||
Future<bool> isConnected() => RustLib.instance.api.crateApiIsConnected();
|
Future<bool> isConnected() => RustLib.instance.api.crateApiIsConnected();
|
||||||
|
|
||||||
/// Handle iOS AVAudioSession route changes (SDD-100).
|
/// Handle iOS AVAudioSession route changes (SDD-100).
|
||||||
void handleRouteChange() => RustLib.instance.api.crateApiHandleRouteChange();
|
void handleRouteChange({required BridgeAudioRoute route}) =>
|
||||||
|
RustLib.instance.api.crateApiHandleRouteChange(route: route);
|
||||||
|
|
||||||
|
/// Handle iOS AVAudioSession media-services reset.
|
||||||
|
void handleMediaServicesReset() =>
|
||||||
|
RustLib.instance.api.crateApiHandleMediaServicesReset();
|
||||||
|
|
||||||
/// Handle iOS AVAudioSession interruption begin (SDD-101).
|
/// Handle iOS AVAudioSession interruption begin (SDD-101).
|
||||||
void handleInterruptionBegan() =>
|
void handleInterruptionBegan() =>
|
||||||
@@ -221,6 +226,330 @@ Stream<BridgeEvent> eventsStream() =>
|
|||||||
Future<BridgeAudioStats> audioStats() =>
|
Future<BridgeAudioStats> audioStats() =>
|
||||||
RustLib.instance.api.crateApiAudioStats();
|
RustLib.instance.api.crateApiAudioStats();
|
||||||
|
|
||||||
|
/// Apply the P1 audio-processing config.
|
||||||
|
Future<void> setAudioProcessingConfig({
|
||||||
|
required BridgeAudioProcessingConfig config,
|
||||||
|
}) async {
|
||||||
|
_lastAppliedAudioConfig = config;
|
||||||
|
return RustLib.instance.api.crateApiSetAudioProcessingConfig(config: config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read P1 audio-processing diagnostics.
|
||||||
|
Future<BridgeAudioProcessingStats> audioProcessingStats() =>
|
||||||
|
RustLib.instance.api.crateApiAudioProcessingStats();
|
||||||
|
|
||||||
|
/// Read the current audio-processing config.
|
||||||
|
///
|
||||||
|
/// Derives the config from [audioProcessingStats] for the route/backend
|
||||||
|
/// fields, and returns the last value applied via [setAudioProcessingConfig]
|
||||||
|
/// for timing/debug fields. Falls back to P1 spec defaults on first call.
|
||||||
|
Future<BridgeAudioProcessingConfig> getAudioProcessingConfig() async {
|
||||||
|
BridgeAudioProcessingStats? stats;
|
||||||
|
try {
|
||||||
|
stats = await audioProcessingStats();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final last = _lastAppliedAudioConfig;
|
||||||
|
return BridgeAudioProcessingConfig(
|
||||||
|
route: stats?.audioRoute ?? last?.route ?? BridgeAudioRoute.unknown,
|
||||||
|
iosMode:
|
||||||
|
stats?.iosVoiceProcessingMode ??
|
||||||
|
last?.iosMode ??
|
||||||
|
BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||||
|
processingBackend:
|
||||||
|
stats?.processingBackend ??
|
||||||
|
last?.processingBackend ??
|
||||||
|
BridgeAudioBackend.platformVoiceProcessing,
|
||||||
|
vadBackend:
|
||||||
|
stats?.vadBackend ?? last?.vadBackend ?? BridgeVadBackend.sileroOnnx,
|
||||||
|
aec: last?.aec ?? BridgeEffectOwner.platform,
|
||||||
|
ns: last?.ns ?? BridgeEffectOwner.platform,
|
||||||
|
agc: last?.agc ?? BridgeEffectOwner.platform,
|
||||||
|
hpfEnabled: last?.hpfEnabled ?? true,
|
||||||
|
limiterEnabled: last?.limiterEnabled ?? true,
|
||||||
|
vadHangoverMs: last?.vadHangoverMs ?? 500,
|
||||||
|
vadPreRollMs: last?.vadPreRollMs ?? 160,
|
||||||
|
vadMinTxMs: last?.vadMinTxMs ?? 200,
|
||||||
|
debugWavDumpEnabled: last?.debugWavDumpEnabled ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last config applied via [setAudioProcessingConfig]. Used by
|
||||||
|
/// [getAudioProcessingConfig] to preserve timing/debug values across calls.
|
||||||
|
BridgeAudioProcessingConfig? _lastAppliedAudioConfig;
|
||||||
|
|
||||||
|
/// Configure the VAD model path.
|
||||||
|
Future<void> setVadModelPath({required String path}) =>
|
||||||
|
RustLib.instance.api.crateApiSetVadModelPath(path: path);
|
||||||
|
|
||||||
|
/// Enable or disable audio debug WAV dumping.
|
||||||
|
Future<void> enableAudioDebugWavDump({required bool enabled}) =>
|
||||||
|
RustLib.instance.api.crateApiEnableAudioDebugWavDump(enabled: enabled);
|
||||||
|
|
||||||
|
/// Select the iOS voice-processing mode.
|
||||||
|
Future<void> setIosVoiceProcessingMode({
|
||||||
|
required BridgeIosVoiceProcessingMode mode,
|
||||||
|
}) => RustLib.instance.api.crateApiSetIosVoiceProcessingMode(mode: mode);
|
||||||
|
|
||||||
|
/// Bridge processing backend.
|
||||||
|
enum BridgeAudioBackend {
|
||||||
|
/// Platform voice processing.
|
||||||
|
platformVoiceProcessing,
|
||||||
|
|
||||||
|
/// Sonora backend.
|
||||||
|
sonora,
|
||||||
|
|
||||||
|
/// WebRTC APM backend.
|
||||||
|
webrtcApm,
|
||||||
|
|
||||||
|
/// No-op backend.
|
||||||
|
noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P1 audio-processing configuration DTO.
|
||||||
|
class BridgeAudioProcessingConfig {
|
||||||
|
/// Route class.
|
||||||
|
final BridgeAudioRoute route;
|
||||||
|
|
||||||
|
/// iOS voice-processing mode.
|
||||||
|
final BridgeIosVoiceProcessingMode iosMode;
|
||||||
|
|
||||||
|
/// Processing backend.
|
||||||
|
final BridgeAudioBackend processingBackend;
|
||||||
|
|
||||||
|
/// VAD backend.
|
||||||
|
final BridgeVadBackend vadBackend;
|
||||||
|
|
||||||
|
/// AEC owner.
|
||||||
|
final BridgeEffectOwner aec;
|
||||||
|
|
||||||
|
/// Noise suppression owner.
|
||||||
|
final BridgeEffectOwner ns;
|
||||||
|
|
||||||
|
/// AGC owner.
|
||||||
|
final BridgeEffectOwner agc;
|
||||||
|
|
||||||
|
/// High-pass filter enabled.
|
||||||
|
final bool hpfEnabled;
|
||||||
|
|
||||||
|
/// Limiter enabled.
|
||||||
|
final bool limiterEnabled;
|
||||||
|
|
||||||
|
/// VAD hangover in ms.
|
||||||
|
final int vadHangoverMs;
|
||||||
|
|
||||||
|
/// VAD pre-roll in ms.
|
||||||
|
final int vadPreRollMs;
|
||||||
|
|
||||||
|
/// Minimum transmit duration in ms.
|
||||||
|
final int vadMinTxMs;
|
||||||
|
|
||||||
|
/// Debug WAV dump enabled.
|
||||||
|
final bool debugWavDumpEnabled;
|
||||||
|
|
||||||
|
const BridgeAudioProcessingConfig({
|
||||||
|
required this.route,
|
||||||
|
required this.iosMode,
|
||||||
|
required this.processingBackend,
|
||||||
|
required this.vadBackend,
|
||||||
|
required this.aec,
|
||||||
|
required this.ns,
|
||||||
|
required this.agc,
|
||||||
|
required this.hpfEnabled,
|
||||||
|
required this.limiterEnabled,
|
||||||
|
required this.vadHangoverMs,
|
||||||
|
required this.vadPreRollMs,
|
||||||
|
required this.vadMinTxMs,
|
||||||
|
required this.debugWavDumpEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
route.hashCode ^
|
||||||
|
iosMode.hashCode ^
|
||||||
|
processingBackend.hashCode ^
|
||||||
|
vadBackend.hashCode ^
|
||||||
|
aec.hashCode ^
|
||||||
|
ns.hashCode ^
|
||||||
|
agc.hashCode ^
|
||||||
|
hpfEnabled.hashCode ^
|
||||||
|
limiterEnabled.hashCode ^
|
||||||
|
vadHangoverMs.hashCode ^
|
||||||
|
vadPreRollMs.hashCode ^
|
||||||
|
vadMinTxMs.hashCode ^
|
||||||
|
debugWavDumpEnabled.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is BridgeAudioProcessingConfig &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
route == other.route &&
|
||||||
|
iosMode == other.iosMode &&
|
||||||
|
processingBackend == other.processingBackend &&
|
||||||
|
vadBackend == other.vadBackend &&
|
||||||
|
aec == other.aec &&
|
||||||
|
ns == other.ns &&
|
||||||
|
agc == other.agc &&
|
||||||
|
hpfEnabled == other.hpfEnabled &&
|
||||||
|
limiterEnabled == other.limiterEnabled &&
|
||||||
|
vadHangoverMs == other.vadHangoverMs &&
|
||||||
|
vadPreRollMs == other.vadPreRollMs &&
|
||||||
|
vadMinTxMs == other.vadMinTxMs &&
|
||||||
|
debugWavDumpEnabled == other.debugWavDumpEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P1 audio-processing stats DTO.
|
||||||
|
class BridgeAudioProcessingStats {
|
||||||
|
/// Input dBFS.
|
||||||
|
final double inputDbfs;
|
||||||
|
|
||||||
|
/// Render dBFS.
|
||||||
|
final double renderDbfs;
|
||||||
|
|
||||||
|
/// Processed capture dBFS.
|
||||||
|
final double processedDbfs;
|
||||||
|
|
||||||
|
/// Latest VAD probability.
|
||||||
|
final double vadProbability;
|
||||||
|
|
||||||
|
/// VAD active.
|
||||||
|
final bool vadActive;
|
||||||
|
|
||||||
|
/// Currently transmitting.
|
||||||
|
final bool transmitting;
|
||||||
|
|
||||||
|
/// VAD backend.
|
||||||
|
final BridgeVadBackend vadBackend;
|
||||||
|
|
||||||
|
/// Fallback VAD active.
|
||||||
|
final bool vadFallbackActive;
|
||||||
|
|
||||||
|
/// Processing backend.
|
||||||
|
final BridgeAudioBackend processingBackend;
|
||||||
|
|
||||||
|
/// iOS voice-processing mode.
|
||||||
|
final BridgeIosVoiceProcessingMode iosVoiceProcessingMode;
|
||||||
|
|
||||||
|
/// Audio route.
|
||||||
|
final BridgeAudioRoute audioRoute;
|
||||||
|
|
||||||
|
/// Actual sample rate.
|
||||||
|
final int actualSampleRateHz;
|
||||||
|
|
||||||
|
/// Actual IO buffer frames.
|
||||||
|
final int actualIoBufferFrames;
|
||||||
|
|
||||||
|
/// Input overruns.
|
||||||
|
final BigInt inputOverruns;
|
||||||
|
|
||||||
|
/// Output underruns.
|
||||||
|
final BigInt outputUnderruns;
|
||||||
|
|
||||||
|
/// Callback xruns.
|
||||||
|
final BigInt callbackXruns;
|
||||||
|
|
||||||
|
/// Clipped samples.
|
||||||
|
final BigInt clippedSamples;
|
||||||
|
|
||||||
|
/// Sonora enabled.
|
||||||
|
final bool sonoraEnabled;
|
||||||
|
|
||||||
|
/// Platform voice processing enabled.
|
||||||
|
final bool platformVoiceProcessingEnabled;
|
||||||
|
|
||||||
|
const BridgeAudioProcessingStats({
|
||||||
|
required this.inputDbfs,
|
||||||
|
required this.renderDbfs,
|
||||||
|
required this.processedDbfs,
|
||||||
|
required this.vadProbability,
|
||||||
|
required this.vadActive,
|
||||||
|
required this.transmitting,
|
||||||
|
required this.vadBackend,
|
||||||
|
required this.vadFallbackActive,
|
||||||
|
required this.processingBackend,
|
||||||
|
required this.iosVoiceProcessingMode,
|
||||||
|
required this.audioRoute,
|
||||||
|
required this.actualSampleRateHz,
|
||||||
|
required this.actualIoBufferFrames,
|
||||||
|
required this.inputOverruns,
|
||||||
|
required this.outputUnderruns,
|
||||||
|
required this.callbackXruns,
|
||||||
|
required this.clippedSamples,
|
||||||
|
required this.sonoraEnabled,
|
||||||
|
required this.platformVoiceProcessingEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
inputDbfs.hashCode ^
|
||||||
|
renderDbfs.hashCode ^
|
||||||
|
processedDbfs.hashCode ^
|
||||||
|
vadProbability.hashCode ^
|
||||||
|
vadActive.hashCode ^
|
||||||
|
transmitting.hashCode ^
|
||||||
|
vadBackend.hashCode ^
|
||||||
|
vadFallbackActive.hashCode ^
|
||||||
|
processingBackend.hashCode ^
|
||||||
|
iosVoiceProcessingMode.hashCode ^
|
||||||
|
audioRoute.hashCode ^
|
||||||
|
actualSampleRateHz.hashCode ^
|
||||||
|
actualIoBufferFrames.hashCode ^
|
||||||
|
inputOverruns.hashCode ^
|
||||||
|
outputUnderruns.hashCode ^
|
||||||
|
callbackXruns.hashCode ^
|
||||||
|
clippedSamples.hashCode ^
|
||||||
|
sonoraEnabled.hashCode ^
|
||||||
|
platformVoiceProcessingEnabled.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is BridgeAudioProcessingStats &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
inputDbfs == other.inputDbfs &&
|
||||||
|
renderDbfs == other.renderDbfs &&
|
||||||
|
processedDbfs == other.processedDbfs &&
|
||||||
|
vadProbability == other.vadProbability &&
|
||||||
|
vadActive == other.vadActive &&
|
||||||
|
transmitting == other.transmitting &&
|
||||||
|
vadBackend == other.vadBackend &&
|
||||||
|
vadFallbackActive == other.vadFallbackActive &&
|
||||||
|
processingBackend == other.processingBackend &&
|
||||||
|
iosVoiceProcessingMode == other.iosVoiceProcessingMode &&
|
||||||
|
audioRoute == other.audioRoute &&
|
||||||
|
actualSampleRateHz == other.actualSampleRateHz &&
|
||||||
|
actualIoBufferFrames == other.actualIoBufferFrames &&
|
||||||
|
inputOverruns == other.inputOverruns &&
|
||||||
|
outputUnderruns == other.outputUnderruns &&
|
||||||
|
callbackXruns == other.callbackXruns &&
|
||||||
|
clippedSamples == other.clippedSamples &&
|
||||||
|
sonoraEnabled == other.sonoraEnabled &&
|
||||||
|
platformVoiceProcessingEnabled ==
|
||||||
|
other.platformVoiceProcessingEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge route class for P1 audio-processing policy.
|
||||||
|
enum BridgeAudioRoute {
|
||||||
|
/// Built-in speakerphone.
|
||||||
|
speaker,
|
||||||
|
|
||||||
|
/// Built-in receiver/earpiece.
|
||||||
|
earpiece,
|
||||||
|
|
||||||
|
/// Wired or USB headset.
|
||||||
|
wiredHeadset,
|
||||||
|
|
||||||
|
/// Bluetooth HFP duplex route.
|
||||||
|
bluetoothHfp,
|
||||||
|
|
||||||
|
/// Bluetooth A2DP output-only route.
|
||||||
|
bluetoothA2Dp,
|
||||||
|
|
||||||
|
/// Unknown route.
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
/// Statistics from the audio engine.
|
/// Statistics from the audio engine.
|
||||||
class BridgeAudioStats {
|
class BridgeAudioStats {
|
||||||
/// Number of Opus frames sent since audio started.
|
/// Number of Opus frames sent since audio started.
|
||||||
@@ -402,6 +731,24 @@ class BridgeClient {
|
|||||||
isServerQuery == other.isServerQuery;
|
isServerQuery == other.isServerQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bridge effect owner for AEC/NS/AGC.
|
||||||
|
enum BridgeEffectOwner {
|
||||||
|
/// Platform-owned effect.
|
||||||
|
platform,
|
||||||
|
|
||||||
|
/// Sonora-owned effect.
|
||||||
|
sonora,
|
||||||
|
|
||||||
|
/// WebRTC APM-owned effect.
|
||||||
|
webrtcApm,
|
||||||
|
|
||||||
|
/// Conservative route-managed setting.
|
||||||
|
conservative,
|
||||||
|
|
||||||
|
/// Disabled.
|
||||||
|
off,
|
||||||
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
sealed class BridgeEvent with _$BridgeEvent {
|
sealed class BridgeEvent with _$BridgeEvent {
|
||||||
const BridgeEvent._();
|
const BridgeEvent._();
|
||||||
@@ -536,6 +883,15 @@ sealed class BridgeEvent with _$BridgeEvent {
|
|||||||
}) = BridgeEvent_PermissionState;
|
}) = BridgeEvent_PermissionState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bridge iOS voice-processing mode.
|
||||||
|
enum BridgeIosVoiceProcessingMode {
|
||||||
|
/// Shipping VPIO path.
|
||||||
|
platformVoiceProcessing,
|
||||||
|
|
||||||
|
/// Experimental Sonora path.
|
||||||
|
sonoraExperimental,
|
||||||
|
}
|
||||||
|
|
||||||
/// Coarse OS-reported network state. Mirrors
|
/// Coarse OS-reported network state. Mirrors
|
||||||
/// [`chanora_core::NetworkState`] across the bridge.
|
/// [`chanora_core::NetworkState`] across the bridge.
|
||||||
enum BridgeNetworkState {
|
enum BridgeNetworkState {
|
||||||
@@ -635,25 +991,69 @@ enum BridgeTransmitMode {
|
|||||||
voiceActivity,
|
voiceActivity,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bridge VAD backend.
|
||||||
|
enum BridgeVadBackend {
|
||||||
|
/// Silero ONNX VAD.
|
||||||
|
sileroOnnx,
|
||||||
|
|
||||||
|
/// TEN VAD.
|
||||||
|
tenVad,
|
||||||
|
|
||||||
|
/// WebRTC fallback VAD.
|
||||||
|
webrtcVad,
|
||||||
|
|
||||||
|
/// Debug energy VAD.
|
||||||
|
energyDebug,
|
||||||
|
|
||||||
|
/// VAD disabled.
|
||||||
|
disabled,
|
||||||
|
}
|
||||||
|
|
||||||
/// Bridge mirror of stable join error/status codes.
|
/// Bridge mirror of stable join error/status codes.
|
||||||
enum BridgeVoiceJoinErrorCode {
|
enum BridgeVoiceJoinErrorCode {
|
||||||
|
/// Duplicate same-target join intent was coalesced.
|
||||||
duplicateSameTargetCoalesced,
|
duplicateSameTargetCoalesced,
|
||||||
|
|
||||||
|
/// A different target was requested while one is already pending.
|
||||||
joinAlreadyPendingDifferentTarget,
|
joinAlreadyPendingDifferentTarget,
|
||||||
|
|
||||||
|
/// Join denied by server policy/permission.
|
||||||
joinDenied,
|
joinDenied,
|
||||||
|
|
||||||
|
/// Join failed due to protocol-level error.
|
||||||
joinProtocolFailure,
|
joinProtocolFailure,
|
||||||
|
|
||||||
|
/// Join failed due to transport/network error.
|
||||||
joinNetworkFailure,
|
joinNetworkFailure,
|
||||||
|
|
||||||
|
/// Join timed out awaiting confirmation.
|
||||||
joinTimeout,
|
joinTimeout,
|
||||||
|
|
||||||
|
/// Pending join was superseded by user leave.
|
||||||
joinSupersededByLeave,
|
joinSupersededByLeave,
|
||||||
|
|
||||||
|
/// Stale join outcome was ignored.
|
||||||
joinStaleOutcomeIgnored,
|
joinStaleOutcomeIgnored,
|
||||||
|
|
||||||
|
/// Authoritative membership reconciled to different channel.
|
||||||
joinReconciledDifferentChannel,
|
joinReconciledDifferentChannel,
|
||||||
|
|
||||||
|
/// Join command was rejected before send acceptance.
|
||||||
joinCommandRejectedBeforeSend,
|
joinCommandRejectedBeforeSend,
|
||||||
|
|
||||||
|
/// Join intent rejected while reducer synchronizing.
|
||||||
joinCannotStartWhileSynchronizing,
|
joinCannotStartWhileSynchronizing,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bridge mirror of core join projection sync state.
|
/// Bridge mirror of core join projection sync state.
|
||||||
enum BridgeVoiceJoinSyncState {
|
enum BridgeVoiceJoinSyncState {
|
||||||
|
/// Reducer is ready to accept channel actions.
|
||||||
ready,
|
ready,
|
||||||
|
|
||||||
|
/// Reducer is waiting on initial snapshot reconciliation.
|
||||||
synchronizingInitialSnapshot,
|
synchronizingInitialSnapshot,
|
||||||
|
|
||||||
|
/// Reducer is waiting on reconnect snapshot reconciliation.
|
||||||
synchronizingReconnect,
|
synchronizingReconnect,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
|||||||
String get codegenVersion => '2.12.0';
|
String get codegenVersion => '2.12.0';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get rustContentHash => 1322894465;
|
int get rustContentHash => -1835973251;
|
||||||
|
|
||||||
static const kDefaultExternalLibraryLoaderConfig =
|
static const kDefaultExternalLibraryLoaderConfig =
|
||||||
ExternalLibraryLoaderConfig(
|
ExternalLibraryLoaderConfig(
|
||||||
@@ -81,6 +81,8 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
|||||||
abstract class RustLibApi extends BaseApi {
|
abstract class RustLibApi extends BaseApi {
|
||||||
Future<PlatformInt64> crateApiAddBookmark({required BridgeBookmark b});
|
Future<PlatformInt64> crateApiAddBookmark({required BridgeBookmark b});
|
||||||
|
|
||||||
|
Future<BridgeAudioProcessingStats> crateApiAudioProcessingStats();
|
||||||
|
|
||||||
Future<BridgeAudioStats> crateApiAudioStats();
|
Future<BridgeAudioStats> crateApiAudioStats();
|
||||||
|
|
||||||
Future<void> crateApiBridgeInit();
|
Future<void> crateApiBridgeInit();
|
||||||
@@ -95,6 +97,8 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
Future<void> crateApiDisconnect();
|
Future<void> crateApiDisconnect();
|
||||||
|
|
||||||
|
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled});
|
||||||
|
|
||||||
Stream<BridgeEvent> crateApiEventsStream();
|
Stream<BridgeEvent> crateApiEventsStream();
|
||||||
|
|
||||||
String crateApiExportDiagnostics();
|
String crateApiExportDiagnostics();
|
||||||
@@ -109,7 +113,9 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
void crateApiHandleInterruptionEnded({required bool shouldResume});
|
void crateApiHandleInterruptionEnded({required bool shouldResume});
|
||||||
|
|
||||||
void crateApiHandleRouteChange();
|
void crateApiHandleMediaServicesReset();
|
||||||
|
|
||||||
|
void crateApiHandleRouteChange({required BridgeAudioRoute route});
|
||||||
|
|
||||||
Future<void> crateApiInitStorage({required String dir});
|
Future<void> crateApiInitStorage({required String dir});
|
||||||
|
|
||||||
@@ -126,10 +132,18 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
Future<(String, String, String)> crateApiPttDescriptor();
|
Future<(String, String, String)> crateApiPttDescriptor();
|
||||||
|
|
||||||
|
Future<void> crateApiSetAudioProcessingConfig({
|
||||||
|
required BridgeAudioProcessingConfig config,
|
||||||
|
});
|
||||||
|
|
||||||
Future<void> crateApiSetHardMute({required bool muted});
|
Future<void> crateApiSetHardMute({required bool muted});
|
||||||
|
|
||||||
Future<void> crateApiSetInputMuted({required bool muted});
|
Future<void> crateApiSetInputMuted({required bool muted});
|
||||||
|
|
||||||
|
Future<void> crateApiSetIosVoiceProcessingMode({
|
||||||
|
required BridgeIosVoiceProcessingMode mode,
|
||||||
|
});
|
||||||
|
|
||||||
void crateApiSetNetworkState({required BridgeNetworkState state});
|
void crateApiSetNetworkState({required BridgeNetworkState state});
|
||||||
|
|
||||||
Future<void> crateApiSetOutputGain({required double gain});
|
Future<void> crateApiSetOutputGain({required double gain});
|
||||||
@@ -147,6 +161,8 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode});
|
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode});
|
||||||
|
|
||||||
|
Future<void> crateApiSetVadModelPath({required String path});
|
||||||
|
|
||||||
Future<BridgeSnapshot> crateApiSnapshot();
|
Future<BridgeSnapshot> crateApiSnapshot();
|
||||||
|
|
||||||
Future<void> crateApiUpdateBookmark({required BridgeBookmark b});
|
Future<void> crateApiUpdateBookmark({required BridgeBookmark b});
|
||||||
@@ -196,7 +212,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
const TaskConstMeta(debugName: "add_bookmark", argNames: ["b"]);
|
const TaskConstMeta(debugName: "add_bookmark", argNames: ["b"]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<BridgeAudioStats> crateApiAudioStats() {
|
Future<BridgeAudioProcessingStats> crateApiAudioProcessingStats() {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
NormalTask(
|
NormalTask(
|
||||||
callFfi: (port_) {
|
callFfi: (port_) {
|
||||||
@@ -208,6 +224,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_bridge_audio_processing_stats,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiAudioProcessingStatsConstMeta,
|
||||||
|
argValues: [],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiAudioProcessingStatsConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "audio_processing_stats", argNames: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BridgeAudioStats> crateApiAudioStats() {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 3,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_bridge_audio_stats,
|
decodeSuccessData: sse_decode_bridge_audio_stats,
|
||||||
decodeErrorData: sse_decode_bridge_error,
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
@@ -231,7 +274,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 3,
|
funcId: 4,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -265,7 +308,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 4,
|
funcId: 5,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -295,7 +338,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 5,
|
funcId: 6,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -322,7 +365,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 6,
|
funcId: 7,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -340,6 +383,37 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiDisconnectConstMeta =>
|
TaskConstMeta get kCrateApiDisconnectConstMeta =>
|
||||||
const TaskConstMeta(debugName: "disconnect", argNames: []);
|
const TaskConstMeta(debugName: "disconnect", argNames: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_bool(enabled, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 8,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_unit,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiEnableAudioDebugWavDumpConstMeta,
|
||||||
|
argValues: [enabled],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiEnableAudioDebugWavDumpConstMeta =>
|
||||||
|
const TaskConstMeta(
|
||||||
|
debugName: "enable_audio_debug_wav_dump",
|
||||||
|
argNames: ["enabled"],
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<BridgeEvent> crateApiEventsStream() {
|
Stream<BridgeEvent> crateApiEventsStream() {
|
||||||
final sink = RustStreamSink<BridgeEvent>();
|
final sink = RustStreamSink<BridgeEvent>();
|
||||||
@@ -352,7 +426,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 7,
|
funcId: 9,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -378,7 +452,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
SyncTask(
|
SyncTask(
|
||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 8)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 10)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_String,
|
decodeSuccessData: sse_decode_String,
|
||||||
@@ -403,7 +477,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 9,
|
funcId: 11,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -430,7 +504,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 10,
|
funcId: 12,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -457,7 +531,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 11,
|
funcId: 13,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -481,7 +555,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
SyncTask(
|
SyncTask(
|
||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -504,7 +578,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bool(shouldResume, serializer);
|
sse_encode_bool(shouldResume, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -524,26 +598,54 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void crateApiHandleRouteChange() {
|
void crateApiHandleMediaServicesReset() {
|
||||||
return handler.executeSync(
|
return handler.executeSync(
|
||||||
SyncTask(
|
SyncTask(
|
||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
decodeErrorData: null,
|
decodeErrorData: null,
|
||||||
),
|
),
|
||||||
constMeta: kCrateApiHandleRouteChangeConstMeta,
|
constMeta: kCrateApiHandleMediaServicesResetConstMeta,
|
||||||
argValues: [],
|
argValues: [],
|
||||||
apiImpl: this,
|
apiImpl: this,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
TaskConstMeta get kCrateApiHandleRouteChangeConstMeta =>
|
TaskConstMeta get kCrateApiHandleMediaServicesResetConstMeta =>
|
||||||
const TaskConstMeta(debugName: "handle_route_change", argNames: []);
|
const TaskConstMeta(
|
||||||
|
debugName: "handle_media_services_reset",
|
||||||
|
argNames: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void crateApiHandleRouteChange({required BridgeAudioRoute route}) {
|
||||||
|
return handler.executeSync(
|
||||||
|
SyncTask(
|
||||||
|
callFfi: () {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_bridge_audio_route(route, serializer);
|
||||||
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_unit,
|
||||||
|
decodeErrorData: null,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiHandleRouteChangeConstMeta,
|
||||||
|
argValues: [route],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiHandleRouteChangeConstMeta => const TaskConstMeta(
|
||||||
|
debugName: "handle_route_change",
|
||||||
|
argNames: ["route"],
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> crateApiInitStorage({required String dir}) {
|
Future<void> crateApiInitStorage({required String dir}) {
|
||||||
@@ -555,7 +657,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 15,
|
funcId: 18,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -582,7 +684,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 16,
|
funcId: 19,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -609,7 +711,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 17,
|
funcId: 20,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -633,7 +735,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
SyncTask(
|
SyncTask(
|
||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_String,
|
decodeSuccessData: sse_decode_String,
|
||||||
@@ -663,7 +765,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 19,
|
funcId: 22,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -692,7 +794,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 20,
|
funcId: 23,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -710,6 +812,42 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiPttDescriptorConstMeta =>
|
TaskConstMeta get kCrateApiPttDescriptorConstMeta =>
|
||||||
const TaskConstMeta(debugName: "ptt_descriptor", argNames: []);
|
const TaskConstMeta(debugName: "ptt_descriptor", argNames: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> crateApiSetAudioProcessingConfig({
|
||||||
|
required BridgeAudioProcessingConfig config,
|
||||||
|
}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_box_autoadd_bridge_audio_processing_config(
|
||||||
|
config,
|
||||||
|
serializer,
|
||||||
|
);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 24,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_unit,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiSetAudioProcessingConfigConstMeta,
|
||||||
|
argValues: [config],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiSetAudioProcessingConfigConstMeta =>
|
||||||
|
const TaskConstMeta(
|
||||||
|
debugName: "set_audio_processing_config",
|
||||||
|
argNames: ["config"],
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> crateApiSetHardMute({required bool muted}) {
|
Future<void> crateApiSetHardMute({required bool muted}) {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
@@ -720,7 +858,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 21,
|
funcId: 25,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -748,7 +886,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 22,
|
funcId: 26,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -766,6 +904,39 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiSetInputMutedConstMeta =>
|
TaskConstMeta get kCrateApiSetInputMutedConstMeta =>
|
||||||
const TaskConstMeta(debugName: "set_input_muted", argNames: ["muted"]);
|
const TaskConstMeta(debugName: "set_input_muted", argNames: ["muted"]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> crateApiSetIosVoiceProcessingMode({
|
||||||
|
required BridgeIosVoiceProcessingMode mode,
|
||||||
|
}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_bridge_ios_voice_processing_mode(mode, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 27,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_unit,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiSetIosVoiceProcessingModeConstMeta,
|
||||||
|
argValues: [mode],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiSetIosVoiceProcessingModeConstMeta =>
|
||||||
|
const TaskConstMeta(
|
||||||
|
debugName: "set_ios_voice_processing_mode",
|
||||||
|
argNames: ["mode"],
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void crateApiSetNetworkState({required BridgeNetworkState state}) {
|
void crateApiSetNetworkState({required BridgeNetworkState state}) {
|
||||||
return handler.executeSync(
|
return handler.executeSync(
|
||||||
@@ -773,7 +944,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bridge_network_state(state, serializer);
|
sse_encode_bridge_network_state(state, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -799,7 +970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 24,
|
funcId: 29,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -827,7 +998,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 25,
|
funcId: 30,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -855,7 +1026,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 26,
|
funcId: 31,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -887,7 +1058,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 27,
|
funcId: 32,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -917,7 +1088,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 28,
|
funcId: 33,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -945,7 +1116,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 29,
|
funcId: 34,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -963,6 +1134,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiSetTransmitModeConstMeta =>
|
TaskConstMeta get kCrateApiSetTransmitModeConstMeta =>
|
||||||
const TaskConstMeta(debugName: "set_transmit_mode", argNames: ["mode"]);
|
const TaskConstMeta(debugName: "set_transmit_mode", argNames: ["mode"]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> crateApiSetVadModelPath({required String path}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_String(path, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 35,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_unit,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiSetVadModelPathConstMeta,
|
||||||
|
argValues: [path],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiSetVadModelPathConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "set_vad_model_path", argNames: ["path"]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<BridgeSnapshot> crateApiSnapshot() {
|
Future<BridgeSnapshot> crateApiSnapshot() {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
@@ -972,7 +1171,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 30,
|
funcId: 36,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1000,7 +1199,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 31,
|
funcId: 37,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1032,7 +1231,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 32,
|
funcId: 38,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1061,7 +1260,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 33,
|
funcId: 39,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1105,6 +1304,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return raw as bool;
|
return raw as bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingConfig
|
||||||
|
dco_decode_box_autoadd_bridge_audio_processing_config(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return dco_decode_bridge_audio_processing_config(raw);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw) {
|
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
@@ -1125,6 +1331,76 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return dco_decode_u_64(raw);
|
return dco_decode_u_64(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return BridgeAudioBackend.values[raw as int];
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
|
||||||
|
dynamic raw,
|
||||||
|
) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
final arr = raw as List<dynamic>;
|
||||||
|
if (arr.length != 13)
|
||||||
|
throw Exception('unexpected arr length: expect 13 but see ${arr.length}');
|
||||||
|
return BridgeAudioProcessingConfig(
|
||||||
|
route: dco_decode_bridge_audio_route(arr[0]),
|
||||||
|
iosMode: dco_decode_bridge_ios_voice_processing_mode(arr[1]),
|
||||||
|
processingBackend: dco_decode_bridge_audio_backend(arr[2]),
|
||||||
|
vadBackend: dco_decode_bridge_vad_backend(arr[3]),
|
||||||
|
aec: dco_decode_bridge_effect_owner(arr[4]),
|
||||||
|
ns: dco_decode_bridge_effect_owner(arr[5]),
|
||||||
|
agc: dco_decode_bridge_effect_owner(arr[6]),
|
||||||
|
hpfEnabled: dco_decode_bool(arr[7]),
|
||||||
|
limiterEnabled: dco_decode_bool(arr[8]),
|
||||||
|
vadHangoverMs: dco_decode_u_32(arr[9]),
|
||||||
|
vadPreRollMs: dco_decode_u_32(arr[10]),
|
||||||
|
vadMinTxMs: dco_decode_u_32(arr[11]),
|
||||||
|
debugWavDumpEnabled: dco_decode_bool(arr[12]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingStats dco_decode_bridge_audio_processing_stats(
|
||||||
|
dynamic raw,
|
||||||
|
) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
final arr = raw as List<dynamic>;
|
||||||
|
if (arr.length != 19)
|
||||||
|
throw Exception('unexpected arr length: expect 19 but see ${arr.length}');
|
||||||
|
return BridgeAudioProcessingStats(
|
||||||
|
inputDbfs: dco_decode_f_32(arr[0]),
|
||||||
|
renderDbfs: dco_decode_f_32(arr[1]),
|
||||||
|
processedDbfs: dco_decode_f_32(arr[2]),
|
||||||
|
vadProbability: dco_decode_f_32(arr[3]),
|
||||||
|
vadActive: dco_decode_bool(arr[4]),
|
||||||
|
transmitting: dco_decode_bool(arr[5]),
|
||||||
|
vadBackend: dco_decode_bridge_vad_backend(arr[6]),
|
||||||
|
vadFallbackActive: dco_decode_bool(arr[7]),
|
||||||
|
processingBackend: dco_decode_bridge_audio_backend(arr[8]),
|
||||||
|
iosVoiceProcessingMode: dco_decode_bridge_ios_voice_processing_mode(
|
||||||
|
arr[9],
|
||||||
|
),
|
||||||
|
audioRoute: dco_decode_bridge_audio_route(arr[10]),
|
||||||
|
actualSampleRateHz: dco_decode_u_32(arr[11]),
|
||||||
|
actualIoBufferFrames: dco_decode_u_32(arr[12]),
|
||||||
|
inputOverruns: dco_decode_u_64(arr[13]),
|
||||||
|
outputUnderruns: dco_decode_u_64(arr[14]),
|
||||||
|
callbackXruns: dco_decode_u_64(arr[15]),
|
||||||
|
clippedSamples: dco_decode_u_64(arr[16]),
|
||||||
|
sonoraEnabled: dco_decode_bool(arr[17]),
|
||||||
|
platformVoiceProcessingEnabled: dco_decode_bool(arr[18]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioRoute dco_decode_bridge_audio_route(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return BridgeAudioRoute.values[raw as int];
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) {
|
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
@@ -1185,6 +1461,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return BridgeEffectOwner.values[raw as int];
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeError dco_decode_bridge_error(dynamic raw) {
|
BridgeError dco_decode_bridge_error(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
@@ -1273,6 +1555,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeIosVoiceProcessingMode dco_decode_bridge_ios_voice_processing_mode(
|
||||||
|
dynamic raw,
|
||||||
|
) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return BridgeIosVoiceProcessingMode.values[raw as int];
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw) {
|
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
@@ -1308,6 +1598,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return BridgeTransmitMode.values[raw as int];
|
return BridgeTransmitMode.values[raw as int];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeVadBackend dco_decode_bridge_vad_backend(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return BridgeVadBackend.values[raw as int];
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(
|
||||||
dynamic raw,
|
dynamic raw,
|
||||||
@@ -1463,6 +1759,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return deserializer.buffer.getUint8() != 0;
|
return deserializer.buffer.getUint8() != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingConfig
|
||||||
|
sse_decode_box_autoadd_bridge_audio_processing_config(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
return (sse_decode_bridge_audio_processing_config(deserializer));
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeBookmark sse_decode_box_autoadd_bridge_bookmark(
|
BridgeBookmark sse_decode_box_autoadd_bridge_bookmark(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -1485,6 +1790,105 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return (sse_decode_u_64(deserializer));
|
return (sse_decode_u_64(deserializer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioBackend sse_decode_bridge_audio_backend(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
var inner = sse_decode_i_32(deserializer);
|
||||||
|
return BridgeAudioBackend.values[inner];
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
var var_route = sse_decode_bridge_audio_route(deserializer);
|
||||||
|
var var_iosMode = sse_decode_bridge_ios_voice_processing_mode(deserializer);
|
||||||
|
var var_processingBackend = sse_decode_bridge_audio_backend(deserializer);
|
||||||
|
var var_vadBackend = sse_decode_bridge_vad_backend(deserializer);
|
||||||
|
var var_aec = sse_decode_bridge_effect_owner(deserializer);
|
||||||
|
var var_ns = sse_decode_bridge_effect_owner(deserializer);
|
||||||
|
var var_agc = sse_decode_bridge_effect_owner(deserializer);
|
||||||
|
var var_hpfEnabled = sse_decode_bool(deserializer);
|
||||||
|
var var_limiterEnabled = sse_decode_bool(deserializer);
|
||||||
|
var var_vadHangoverMs = sse_decode_u_32(deserializer);
|
||||||
|
var var_vadPreRollMs = sse_decode_u_32(deserializer);
|
||||||
|
var var_vadMinTxMs = sse_decode_u_32(deserializer);
|
||||||
|
var var_debugWavDumpEnabled = sse_decode_bool(deserializer);
|
||||||
|
return BridgeAudioProcessingConfig(
|
||||||
|
route: var_route,
|
||||||
|
iosMode: var_iosMode,
|
||||||
|
processingBackend: var_processingBackend,
|
||||||
|
vadBackend: var_vadBackend,
|
||||||
|
aec: var_aec,
|
||||||
|
ns: var_ns,
|
||||||
|
agc: var_agc,
|
||||||
|
hpfEnabled: var_hpfEnabled,
|
||||||
|
limiterEnabled: var_limiterEnabled,
|
||||||
|
vadHangoverMs: var_vadHangoverMs,
|
||||||
|
vadPreRollMs: var_vadPreRollMs,
|
||||||
|
vadMinTxMs: var_vadMinTxMs,
|
||||||
|
debugWavDumpEnabled: var_debugWavDumpEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingStats sse_decode_bridge_audio_processing_stats(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
var var_inputDbfs = sse_decode_f_32(deserializer);
|
||||||
|
var var_renderDbfs = sse_decode_f_32(deserializer);
|
||||||
|
var var_processedDbfs = sse_decode_f_32(deserializer);
|
||||||
|
var var_vadProbability = sse_decode_f_32(deserializer);
|
||||||
|
var var_vadActive = sse_decode_bool(deserializer);
|
||||||
|
var var_transmitting = sse_decode_bool(deserializer);
|
||||||
|
var var_vadBackend = sse_decode_bridge_vad_backend(deserializer);
|
||||||
|
var var_vadFallbackActive = sse_decode_bool(deserializer);
|
||||||
|
var var_processingBackend = sse_decode_bridge_audio_backend(deserializer);
|
||||||
|
var var_iosVoiceProcessingMode =
|
||||||
|
sse_decode_bridge_ios_voice_processing_mode(deserializer);
|
||||||
|
var var_audioRoute = sse_decode_bridge_audio_route(deserializer);
|
||||||
|
var var_actualSampleRateHz = sse_decode_u_32(deserializer);
|
||||||
|
var var_actualIoBufferFrames = sse_decode_u_32(deserializer);
|
||||||
|
var var_inputOverruns = sse_decode_u_64(deserializer);
|
||||||
|
var var_outputUnderruns = sse_decode_u_64(deserializer);
|
||||||
|
var var_callbackXruns = sse_decode_u_64(deserializer);
|
||||||
|
var var_clippedSamples = sse_decode_u_64(deserializer);
|
||||||
|
var var_sonoraEnabled = sse_decode_bool(deserializer);
|
||||||
|
var var_platformVoiceProcessingEnabled = sse_decode_bool(deserializer);
|
||||||
|
return BridgeAudioProcessingStats(
|
||||||
|
inputDbfs: var_inputDbfs,
|
||||||
|
renderDbfs: var_renderDbfs,
|
||||||
|
processedDbfs: var_processedDbfs,
|
||||||
|
vadProbability: var_vadProbability,
|
||||||
|
vadActive: var_vadActive,
|
||||||
|
transmitting: var_transmitting,
|
||||||
|
vadBackend: var_vadBackend,
|
||||||
|
vadFallbackActive: var_vadFallbackActive,
|
||||||
|
processingBackend: var_processingBackend,
|
||||||
|
iosVoiceProcessingMode: var_iosVoiceProcessingMode,
|
||||||
|
audioRoute: var_audioRoute,
|
||||||
|
actualSampleRateHz: var_actualSampleRateHz,
|
||||||
|
actualIoBufferFrames: var_actualIoBufferFrames,
|
||||||
|
inputOverruns: var_inputOverruns,
|
||||||
|
outputUnderruns: var_outputUnderruns,
|
||||||
|
callbackXruns: var_callbackXruns,
|
||||||
|
clippedSamples: var_clippedSamples,
|
||||||
|
sonoraEnabled: var_sonoraEnabled,
|
||||||
|
platformVoiceProcessingEnabled: var_platformVoiceProcessingEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioRoute sse_decode_bridge_audio_route(SseDeserializer deserializer) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
var inner = sse_decode_i_32(deserializer);
|
||||||
|
return BridgeAudioRoute.values[inner];
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer) {
|
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer) {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
@@ -1553,6 +1957,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeEffectOwner sse_decode_bridge_effect_owner(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
var inner = sse_decode_i_32(deserializer);
|
||||||
|
return BridgeEffectOwner.values[inner];
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeError sse_decode_bridge_error(SseDeserializer deserializer) {
|
BridgeError sse_decode_bridge_error(SseDeserializer deserializer) {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
@@ -1678,6 +2091,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeIosVoiceProcessingMode sse_decode_bridge_ios_voice_processing_mode(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
var inner = sse_decode_i_32(deserializer);
|
||||||
|
return BridgeIosVoiceProcessingMode.values[inner];
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeNetworkState sse_decode_bridge_network_state(
|
BridgeNetworkState sse_decode_bridge_network_state(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -1726,6 +2148,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return BridgeTransmitMode.values[inner];
|
return BridgeTransmitMode.values[inner];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeVadBackend sse_decode_bridge_vad_backend(SseDeserializer deserializer) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
var inner = sse_decode_i_32(deserializer);
|
||||||
|
return BridgeVadBackend.values[inner];
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -1929,6 +2358,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
serializer.buffer.putUint8(self ? 1 : 0);
|
serializer.buffer.putUint8(self ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_bridge_audio_processing_config(
|
||||||
|
BridgeAudioProcessingConfig self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_bridge_audio_processing_config(self, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_bridge_bookmark(
|
void sse_encode_box_autoadd_bridge_bookmark(
|
||||||
BridgeBookmark self,
|
BridgeBookmark self,
|
||||||
@@ -1953,6 +2391,75 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
sse_encode_u_64(self, serializer);
|
sse_encode_u_64(self, serializer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_backend(
|
||||||
|
BridgeAudioBackend self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_i_32(self.index, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_processing_config(
|
||||||
|
BridgeAudioProcessingConfig self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_bridge_audio_route(self.route, serializer);
|
||||||
|
sse_encode_bridge_ios_voice_processing_mode(self.iosMode, serializer);
|
||||||
|
sse_encode_bridge_audio_backend(self.processingBackend, serializer);
|
||||||
|
sse_encode_bridge_vad_backend(self.vadBackend, serializer);
|
||||||
|
sse_encode_bridge_effect_owner(self.aec, serializer);
|
||||||
|
sse_encode_bridge_effect_owner(self.ns, serializer);
|
||||||
|
sse_encode_bridge_effect_owner(self.agc, serializer);
|
||||||
|
sse_encode_bool(self.hpfEnabled, serializer);
|
||||||
|
sse_encode_bool(self.limiterEnabled, serializer);
|
||||||
|
sse_encode_u_32(self.vadHangoverMs, serializer);
|
||||||
|
sse_encode_u_32(self.vadPreRollMs, serializer);
|
||||||
|
sse_encode_u_32(self.vadMinTxMs, serializer);
|
||||||
|
sse_encode_bool(self.debugWavDumpEnabled, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_processing_stats(
|
||||||
|
BridgeAudioProcessingStats self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_f_32(self.inputDbfs, serializer);
|
||||||
|
sse_encode_f_32(self.renderDbfs, serializer);
|
||||||
|
sse_encode_f_32(self.processedDbfs, serializer);
|
||||||
|
sse_encode_f_32(self.vadProbability, serializer);
|
||||||
|
sse_encode_bool(self.vadActive, serializer);
|
||||||
|
sse_encode_bool(self.transmitting, serializer);
|
||||||
|
sse_encode_bridge_vad_backend(self.vadBackend, serializer);
|
||||||
|
sse_encode_bool(self.vadFallbackActive, serializer);
|
||||||
|
sse_encode_bridge_audio_backend(self.processingBackend, serializer);
|
||||||
|
sse_encode_bridge_ios_voice_processing_mode(
|
||||||
|
self.iosVoiceProcessingMode,
|
||||||
|
serializer,
|
||||||
|
);
|
||||||
|
sse_encode_bridge_audio_route(self.audioRoute, serializer);
|
||||||
|
sse_encode_u_32(self.actualSampleRateHz, serializer);
|
||||||
|
sse_encode_u_32(self.actualIoBufferFrames, serializer);
|
||||||
|
sse_encode_u_64(self.inputOverruns, serializer);
|
||||||
|
sse_encode_u_64(self.outputUnderruns, serializer);
|
||||||
|
sse_encode_u_64(self.callbackXruns, serializer);
|
||||||
|
sse_encode_u_64(self.clippedSamples, serializer);
|
||||||
|
sse_encode_bool(self.sonoraEnabled, serializer);
|
||||||
|
sse_encode_bool(self.platformVoiceProcessingEnabled, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_route(
|
||||||
|
BridgeAudioRoute self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_i_32(self.index, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_audio_stats(
|
void sse_encode_bridge_audio_stats(
|
||||||
BridgeAudioStats self,
|
BridgeAudioStats self,
|
||||||
@@ -1984,6 +2491,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
sse_encode_u_64(self.parent, serializer);
|
sse_encode_u_64(self.parent, serializer);
|
||||||
sse_encode_String(self.name, serializer);
|
sse_encode_String(self.name, serializer);
|
||||||
sse_encode_i_64(self.order, serializer);
|
sse_encode_i_64(self.order, serializer);
|
||||||
|
sse_encode_bool(self.hasPassword, serializer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
@@ -1992,9 +2500,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
sse_encode_u_64(self.id, serializer);
|
sse_encode_u_64(self.id, serializer);
|
||||||
sse_encode_u_64(self.channel, serializer);
|
sse_encode_u_64(self.channel, serializer);
|
||||||
sse_encode_String(self.name, serializer);
|
sse_encode_String(self.name, serializer);
|
||||||
|
sse_encode_bool(self.inputMuted, serializer);
|
||||||
|
sse_encode_bool(self.outputMuted, serializer);
|
||||||
|
sse_encode_bool(self.isSpeaking, serializer);
|
||||||
sse_encode_bool(self.isServerQuery, serializer);
|
sse_encode_bool(self.isServerQuery, serializer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_effect_owner(
|
||||||
|
BridgeEffectOwner self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_i_32(self.index, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer) {
|
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer) {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
@@ -2106,6 +2626,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_ios_voice_processing_mode(
|
||||||
|
BridgeIosVoiceProcessingMode self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_i_32(self.index, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_network_state(
|
void sse_encode_bridge_network_state(
|
||||||
BridgeNetworkState self,
|
BridgeNetworkState self,
|
||||||
@@ -2148,6 +2677,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
sse_encode_i_32(self.index, serializer);
|
sse_encode_i_32(self.index, serializer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_vad_backend(
|
||||||
|
BridgeVadBackend self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_i_32(self.index, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_voice_join_error_code(
|
void sse_encode_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode self,
|
BridgeVoiceJoinErrorCode self,
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
bool dco_decode_bool(dynamic raw);
|
bool dco_decode_bool(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingConfig
|
||||||
|
dco_decode_box_autoadd_bridge_audio_processing_config(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw);
|
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw);
|
||||||
|
|
||||||
@@ -44,6 +48,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
|
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
|
||||||
|
dynamic raw,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingStats dco_decode_bridge_audio_processing_stats(
|
||||||
|
dynamic raw,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioRoute dco_decode_bridge_audio_route(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw);
|
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw);
|
||||||
|
|
||||||
@@ -56,12 +76,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeClient dco_decode_bridge_client(dynamic raw);
|
BridgeClient dco_decode_bridge_client(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeError dco_decode_bridge_error(dynamic raw);
|
BridgeError dco_decode_bridge_error(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeEvent dco_decode_bridge_event(dynamic raw);
|
BridgeEvent dco_decode_bridge_event(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeIosVoiceProcessingMode dco_decode_bridge_ios_voice_processing_mode(
|
||||||
|
dynamic raw,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
||||||
|
|
||||||
@@ -74,6 +102,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw);
|
BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeVadBackend dco_decode_bridge_vad_backend(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(dynamic raw);
|
BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(dynamic raw);
|
||||||
|
|
||||||
@@ -143,6 +174,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
bool sse_decode_bool(SseDeserializer deserializer);
|
bool sse_decode_bool(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingConfig
|
||||||
|
sse_decode_box_autoadd_bridge_audio_processing_config(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeBookmark sse_decode_box_autoadd_bridge_bookmark(
|
BridgeBookmark sse_decode_box_autoadd_bridge_bookmark(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -156,6 +193,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
|
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioBackend sse_decode_bridge_audio_backend(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingStats sse_decode_bridge_audio_processing_stats(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioRoute sse_decode_bridge_audio_route(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer);
|
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer);
|
||||||
|
|
||||||
@@ -168,12 +223,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
|
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeEffectOwner sse_decode_bridge_effect_owner(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeError sse_decode_bridge_error(SseDeserializer deserializer);
|
BridgeError sse_decode_bridge_error(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer);
|
BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeIosVoiceProcessingMode sse_decode_bridge_ios_voice_processing_mode(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeNetworkState sse_decode_bridge_network_state(
|
BridgeNetworkState sse_decode_bridge_network_state(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -192,6 +257,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeVadBackend sse_decode_bridge_vad_backend(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -283,6 +351,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_bool(bool self, SseSerializer serializer);
|
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_bridge_audio_processing_config(
|
||||||
|
BridgeAudioProcessingConfig self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_bridge_bookmark(
|
void sse_encode_box_autoadd_bridge_bookmark(
|
||||||
BridgeBookmark self,
|
BridgeBookmark self,
|
||||||
@@ -298,6 +372,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
|
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_backend(
|
||||||
|
BridgeAudioBackend self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_processing_config(
|
||||||
|
BridgeAudioProcessingConfig self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_processing_stats(
|
||||||
|
BridgeAudioProcessingStats self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_route(
|
||||||
|
BridgeAudioRoute self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_audio_stats(
|
void sse_encode_bridge_audio_stats(
|
||||||
BridgeAudioStats self,
|
BridgeAudioStats self,
|
||||||
@@ -316,12 +414,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
|
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_effect_owner(
|
||||||
|
BridgeEffectOwner self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer);
|
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer);
|
void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_ios_voice_processing_mode(
|
||||||
|
BridgeIosVoiceProcessingMode self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_network_state(
|
void sse_encode_bridge_network_state(
|
||||||
BridgeNetworkState self,
|
BridgeNetworkState self,
|
||||||
@@ -346,6 +456,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_vad_backend(
|
||||||
|
BridgeVadBackend self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_voice_join_error_code(
|
void sse_encode_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode self,
|
BridgeVoiceJoinErrorCode self,
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
bool dco_decode_bool(dynamic raw);
|
bool dco_decode_bool(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingConfig
|
||||||
|
dco_decode_box_autoadd_bridge_audio_processing_config(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw);
|
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw);
|
||||||
|
|
||||||
@@ -46,6 +50,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
|
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
|
||||||
|
dynamic raw,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingStats dco_decode_bridge_audio_processing_stats(
|
||||||
|
dynamic raw,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioRoute dco_decode_bridge_audio_route(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw);
|
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw);
|
||||||
|
|
||||||
@@ -58,12 +78,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeClient dco_decode_bridge_client(dynamic raw);
|
BridgeClient dco_decode_bridge_client(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeError dco_decode_bridge_error(dynamic raw);
|
BridgeError dco_decode_bridge_error(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeEvent dco_decode_bridge_event(dynamic raw);
|
BridgeEvent dco_decode_bridge_event(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeIosVoiceProcessingMode dco_decode_bridge_ios_voice_processing_mode(
|
||||||
|
dynamic raw,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
||||||
|
|
||||||
@@ -76,6 +104,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw);
|
BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeVadBackend dco_decode_bridge_vad_backend(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(dynamic raw);
|
BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(dynamic raw);
|
||||||
|
|
||||||
@@ -145,6 +176,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
bool sse_decode_bool(SseDeserializer deserializer);
|
bool sse_decode_bool(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingConfig
|
||||||
|
sse_decode_box_autoadd_bridge_audio_processing_config(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeBookmark sse_decode_box_autoadd_bridge_bookmark(
|
BridgeBookmark sse_decode_box_autoadd_bridge_bookmark(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -158,6 +195,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
|
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioBackend sse_decode_bridge_audio_backend(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioProcessingStats sse_decode_bridge_audio_processing_stats(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeAudioRoute sse_decode_bridge_audio_route(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer);
|
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer);
|
||||||
|
|
||||||
@@ -170,12 +225,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
|
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeEffectOwner sse_decode_bridge_effect_owner(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeError sse_decode_bridge_error(SseDeserializer deserializer);
|
BridgeError sse_decode_bridge_error(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer);
|
BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeIosVoiceProcessingMode sse_decode_bridge_ios_voice_processing_mode(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeNetworkState sse_decode_bridge_network_state(
|
BridgeNetworkState sse_decode_bridge_network_state(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -194,6 +259,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgeVadBackend sse_decode_bridge_vad_backend(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -285,6 +353,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_bool(bool self, SseSerializer serializer);
|
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_bridge_audio_processing_config(
|
||||||
|
BridgeAudioProcessingConfig self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_bridge_bookmark(
|
void sse_encode_box_autoadd_bridge_bookmark(
|
||||||
BridgeBookmark self,
|
BridgeBookmark self,
|
||||||
@@ -300,6 +374,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
|
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_backend(
|
||||||
|
BridgeAudioBackend self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_processing_config(
|
||||||
|
BridgeAudioProcessingConfig self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_processing_stats(
|
||||||
|
BridgeAudioProcessingStats self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_audio_route(
|
||||||
|
BridgeAudioRoute self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_audio_stats(
|
void sse_encode_bridge_audio_stats(
|
||||||
BridgeAudioStats self,
|
BridgeAudioStats self,
|
||||||
@@ -318,12 +416,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
|
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_effect_owner(
|
||||||
|
BridgeEffectOwner self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer);
|
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer);
|
void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_ios_voice_processing_mode(
|
||||||
|
BridgeIosVoiceProcessingMode self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_network_state(
|
void sse_encode_bridge_network_state(
|
||||||
BridgeNetworkState self,
|
BridgeNetworkState self,
|
||||||
@@ -348,6 +458,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_vad_backend(
|
||||||
|
BridgeVadBackend self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_voice_join_error_code(
|
void sse_encode_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode self,
|
BridgeVoiceJoinErrorCode self,
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// P1 debug stats overlay widget.
|
||||||
|
//
|
||||||
|
// Shows a compact, auto-refreshing panel with the key audio processing
|
||||||
|
// metrics from [BridgeAudioProcessingStats]. Intended for internal
|
||||||
|
// debug builds only — wrap with a kDebugMode guard at the call site.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// if (kDebugMode) const AudioDebugStatsPanel(),
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../src/rust/api.dart';
|
||||||
|
|
||||||
|
/// Compact debug panel that polls [audioProcessingStats] every 500 ms
|
||||||
|
/// and renders the key metrics in a monospace overlay.
|
||||||
|
///
|
||||||
|
/// Designed to be placed in a [Stack] over the main UI during
|
||||||
|
/// development. It is transparent to hit-testing so it does not
|
||||||
|
/// interfere with taps.
|
||||||
|
class AudioDebugStatsPanel extends StatefulWidget {
|
||||||
|
const AudioDebugStatsPanel({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AudioDebugStatsPanel> createState() => _AudioDebugStatsPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AudioDebugStatsPanelState extends State<AudioDebugStatsPanel> {
|
||||||
|
BridgeAudioProcessingStats? _stats;
|
||||||
|
Timer? _timer;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_poll();
|
||||||
|
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _poll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _poll() async {
|
||||||
|
try {
|
||||||
|
final stats = await audioProcessingStats();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_stats = stats;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _error = e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: _buildPanel(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPanel() {
|
||||||
|
if (_error != null) {
|
||||||
|
return _PanelBox(
|
||||||
|
child: Text(
|
||||||
|
'audio stats error:\n$_error',
|
||||||
|
style: _monoStyle(Colors.red),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final s = _stats;
|
||||||
|
if (s == null) {
|
||||||
|
return _PanelBox(
|
||||||
|
child: Text('audio stats: loading…', style: _monoStyle(Colors.grey)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final vadColor = s.vadActive ? Colors.greenAccent : Colors.grey;
|
||||||
|
final txColor = s.transmitting ? Colors.redAccent : Colors.grey;
|
||||||
|
final xruns = s.callbackXruns + s.inputOverruns + s.outputUnderruns;
|
||||||
|
|
||||||
|
return _PanelBox(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_row('route', _routeLabel(s.audioRoute), Colors.white),
|
||||||
|
_row('backend', _backendLabel(s.processingBackend), Colors.white),
|
||||||
|
_row(
|
||||||
|
'vpio',
|
||||||
|
s.platformVoiceProcessingEnabled ? 'on' : 'off',
|
||||||
|
Colors.white,
|
||||||
|
),
|
||||||
|
_row('sonora', s.sonoraEnabled ? 'on' : 'off', Colors.white),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_row(
|
||||||
|
'mic in',
|
||||||
|
'${s.inputDbfs.toStringAsFixed(1)} dBFS',
|
||||||
|
Colors.white,
|
||||||
|
),
|
||||||
|
_row(
|
||||||
|
'mic out',
|
||||||
|
'${s.processedDbfs.toStringAsFixed(1)} dBFS',
|
||||||
|
Colors.white,
|
||||||
|
),
|
||||||
|
_row(
|
||||||
|
'render',
|
||||||
|
'${s.renderDbfs.toStringAsFixed(1)} dBFS',
|
||||||
|
Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_row(
|
||||||
|
'vad',
|
||||||
|
'${(s.vadProbability * 100).toStringAsFixed(0)}% '
|
||||||
|
'${s.vadActive ? "OPEN" : "closed"}',
|
||||||
|
vadColor,
|
||||||
|
),
|
||||||
|
_row('vad backend', _vadBackendLabel(s.vadBackend), Colors.white),
|
||||||
|
if (s.vadFallbackActive)
|
||||||
|
_row('vad fallback', 'ACTIVE', Colors.orange),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_row('tx', s.transmitting ? 'TRANSMITTING' : 'idle', txColor),
|
||||||
|
_row('sr', '${s.actualSampleRateHz} Hz', Colors.white),
|
||||||
|
_row('buf', '${s.actualIoBufferFrames} frames', Colors.white),
|
||||||
|
if (xruns > BigInt.zero)
|
||||||
|
_row('xruns', xruns.toString(), Colors.orange),
|
||||||
|
if (s.clippedSamples > BigInt.zero)
|
||||||
|
_row('clipped', s.clippedSamples.toString(), Colors.orange),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _row(String label, String value, Color valueColor) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('$label: ', style: _monoStyle(Colors.grey.shade400)),
|
||||||
|
Text(value, style: _monoStyle(valueColor)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TextStyle _monoStyle(Color color) => TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 10,
|
||||||
|
color: color,
|
||||||
|
height: 1.4,
|
||||||
|
);
|
||||||
|
|
||||||
|
String _routeLabel(BridgeAudioRoute route) => switch (route) {
|
||||||
|
BridgeAudioRoute.speaker => 'speaker',
|
||||||
|
BridgeAudioRoute.earpiece => 'earpiece',
|
||||||
|
BridgeAudioRoute.wiredHeadset => 'wired',
|
||||||
|
BridgeAudioRoute.bluetoothHfp => 'bt-hfp',
|
||||||
|
BridgeAudioRoute.bluetoothA2Dp => 'bt-a2dp',
|
||||||
|
BridgeAudioRoute.unknown => 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
String _backendLabel(BridgeAudioBackend backend) => switch (backend) {
|
||||||
|
BridgeAudioBackend.platformVoiceProcessing => 'vpio',
|
||||||
|
BridgeAudioBackend.sonora => 'sonora',
|
||||||
|
BridgeAudioBackend.noop => 'noop',
|
||||||
|
BridgeAudioBackend.webrtcApm => 'webrtc-apm',
|
||||||
|
};
|
||||||
|
|
||||||
|
String _vadBackendLabel(BridgeVadBackend backend) => switch (backend) {
|
||||||
|
BridgeVadBackend.webrtcVad => 'webrtc',
|
||||||
|
BridgeVadBackend.sileroOnnx => 'silero',
|
||||||
|
BridgeVadBackend.tenVad => 'ten',
|
||||||
|
BridgeVadBackend.energyDebug => 'energy',
|
||||||
|
BridgeVadBackend.disabled => 'off',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Semi-transparent dark box for the debug panel.
|
||||||
|
class _PanelBox extends StatelessWidget {
|
||||||
|
const _PanelBox({required this.child});
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.72),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/// SRS-209 listen-only banner for Android RECORD_AUDIO permission.
|
/// SRS-209 listen-only banner for mobile microphone permission.
|
||||||
///
|
///
|
||||||
/// Trace:
|
/// Trace:
|
||||||
/// - SDD-106 §2 (denial UX — non-blocking affordance "Enable
|
/// - SDD-106 §2 (denial UX — non-blocking affordance "Enable
|
||||||
@@ -6,17 +6,19 @@
|
|||||||
/// - SRS-209 (path to grant; listen-only fallback).
|
/// - SRS-209 (path to grant; listen-only fallback).
|
||||||
///
|
///
|
||||||
/// Behaviour:
|
/// Behaviour:
|
||||||
/// * Watches [AndroidPermissionsService.recordAudioState].
|
/// * Watches the injected microphone permission state listenable.
|
||||||
/// * On `denied`: renders a non-modal banner with a "Grant" action.
|
/// * On `denied`: renders a non-modal banner with a "Grant" action.
|
||||||
/// * On `permanentlyDenied`: action text becomes "Open Settings" and
|
/// * On `permanentlyDenied`: action text becomes "Open Settings" and
|
||||||
/// invokes [AndroidPermissionsService.openAppSettings].
|
/// invokes [AndroidPermissionsService.openAppSettings].
|
||||||
/// * On `granted` / `unknown`: builds an empty [SizedBox.shrink].
|
/// * On `granted` / `unknown`: builds an empty [SizedBox.shrink].
|
||||||
/// * On non-Android hosts the service stays at `granted`, so this
|
/// * On platforms whose service stays at `granted`, this widget is
|
||||||
/// widget is effectively invisible without any extra branching.
|
/// effectively invisible without any extra branching.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../l10n/generated/app_localizations.dart';
|
||||||
import '../services/android_permissions_service.dart';
|
import '../services/android_permissions_service.dart';
|
||||||
|
|
||||||
/// Listen-only banner widget. Drop this above the `VoiceBar` in the
|
/// Listen-only banner widget. Drop this above the `VoiceBar` in the
|
||||||
@@ -24,43 +26,69 @@ import '../services/android_permissions_service.dart';
|
|||||||
///
|
///
|
||||||
/// Trace: SDD-106 §2, §3; SRS-209.
|
/// Trace: SDD-106 §2, §3; SRS-209.
|
||||||
class PermissionStateBanner extends StatelessWidget {
|
class PermissionStateBanner extends StatelessWidget {
|
||||||
const PermissionStateBanner({super.key, required this.service});
|
PermissionStateBanner({super.key, required AndroidPermissionsService service})
|
||||||
|
: recordAudioState = service.recordAudioState,
|
||||||
|
ensureRecordAudio = service.ensureRecordAudio,
|
||||||
|
openAppSettings = service.openAppSettings;
|
||||||
|
|
||||||
/// Permissions service whose [AndroidPermissionsService.recordAudioState]
|
const PermissionStateBanner.fromCallbacks({
|
||||||
/// drives the banner.
|
super.key,
|
||||||
final AndroidPermissionsService service;
|
required this.recordAudioState,
|
||||||
|
required this.ensureRecordAudio,
|
||||||
|
required this.openAppSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ValueListenable<AndroidRecordAudioPermissionState> recordAudioState;
|
||||||
|
final Future<AndroidRecordAudioPermissionState> Function() ensureRecordAudio;
|
||||||
|
final Future<void> Function() openAppSettings;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<AndroidRecordAudioPermissionState>(
|
return ValueListenableBuilder<AndroidRecordAudioPermissionState>(
|
||||||
valueListenable: service.recordAudioState,
|
valueListenable: recordAudioState,
|
||||||
builder: (ctx, state, _) {
|
builder: (ctx, state, _) {
|
||||||
switch (state) {
|
final l10n = AppL10n.of(ctx);
|
||||||
case AndroidRecordAudioPermissionState.granted:
|
final action = _actionFor(state, l10n);
|
||||||
case AndroidRecordAudioPermissionState.unknown:
|
if (action == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
case AndroidRecordAudioPermissionState.denied:
|
|
||||||
return _BannerBody(
|
|
||||||
// TODO(localization): route through AppL10n once an arb
|
|
||||||
// entry exists. SRS-209 requires the message; the
|
|
||||||
// English literal is a placeholder.
|
|
||||||
message:
|
|
||||||
'Microphone permission required for voice transmission.',
|
|
||||||
actionLabel: 'Grant',
|
|
||||||
onPressed: () => service.ensureRecordAudio(),
|
|
||||||
);
|
|
||||||
case AndroidRecordAudioPermissionState.permanentlyDenied:
|
|
||||||
return _BannerBody(
|
|
||||||
// TODO(localization): see above.
|
|
||||||
message:
|
|
||||||
'Microphone permission required for voice transmission.',
|
|
||||||
actionLabel: 'Open Settings',
|
|
||||||
onPressed: () => service.openAppSettings(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return _BannerBody(
|
||||||
|
message: l10n.microphonePermissionRequiredForVoice,
|
||||||
|
actionLabel: action.label,
|
||||||
|
onPressed: action.onPressed,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_BannerAction? _actionFor(
|
||||||
|
AndroidRecordAudioPermissionState state,
|
||||||
|
AppL10n l10n,
|
||||||
|
) {
|
||||||
|
switch (state) {
|
||||||
|
case AndroidRecordAudioPermissionState.granted:
|
||||||
|
case AndroidRecordAudioPermissionState.unknown:
|
||||||
|
return null;
|
||||||
|
case AndroidRecordAudioPermissionState.denied:
|
||||||
|
return _BannerAction(
|
||||||
|
label: l10n.permissionGrantAction,
|
||||||
|
onPressed: () => ensureRecordAudio(),
|
||||||
|
);
|
||||||
|
case AndroidRecordAudioPermissionState.permanentlyDenied:
|
||||||
|
return _BannerAction(
|
||||||
|
label: l10n.networkPermissionOpenSettings,
|
||||||
|
onPressed: () => openAppSettings(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BannerAction {
|
||||||
|
const _BannerAction({required this.label, required this.onPressed});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onPressed;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BannerBody extends StatelessWidget {
|
class _BannerBody extends StatelessWidget {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show TargetPlatform, defaultTargetPlatform;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../l10n/generated/app_localizations.dart';
|
||||||
|
|
||||||
|
/// PTT capability badge (gen2 v0.9.3 / SDD-091).
|
||||||
|
///
|
||||||
|
/// Renders the active PTT level + backend in the Voice Bar so the
|
||||||
|
/// user understands which input path is in effect. When the
|
||||||
|
/// resolved capability is `L0Focused` an info icon appears that
|
||||||
|
/// opens a per-platform explanation sheet describing why Global
|
||||||
|
/// PTT is not active and what the user can do to engage it.
|
||||||
|
class PttCapabilityBadge extends StatelessWidget {
|
||||||
|
/// Construct a badge.
|
||||||
|
const PttCapabilityBadge({
|
||||||
|
super.key,
|
||||||
|
required this.level,
|
||||||
|
required this.backendId,
|
||||||
|
required this.boundInputClass,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Resolved capability level as the bridge emits it
|
||||||
|
/// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
|
||||||
|
/// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
|
||||||
|
final String level;
|
||||||
|
|
||||||
|
/// Stable backend identifier (`focused`, `windows-raw-input`, …).
|
||||||
|
final String backendId;
|
||||||
|
|
||||||
|
/// Privacy-safe input class (`keyboard`, `mouse-side-button`,
|
||||||
|
/// or empty when no binding is set).
|
||||||
|
final String boundInputClass;
|
||||||
|
|
||||||
|
bool get _isFocused => level == 'L0Focused';
|
||||||
|
|
||||||
|
String _explainBodyForPlatform(AppL10n l10n) {
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
return l10n.pttCapabilityExplainGoGlobalWindows;
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
return l10n.pttCapabilityExplainGoGlobalMacos;
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
return l10n.pttCapabilityExplainGoGlobalLinux;
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
return l10n.pttCapabilityExplainGoGlobalIos;
|
||||||
|
default:
|
||||||
|
return l10n.pttCapabilityExplainGoGlobalGeneric;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openExplanationSheet(BuildContext context) {
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
final theme = Theme.of(sheetContext);
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.pttCapabilityExplainTitle,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
l10n.pttCapabilityExplainFocusedHeading,
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l10n.pttCapabilityExplainFocusedBody,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_explainBodyForPlatform(l10n),
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.centerEnd,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||||
|
child: Text(l10n.closeAction),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
|
||||||
|
final tooltipMessage = boundInputClass.isEmpty
|
||||||
|
? badgeLabel
|
||||||
|
: '$badgeLabel\n($boundInputClass)';
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: Tooltip(
|
||||||
|
message: tooltipMessage,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_isFocused ? Icons.crop_free : Icons.public,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
badgeLabel,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isFocused)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.info_outline, size: 16),
|
||||||
|
tooltip: l10n.pttCapabilityExplainTitle,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () => _openExplanationSheet(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,24 +3,16 @@
|
|||||||
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
||||||
// core's transmit-mode selector + release-tail timer.
|
// core's transmit-mode selector + release-tail timer.
|
||||||
|
|
||||||
import 'dart:io' show Platform;
|
import 'dart:async' show unawaited;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:haptic_kit/haptic_kit.dart';
|
||||||
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
import '../main.dart' show PttCapabilityBadge;
|
import 'ptt_capability_badge.dart';
|
||||||
|
import 'voice_platform.dart';
|
||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
/// True when the host is a mobile platform without a hardware
|
|
||||||
/// keyboard the user would bind a PTT key on. iOS / iPadOS /
|
|
||||||
/// Android fall here. macOS / Linux / Windows / Web fall on the
|
|
||||||
/// hardware-key path.
|
|
||||||
bool get _isTouchOnlyPttHost {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isIOS || Platform.isAndroid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
|
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
|
||||||
/// toggle, level meter, and a leave-channel affordance.
|
/// toggle, level meter, and a leave-channel affordance.
|
||||||
class VoiceBar extends StatelessWidget {
|
class VoiceBar extends StatelessWidget {
|
||||||
@@ -113,7 +105,7 @@ class VoiceBar extends StatelessWidget {
|
|||||||
case rust.BridgeTransmitMode.continuous:
|
case rust.BridgeTransmitMode.continuous:
|
||||||
return l10n.voiceModeContinuous;
|
return l10n.voiceModeContinuous;
|
||||||
case rust.BridgeTransmitMode.voiceActivity:
|
case rust.BridgeTransmitMode.voiceActivity:
|
||||||
return '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})';
|
return l10n.voiceModeVoiceActivity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +229,7 @@ class VoiceBar extends StatelessWidget {
|
|||||||
// pinned to the bottom of a narrow-layout screen. The
|
// pinned to the bottom of a narrow-layout screen. The
|
||||||
// release-tail value is folded into the small print
|
// release-tail value is folded into the small print
|
||||||
// under the button rather than shown here.
|
// under the button rather than shown here.
|
||||||
if (isPtt && !_isTouchOnlyPttHost)
|
if (isPtt && !isTouchOnlyPttHost)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 22, top: 2),
|
padding: const EdgeInsets.only(left: 22, top: 2),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -288,7 +280,7 @@ class VoiceBar extends StatelessWidget {
|
|||||||
// the bottom of a narrow-layout screen. The release-
|
// the bottom of a narrow-layout screen. The release-
|
||||||
// tail value sits above the button so the user sees
|
// tail value sits above the button so the user sees
|
||||||
// how long their voice continues after they let go.
|
// how long their voice continues after they let go.
|
||||||
if (isPtt && _isTouchOnlyPttHost) ...[
|
if (isPtt && isTouchOnlyPttHost) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -380,10 +372,24 @@ class _PttHoldButton extends StatefulWidget {
|
|||||||
class _PttHoldButtonState extends State<_PttHoldButton> {
|
class _PttHoldButtonState extends State<_PttHoldButton> {
|
||||||
bool _pressed = false;
|
bool _pressed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
unawaited(Haptics.prepare().catchError((_) => false));
|
||||||
|
}
|
||||||
|
|
||||||
void _setHeld(bool held) {
|
void _setHeld(bool held) {
|
||||||
if (_pressed == held) return;
|
if (_pressed == held) return;
|
||||||
setState(() => _pressed = held);
|
setState(() => _pressed = held);
|
||||||
widget.onHeldChanged(held);
|
widget.onHeldChanged(held);
|
||||||
|
_playPressHaptic(held);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _playPressHaptic(bool held) {
|
||||||
|
final haptic = held
|
||||||
|
? Haptics.impact(HapticImpactStyle.medium)
|
||||||
|
: Haptics.selection();
|
||||||
|
unawaited(haptic.catchError((_) {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -392,7 +398,12 @@ class _PttHoldButtonState extends State<_PttHoldButton> {
|
|||||||
final activeNow = _pressed || widget.active;
|
final activeNow = _pressed || widget.active;
|
||||||
final l10n = AppL10n.of(context);
|
final l10n = AppL10n.of(context);
|
||||||
|
|
||||||
return GestureDetector(
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
liveRegion: true,
|
||||||
|
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
|
||||||
|
hint: l10n.pttHoldToTalkSemanticsHint,
|
||||||
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTapDown: (_) => _setHeld(true),
|
onTapDown: (_) => _setHeld(true),
|
||||||
onTapUp: (_) => _setHeld(false),
|
onTapUp: (_) => _setHeld(false),
|
||||||
@@ -400,6 +411,7 @@ class _PttHoldButtonState extends State<_PttHoldButton> {
|
|||||||
onPanDown: (_) => _setHeld(true),
|
onPanDown: (_) => _setHeld(true),
|
||||||
onPanEnd: (_) => _setHeld(false),
|
onPanEnd: (_) => _setHeld(false),
|
||||||
onPanCancel: () => _setHeld(false),
|
onPanCancel: () => _setHeld(false),
|
||||||
|
child: ExcludeSemantics(
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 80),
|
duration: const Duration(milliseconds: 80),
|
||||||
height: 64,
|
height: 64,
|
||||||
@@ -443,6 +455,8 @@ class _PttHoldButtonState extends State<_PttHoldButton> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,16 +15,17 @@
|
|||||||
// wrong UI: that only lists AirPlay output destinations, not the
|
// wrong UI: that only lists AirPlay output destinations, not the
|
||||||
// speaker/receiver/Bluetooth choices we want.
|
// speaker/receiver/Bluetooth choices we want.
|
||||||
|
|
||||||
import 'dart:async' show StreamSubscription;
|
import 'dart:async' show StreamSubscription, Timer, unawaited;
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:audio_session/audio_session.dart';
|
import 'package:audio_session/audio_session.dart';
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:haptic_kit/haptic_kit.dart';
|
||||||
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
import '../main.dart' show PttCapabilityBadge;
|
import 'ptt_capability_badge.dart';
|
||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
/// Two-line status chip that summarises the current voice state.
|
/// Two-line status chip that summarises the current voice state.
|
||||||
@@ -65,13 +66,18 @@ class VoiceStatusChip extends StatelessWidget {
|
|||||||
final l10n = AppL10n.of(context);
|
final l10n = AppL10n.of(context);
|
||||||
|
|
||||||
final stats = audioStats;
|
final stats = audioStats;
|
||||||
final micOn = stats?.pttActive ?? false;
|
// For PTT: pttActive = button held.
|
||||||
|
// For Continuous: pttActive = always true (always transmitting).
|
||||||
|
// For VoiceActivity: pttActive = VAD gate open (speech detected).
|
||||||
|
final micOn = switch (transmitMode) {
|
||||||
|
rust.BridgeTransmitMode.continuous => true,
|
||||||
|
_ => stats?.pttActive ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
final modeLabel = switch (transmitMode) {
|
final modeLabel = switch (transmitMode) {
|
||||||
rust.BridgeTransmitMode.ptt => l10n.voiceModePtt,
|
rust.BridgeTransmitMode.ptt => l10n.voiceModePtt,
|
||||||
rust.BridgeTransmitMode.continuous => l10n.voiceModeContinuous,
|
rust.BridgeTransmitMode.continuous => l10n.voiceModeContinuous,
|
||||||
rust.BridgeTransmitMode.voiceActivity =>
|
rust.BridgeTransmitMode.voiceActivity => l10n.voiceModeVoiceActivity,
|
||||||
'${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
String line1;
|
String line1;
|
||||||
@@ -92,11 +98,16 @@ class VoiceStatusChip extends StatelessWidget {
|
|||||||
final micText = micOn ? l10n.voiceMicOn : l10n.voiceMicOff;
|
final micText = micOn ? l10n.voiceMicOn : l10n.voiceMicOff;
|
||||||
final line2 = tailText == null ? micText : '$tailText \u00b7 $micText';
|
final line2 = tailText == null ? micText : '$tailText \u00b7 $micText';
|
||||||
|
|
||||||
return Material(
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: '${l10n.voiceSheetTitle}: $line1, $line2',
|
||||||
|
hint: l10n.voiceSettingsTitle,
|
||||||
|
child: Material(
|
||||||
type: MaterialType.transparency,
|
type: MaterialType.transparency,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: ExcludeSemantics(
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -153,6 +164,8 @@ class VoiceStatusChip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,10 +192,24 @@ class VoicePttButton extends StatefulWidget {
|
|||||||
class _VoicePttButtonState extends State<VoicePttButton> {
|
class _VoicePttButtonState extends State<VoicePttButton> {
|
||||||
bool _pressed = false;
|
bool _pressed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
unawaited(Haptics.prepare().catchError((_) => false));
|
||||||
|
}
|
||||||
|
|
||||||
void _setHeld(bool held) {
|
void _setHeld(bool held) {
|
||||||
if (_pressed == held) return;
|
if (_pressed == held) return;
|
||||||
setState(() => _pressed = held);
|
setState(() => _pressed = held);
|
||||||
widget.onHeldChanged(held);
|
widget.onHeldChanged(held);
|
||||||
|
_playPressHaptic(held);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _playPressHaptic(bool held) {
|
||||||
|
final haptic = held
|
||||||
|
? Haptics.impact(HapticImpactStyle.medium)
|
||||||
|
: Haptics.selection();
|
||||||
|
unawaited(haptic.catchError((_) {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -191,14 +218,17 @@ class _VoicePttButtonState extends State<VoicePttButton> {
|
|||||||
final l10n = AppL10n.of(context);
|
final l10n = AppL10n.of(context);
|
||||||
final activeNow = _pressed || widget.active;
|
final activeNow = _pressed || widget.active;
|
||||||
|
|
||||||
return GestureDetector(
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
liveRegion: true,
|
||||||
|
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
|
||||||
|
hint: l10n.pttHoldToTalkSemanticsHint,
|
||||||
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTapDown: (_) => _setHeld(true),
|
onTapDown: (_) => _setHeld(true),
|
||||||
onTapUp: (_) => _setHeld(false),
|
onTapUp: (_) => _setHeld(false),
|
||||||
onTapCancel: () => _setHeld(false),
|
onTapCancel: () => _setHeld(false),
|
||||||
onPanDown: (_) => _setHeld(true),
|
child: ExcludeSemantics(
|
||||||
onPanEnd: (_) => _setHeld(false),
|
|
||||||
onPanCancel: () => _setHeld(false),
|
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 80),
|
duration: const Duration(milliseconds: 80),
|
||||||
height: 56,
|
height: 56,
|
||||||
@@ -244,6 +274,8 @@ class _VoicePttButtonState extends State<VoicePttButton> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,11 +284,10 @@ class _VoicePttButtonState extends State<VoicePttButton> {
|
|||||||
/// surface on mobile. Tiles:
|
/// surface on mobile. Tiles:
|
||||||
/// 1. Audio output route picker (iOS native AVRoutePickerView /
|
/// 1. Audio output route picker (iOS native AVRoutePickerView /
|
||||||
/// Android Material 3 list). Mobile only.
|
/// Android Material 3 list). Mobile only.
|
||||||
/// 2. Mode radio buttons (PTT / Continuous; VoiceActivity disabled
|
/// 2. Mode radio buttons (PTT / Continuous / Voice Activity).
|
||||||
/// coming-soon).
|
|
||||||
/// 3. Release-tail slider (PTT-only).
|
/// 3. Release-tail slider (PTT-only).
|
||||||
/// 4. Mic level meter.
|
/// 4. Mic level meter + frame counts.
|
||||||
/// 5. TX / RX frame counts.
|
/// 5. Audio processing (NS · AEC · AGC · HPF · VAD) — all modes.
|
||||||
/// 6. PTT capability badge.
|
/// 6. PTT capability badge.
|
||||||
///
|
///
|
||||||
/// Mode + release-tail are inlined directly here instead of being
|
/// Mode + release-tail are inlined directly here instead of being
|
||||||
@@ -266,7 +297,6 @@ class _VoicePttButtonState extends State<VoicePttButton> {
|
|||||||
/// drag the slider freely.
|
/// drag the slider freely.
|
||||||
Future<void> showVoiceDetailsSheet(
|
Future<void> showVoiceDetailsSheet(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required rust.BridgeAudioStats? audioStats,
|
|
||||||
required rust.BridgeTransmitMode transmitMode,
|
required rust.BridgeTransmitMode transmitMode,
|
||||||
required int releaseTailMs,
|
required int releaseTailMs,
|
||||||
required String pttBoundKeyLabel,
|
required String pttBoundKeyLabel,
|
||||||
@@ -274,16 +304,26 @@ Future<void> showVoiceDetailsSheet(
|
|||||||
required String pttBackendId,
|
required String pttBackendId,
|
||||||
required String pttBoundInputClass,
|
required String pttBoundInputClass,
|
||||||
required bool isTouchOnly,
|
required bool isTouchOnly,
|
||||||
|
required rust.BridgeAudioProcessingConfig initialAudioConfig,
|
||||||
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
|
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
|
||||||
required ValueChanged<int> onReleaseTailChanged,
|
required ValueChanged<int> onReleaseTailChanged,
|
||||||
|
required ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged,
|
||||||
}) async {
|
}) async {
|
||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
return _VoiceSheetBody(
|
return DraggableScrollableSheet(
|
||||||
audioStats: audioStats,
|
initialChildSize: 0.6,
|
||||||
|
minChildSize: 0.3,
|
||||||
|
maxChildSize: 0.95,
|
||||||
|
expand: false,
|
||||||
|
snap: true,
|
||||||
|
snapSizes: const [0.3, 0.6, 0.95],
|
||||||
|
builder: (ctx, scrollController) => _VoiceSheetBody(
|
||||||
|
scrollController: scrollController,
|
||||||
initialMode: transmitMode,
|
initialMode: transmitMode,
|
||||||
initialReleaseTailMs: releaseTailMs,
|
initialReleaseTailMs: releaseTailMs,
|
||||||
pttBoundKeyLabel: pttBoundKeyLabel,
|
pttBoundKeyLabel: pttBoundKeyLabel,
|
||||||
@@ -291,8 +331,11 @@ Future<void> showVoiceDetailsSheet(
|
|||||||
pttBackendId: pttBackendId,
|
pttBackendId: pttBackendId,
|
||||||
pttBoundInputClass: pttBoundInputClass,
|
pttBoundInputClass: pttBoundInputClass,
|
||||||
isTouchOnly: isTouchOnly,
|
isTouchOnly: isTouchOnly,
|
||||||
|
initialAudioConfig: initialAudioConfig,
|
||||||
onModeChanged: onModeChanged,
|
onModeChanged: onModeChanged,
|
||||||
onReleaseTailChanged: onReleaseTailChanged,
|
onReleaseTailChanged: onReleaseTailChanged,
|
||||||
|
onAudioConfigChanged: onAudioConfigChanged,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -300,7 +343,7 @@ Future<void> showVoiceDetailsSheet(
|
|||||||
|
|
||||||
class _VoiceSheetBody extends StatefulWidget {
|
class _VoiceSheetBody extends StatefulWidget {
|
||||||
const _VoiceSheetBody({
|
const _VoiceSheetBody({
|
||||||
required this.audioStats,
|
required this.scrollController,
|
||||||
required this.initialMode,
|
required this.initialMode,
|
||||||
required this.initialReleaseTailMs,
|
required this.initialReleaseTailMs,
|
||||||
required this.pttBoundKeyLabel,
|
required this.pttBoundKeyLabel,
|
||||||
@@ -308,11 +351,13 @@ class _VoiceSheetBody extends StatefulWidget {
|
|||||||
required this.pttBackendId,
|
required this.pttBackendId,
|
||||||
required this.pttBoundInputClass,
|
required this.pttBoundInputClass,
|
||||||
required this.isTouchOnly,
|
required this.isTouchOnly,
|
||||||
|
required this.initialAudioConfig,
|
||||||
required this.onModeChanged,
|
required this.onModeChanged,
|
||||||
required this.onReleaseTailChanged,
|
required this.onReleaseTailChanged,
|
||||||
|
required this.onAudioConfigChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final rust.BridgeAudioStats? audioStats;
|
final ScrollController scrollController;
|
||||||
final rust.BridgeTransmitMode initialMode;
|
final rust.BridgeTransmitMode initialMode;
|
||||||
final int initialReleaseTailMs;
|
final int initialReleaseTailMs;
|
||||||
final String pttBoundKeyLabel;
|
final String pttBoundKeyLabel;
|
||||||
@@ -320,8 +365,10 @@ class _VoiceSheetBody extends StatefulWidget {
|
|||||||
final String pttBackendId;
|
final String pttBackendId;
|
||||||
final String pttBoundInputClass;
|
final String pttBoundInputClass;
|
||||||
final bool isTouchOnly;
|
final bool isTouchOnly;
|
||||||
|
final rust.BridgeAudioProcessingConfig initialAudioConfig;
|
||||||
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
|
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
|
||||||
final ValueChanged<int> onReleaseTailChanged;
|
final ValueChanged<int> onReleaseTailChanged;
|
||||||
|
final ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_VoiceSheetBody> createState() => _VoiceSheetBodyState();
|
State<_VoiceSheetBody> createState() => _VoiceSheetBodyState();
|
||||||
@@ -331,12 +378,116 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
late rust.BridgeTransmitMode _mode = widget.initialMode;
|
late rust.BridgeTransmitMode _mode = widget.initialMode;
|
||||||
late int _tail = widget.initialReleaseTailMs;
|
late int _tail = widget.initialReleaseTailMs;
|
||||||
|
|
||||||
void _setMode(rust.BridgeTransmitMode m) {
|
// Live stats — polled by this widget's own timer so TX/RX update
|
||||||
if (m == rust.BridgeTransmitMode.voiceActivity) {
|
// in real time while the sheet is open, independent of the parent.
|
||||||
// Coming-soon \u2014 disabled in UI; defensive guard.
|
rust.BridgeAudioStats? _stats;
|
||||||
return;
|
Timer? _statsTimer;
|
||||||
|
// Previous snapshot for computing per-second rates.
|
||||||
|
int _prevSent = 0;
|
||||||
|
int _prevReceived = 0;
|
||||||
|
int _txRate = 0; // frames/s
|
||||||
|
int _rxRate = 0; // frames/s
|
||||||
|
int _rateTickCount = 0;
|
||||||
|
|
||||||
|
// Audio processing state.
|
||||||
|
late bool _nsEnabled;
|
||||||
|
late bool _aecEnabled;
|
||||||
|
late bool _agcEnabled;
|
||||||
|
late bool _hpfEnabled;
|
||||||
|
late rust.BridgeVadBackend _vadBackend;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final c = widget.initialAudioConfig;
|
||||||
|
_nsEnabled = c.ns != rust.BridgeEffectOwner.off;
|
||||||
|
_aecEnabled = c.aec != rust.BridgeEffectOwner.off;
|
||||||
|
_agcEnabled = c.agc != rust.BridgeEffectOwner.off;
|
||||||
|
_hpfEnabled = c.hpfEnabled;
|
||||||
|
_vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
|
||||||
|
? rust.BridgeVadBackend.webrtcVad
|
||||||
|
: c.vadBackend;
|
||||||
|
|
||||||
|
// Poll audio stats at 80 ms so TX/RX counters and the level meter
|
||||||
|
// update in real time while the sheet is open, independent of the parent.
|
||||||
|
_statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async {
|
||||||
|
try {
|
||||||
|
final s = await rust.audioStats();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_stats = s;
|
||||||
|
_rateTickCount++;
|
||||||
|
// Compute rates every ~960 ms (12 × 80 ms).
|
||||||
|
if (_rateTickCount >= 12) {
|
||||||
|
_txRate = s.framesSent - _prevSent;
|
||||||
|
_rxRate = s.framesReceived - _prevReceived;
|
||||||
|
_prevSent = s.framesSent;
|
||||||
|
_prevReceived = s.framesReceived;
|
||||||
|
_rateTickCount = 0;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_statsTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||||
|
final c = widget.initialAudioConfig;
|
||||||
|
final isSonora =
|
||||||
|
c.iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
|
||||||
|
// VPIO owns enabled effects on the default path. Sonora owns them only in
|
||||||
|
// the experimental raw path.
|
||||||
|
final aecOwner = isSonora
|
||||||
|
? (_aecEnabled
|
||||||
|
? rust.BridgeEffectOwner.sonora
|
||||||
|
: rust.BridgeEffectOwner.off)
|
||||||
|
: rust.BridgeEffectOwner.platform;
|
||||||
|
final nsOwner = isSonora
|
||||||
|
? (_nsEnabled
|
||||||
|
? rust.BridgeEffectOwner.sonora
|
||||||
|
: rust.BridgeEffectOwner.off)
|
||||||
|
: (_nsEnabled
|
||||||
|
? rust.BridgeEffectOwner.platform
|
||||||
|
: rust.BridgeEffectOwner.off);
|
||||||
|
final agcOwner = isSonora
|
||||||
|
? (_agcEnabled
|
||||||
|
? rust.BridgeEffectOwner.sonora
|
||||||
|
: rust.BridgeEffectOwner.off)
|
||||||
|
: (_agcEnabled
|
||||||
|
? rust.BridgeEffectOwner.platform
|
||||||
|
: rust.BridgeEffectOwner.off);
|
||||||
|
return rust.BridgeAudioProcessingConfig(
|
||||||
|
route: c.route,
|
||||||
|
iosMode: c.iosMode,
|
||||||
|
processingBackend: c.processingBackend,
|
||||||
|
vadBackend: _vadBackend == rust.BridgeVadBackend.disabled
|
||||||
|
? rust.BridgeVadBackend.webrtcVad
|
||||||
|
: _vadBackend,
|
||||||
|
aec: aecOwner,
|
||||||
|
ns: nsOwner,
|
||||||
|
agc: agcOwner,
|
||||||
|
hpfEnabled: _hpfEnabled,
|
||||||
|
limiterEnabled: c.limiterEnabled,
|
||||||
|
vadHangoverMs: c.vadHangoverMs,
|
||||||
|
vadPreRollMs: c.vadPreRollMs,
|
||||||
|
vadMinTxMs: c.vadMinTxMs,
|
||||||
|
debugWavDumpEnabled: c.debugWavDumpEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifyAudioConfig() {
|
||||||
|
widget.onAudioConfigChanged(_buildConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setMode(rust.BridgeTransmitMode m) {
|
||||||
|
if (m == _mode) return;
|
||||||
setState(() => _mode = m);
|
setState(() => _mode = m);
|
||||||
|
unawaited(Haptics.selection().catchError((_) {}));
|
||||||
widget.onModeChanged(m);
|
widget.onModeChanged(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,8 +501,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l10n = AppL10n.of(context);
|
final l10n = AppL10n.of(context);
|
||||||
final stats = widget.audioStats;
|
final stats = _stats;
|
||||||
final levelActive = stats?.pttActive ?? false;
|
// Level meter active = transmitting (any mode).
|
||||||
|
final levelActive = switch (_mode) {
|
||||||
|
rust.BridgeTransmitMode.continuous => true,
|
||||||
|
_ => stats?.pttActive ?? false,
|
||||||
|
};
|
||||||
final isPtt = _mode == rust.BridgeTransmitMode.ptt;
|
final isPtt = _mode == rust.BridgeTransmitMode.ptt;
|
||||||
|
|
||||||
// Route picker is mobile-only. iOS uses AVAudioSession below;
|
// Route picker is mobile-only. iOS uses AVAudioSession below;
|
||||||
@@ -360,6 +515,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
controller: widget.scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -397,11 +553,10 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
onTap: () => _setMode(rust.BridgeTransmitMode.continuous),
|
onTap: () => _setMode(rust.BridgeTransmitMode.continuous),
|
||||||
),
|
),
|
||||||
_ModeRow(
|
_ModeRow(
|
||||||
label:
|
label: l10n.voiceModeVoiceActivity,
|
||||||
'${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})',
|
|
||||||
icon: Icons.graphic_eq,
|
icon: Icons.graphic_eq,
|
||||||
selected: false,
|
selected: _mode == rust.BridgeTransmitMode.voiceActivity,
|
||||||
onTap: null,
|
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 3) Release-tail slider (PTT only).
|
// 3) Release-tail slider (PTT only).
|
||||||
@@ -454,20 +609,96 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// 4) Level meter.
|
// 4) Level meter + live TX/RX stats.
|
||||||
_LevelMeter(active: levelActive),
|
_LevelMeter(active: levelActive),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
if (stats != null)
|
_StatsRow(
|
||||||
Text(
|
txRate: _txRate,
|
||||||
l10n.audioStatsLine(
|
rxRate: _rxRate,
|
||||||
stats.framesSent,
|
totalSent: stats?.framesSent ?? 0,
|
||||||
stats.framesReceived,
|
totalReceived: stats?.framesReceived ?? 0,
|
||||||
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
|
transmitting: levelActive,
|
||||||
),
|
|
||||||
style: theme.textTheme.bodySmall,
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// 5) PTT capability badge. On iOS this must remain
|
// 5) Audio processing — always visible, all modes.
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Audio processing',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_AudioToggleRow(
|
||||||
|
label: 'Noise suppression',
|
||||||
|
subtitle: 'Wiener filter',
|
||||||
|
value: _nsEnabled,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _nsEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_AudioToggleRow(
|
||||||
|
label: 'Echo cancellation',
|
||||||
|
subtitle:
|
||||||
|
widget.initialAudioConfig.iosMode ==
|
||||||
|
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
|
||||||
|
? 'Always on · managed by platform VPIO'
|
||||||
|
: 'AEC3 adaptive filter · 80 ms tail',
|
||||||
|
value:
|
||||||
|
widget.initialAudioConfig.iosMode ==
|
||||||
|
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
|
||||||
|
? true // always on in VPIO
|
||||||
|
: _aecEnabled,
|
||||||
|
// AEC is always on in VPIO — disable the toggle.
|
||||||
|
onChanged:
|
||||||
|
widget.initialAudioConfig.iosMode ==
|
||||||
|
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
|
||||||
|
? null
|
||||||
|
: (v) {
|
||||||
|
setState(() => _aecEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_AudioToggleRow(
|
||||||
|
label: 'Auto gain control',
|
||||||
|
subtitle: 'AGC2 · −18 dBFS target',
|
||||||
|
value: _agcEnabled,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _agcEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_AudioToggleRow(
|
||||||
|
label: 'High-pass filter',
|
||||||
|
subtitle: '80 Hz · DC removal',
|
||||||
|
value: _hpfEnabled,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _hpfEnabled = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// VAD backend selector.
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Voice activity detection',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
_VadBackendRow(
|
||||||
|
value: _vadBackend,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _vadBackend = v);
|
||||||
|
_notifyAudioConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// 6) PTT capability badge. On iOS this must remain
|
||||||
// visible even though the resolved level is always
|
// visible even though the resolved level is always
|
||||||
// `L0Focused`, because the P0 acceptance flow requires
|
// `L0Focused`, because the P0 acceptance flow requires
|
||||||
// honest capability advertising with an explanation of
|
// honest capability advertising with an explanation of
|
||||||
@@ -487,6 +718,151 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Live TX/RX stats row ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _StatsRow extends StatelessWidget {
|
||||||
|
const _StatsRow({
|
||||||
|
required this.txRate,
|
||||||
|
required this.rxRate,
|
||||||
|
required this.totalSent,
|
||||||
|
required this.totalReceived,
|
||||||
|
required this.transmitting,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int txRate;
|
||||||
|
final int rxRate;
|
||||||
|
final int totalSent;
|
||||||
|
final int totalReceived;
|
||||||
|
final bool transmitting;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final txColor = transmitting
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
// TX
|
||||||
|
Icon(Icons.upload, size: 12, color: txColor),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(
|
||||||
|
'TX $txRate/s · $totalSent',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: txColor),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// RX
|
||||||
|
Icon(
|
||||||
|
Icons.download,
|
||||||
|
size: 12,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(
|
||||||
|
'RX $rxRate/s · $totalReceived',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audio processing helper widgets ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// Compact toggle row for a single DSP stage.
|
||||||
|
class _AudioToggleRow extends StatelessWidget {
|
||||||
|
const _AudioToggleRow({
|
||||||
|
required this.label,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String subtitle;
|
||||||
|
final bool value;
|
||||||
|
final ValueChanged<bool>? onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final disabled = onChanged == null;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: disabled
|
||||||
|
? theme.colorScheme.onSurfaceVariant.withAlpha(120)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withAlpha(
|
||||||
|
disabled ? 80 : 160,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(value: value, onChanged: onChanged),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Segmented VAD backend selector.
|
||||||
|
class _VadBackendRow extends StatelessWidget {
|
||||||
|
const _VadBackendRow({required this.value, required this.onChanged});
|
||||||
|
|
||||||
|
final rust.BridgeVadBackend value;
|
||||||
|
final ValueChanged<rust.BridgeVadBackend> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SegmentedButton<rust.BridgeVadBackend>(
|
||||||
|
style: SegmentedButton.styleFrom(
|
||||||
|
textStyle: theme.textTheme.labelSmall,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: rust.BridgeVadBackend.webrtcVad,
|
||||||
|
label: Text('WebRTC'),
|
||||||
|
icon: Icon(Icons.speed, size: 14),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: rust.BridgeVadBackend.sileroOnnx,
|
||||||
|
label: Text('Silero'),
|
||||||
|
icon: Icon(Icons.psychology, size: 14),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: rust.BridgeVadBackend.tenVad,
|
||||||
|
label: Text('TEN'),
|
||||||
|
icon: Icon(Icons.graphic_eq, size: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {value},
|
||||||
|
onSelectionChanged: (s) => onChanged(s.first),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mode row ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _ModeRow extends StatelessWidget {
|
class _ModeRow extends StatelessWidget {
|
||||||
const _ModeRow({
|
const _ModeRow({
|
||||||
required this.label,
|
required this.label,
|
||||||
@@ -509,9 +885,15 @@ class _ModeRow extends StatelessWidget {
|
|||||||
: selected
|
: selected
|
||||||
? theme.colorScheme.primary
|
? theme.colorScheme.primary
|
||||||
: theme.colorScheme.onSurface;
|
: theme.colorScheme.onSurface;
|
||||||
return InkWell(
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
selected: selected,
|
||||||
|
enabled: !disabled,
|
||||||
|
label: label,
|
||||||
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: ExcludeSemantics(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -535,6 +917,8 @@ class _ModeRow extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -725,7 +1109,9 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
|
|||||||
);
|
);
|
||||||
if (changed != true && mounted) {
|
if (changed != true && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('This output cannot be selected.')),
|
SnackBar(
|
||||||
|
content: Text(AppL10n.of(context).audioRouteCannotSelect),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -733,7 +1119,7 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Could not change audio output.')),
|
SnackBar(content: Text(AppL10n.of(context).audioRouteChangeFailed)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -758,7 +1144,7 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
|
|||||||
if (!kIsWeb && Platform.isAndroid) {
|
if (!kIsWeb && Platform.isAndroid) {
|
||||||
final selected = _androidDevices.where((d) => d.isSelected).firstOrNull;
|
final selected = _androidDevices.where((d) => d.isSelected).firstOrNull;
|
||||||
final label = selected == null
|
final label = selected == null
|
||||||
? 'System default'
|
? l10n.audioRouteSystemDefault
|
||||||
: _androidDeviceLabel(selected.type, selected.name, l10n);
|
: _androidDeviceLabel(selected.type, selected.name, l10n);
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: _openPicker,
|
onTap: _openPicker,
|
||||||
@@ -847,14 +1233,14 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
|
|||||||
AppL10n l10n,
|
AppL10n l10n,
|
||||||
) => switch (type) {
|
) => switch (type) {
|
||||||
'speaker' => l10n.audioRouteSpeaker,
|
'speaker' => l10n.audioRouteSpeaker,
|
||||||
'earpiece' => 'Earpiece',
|
'earpiece' => l10n.audioRouteEarpiece,
|
||||||
'wiredHeadset' || 'wiredHeadphones' => l10n.audioRouteWiredHeadset,
|
'wiredHeadset' || 'wiredHeadphones' => l10n.audioRouteWiredHeadset,
|
||||||
'bluetoothA2dp' ||
|
'bluetoothA2dp' ||
|
||||||
'bluetoothSco' ||
|
'bluetoothSco' ||
|
||||||
'bluetoothLe' => l10n.audioRouteBluetooth,
|
'bluetoothLe' => l10n.audioRouteBluetooth,
|
||||||
'usbHeadset' => fallback.isEmpty ? 'USB Headset' : fallback,
|
'usbHeadset' => fallback.isEmpty ? l10n.audioRouteUsbHeadset : fallback,
|
||||||
'hdmi' => l10n.audioRouteCarAudio,
|
'hdmi' => l10n.audioRouteCarAudio,
|
||||||
_ => fallback.isEmpty ? 'Other Device' : fallback,
|
_ => fallback.isEmpty ? l10n.audioRouteOtherDevice : fallback,
|
||||||
};
|
};
|
||||||
|
|
||||||
static IconData _androidDeviceIcon(String? type) => switch (type) {
|
static IconData _androidDeviceIcon(String? type) => switch (type) {
|
||||||
@@ -893,7 +1279,7 @@ class _AndroidAudioOutputPickerSheet extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_PickerRow(
|
_PickerRow(
|
||||||
icon: Icons.speaker,
|
icon: Icons.speaker,
|
||||||
label: 'System default',
|
label: l10n.audioRouteSystemDefault,
|
||||||
selected: !devices.any((d) => d.isSelected),
|
selected: !devices.any((d) => d.isSelected),
|
||||||
onTap: () => Navigator.of(context).pop('auto'),
|
onTap: () => Navigator.of(context).pop('auto'),
|
||||||
),
|
),
|
||||||
@@ -906,7 +1292,7 @@ class _AndroidAudioOutputPickerSheet extends StatelessWidget {
|
|||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: onRefresh,
|
onPressed: onRefresh,
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Refresh audio devices'),
|
label: Text(l10n.audioRouteRefreshDevices),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
for (final device in devices)
|
for (final device in devices)
|
||||||
@@ -1029,6 +1415,7 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
|
|||||||
await AVAudioSession().overrideOutputAudioPort(
|
await AVAudioSession().overrideOutputAudioPort(
|
||||||
AVAudioSessionPortOverride.speaker,
|
AVAudioSessionPortOverride.speaker,
|
||||||
);
|
);
|
||||||
|
unawaited(Haptics.selection().catchError((_) {}));
|
||||||
debugPrint('chanora: audio output -> speakerphone (override applied)');
|
debugPrint('chanora: audio output -> speakerphone (override applied)');
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('chanora: _selectSpeaker FAILED: $e\n$st');
|
debugPrint('chanora: _selectSpeaker FAILED: $e\n$st');
|
||||||
@@ -1047,6 +1434,7 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
|
|||||||
await AVAudioSession().overrideOutputAudioPort(
|
await AVAudioSession().overrideOutputAudioPort(
|
||||||
AVAudioSessionPortOverride.none,
|
AVAudioSessionPortOverride.none,
|
||||||
);
|
);
|
||||||
|
unawaited(Haptics.selection().catchError((_) {}));
|
||||||
debugPrint('chanora: audio output -> receiver (override cleared)');
|
debugPrint('chanora: audio output -> receiver (override cleared)');
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('chanora: _selectReceiver FAILED: $e\n$st');
|
debugPrint('chanora: _selectReceiver FAILED: $e\n$st');
|
||||||
@@ -1066,9 +1454,8 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
|
|||||||
AVAudioSessionPortOverride.none,
|
AVAudioSessionPortOverride.none,
|
||||||
);
|
);
|
||||||
await AVAudioSession().setPreferredInput(port);
|
await AVAudioSession().setPreferredInput(port);
|
||||||
debugPrint(
|
unawaited(Haptics.selection().catchError((_) {}));
|
||||||
'chanora: audio output -> ${port.portName} (${port.portType})',
|
debugPrint('chanora: audio output -> ${port.portType}');
|
||||||
);
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('chanora: _selectInput FAILED: $e\n$st');
|
debugPrint('chanora: _selectInput FAILED: $e\n$st');
|
||||||
}
|
}
|
||||||
@@ -1171,9 +1558,15 @@ class _PickerRow extends StatelessWidget {
|
|||||||
: enabled
|
: enabled
|
||||||
? theme.colorScheme.onSurface
|
? theme.colorScheme.onSurface
|
||||||
: theme.colorScheme.onSurfaceVariant.withAlpha(130);
|
: theme.colorScheme.onSurfaceVariant.withAlpha(130);
|
||||||
return InkWell(
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
selected: selected,
|
||||||
|
enabled: enabled,
|
||||||
|
label: label,
|
||||||
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: ExcludeSemantics(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4),
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -1186,10 +1579,13 @@ class _PickerRow extends StatelessWidget {
|
|||||||
style: theme.textTheme.bodyLarge?.copyWith(color: color),
|
style: theme.textTheme.bodyLarge?.copyWith(color: color),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (selected) Icon(Icons.check, color: theme.colorScheme.primary),
|
if (selected)
|
||||||
|
Icon(Icons.check, color: theme.colorScheme.primary),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
|
||||||
|
/// True when the host is a touch-only mobile platform without a
|
||||||
|
/// hardware keyboard the user would bind a PTT key on.
|
||||||
|
bool get isTouchOnlyPttHost {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
return Platform.isIOS || Platform.isAndroid;
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
// Voice settings dialog (SDD-097). Surfaces a TransmitMode radio
|
// Voice settings dialog (SDD-097). Surfaces transmit mode, release
|
||||||
// group, a bind-key button, and a release-tail slider.
|
// tail, and the full P1 audio processing configuration:
|
||||||
|
// - Noise suppression (NS)
|
||||||
|
// - Echo cancellation (AEC3)
|
||||||
|
// - Automatic gain control (AGC2)
|
||||||
|
// - High-pass filter (HPF)
|
||||||
|
// - VAD backend
|
||||||
|
// - iOS voice processing mode
|
||||||
|
|
||||||
|
// ignore_for_file: deprecated_member_use
|
||||||
|
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
@@ -7,52 +15,41 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
|
import 'voice_platform.dart';
|
||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
/// True when the host is a touch-only mobile platform without a
|
bool get _isIos {
|
||||||
/// hardware keyboard the user would bind a PTT key on. Mirrors the
|
|
||||||
/// helper in `voice_bar.dart`.
|
|
||||||
bool get _isTouchOnlyPttHost {
|
|
||||||
if (kIsWeb) return false;
|
if (kIsWeb) return false;
|
||||||
return Platform.isIOS || Platform.isAndroid;
|
return Platform.isIOS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result returned by [`VoiceSettingsDialog`]. `null` indicates a
|
/// Result returned by [VoiceSettingsDialog].
|
||||||
/// cancelled dialog.
|
|
||||||
class VoiceSettingsResult {
|
class VoiceSettingsResult {
|
||||||
/// Construct a result snapshot.
|
|
||||||
const VoiceSettingsResult({
|
const VoiceSettingsResult({
|
||||||
required this.mode,
|
required this.mode,
|
||||||
required this.releaseTailMs,
|
required this.releaseTailMs,
|
||||||
required this.bindKeyRequested,
|
required this.bindKeyRequested,
|
||||||
|
required this.audioConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Selected transmit mode.
|
|
||||||
final rust.BridgeTransmitMode mode;
|
final rust.BridgeTransmitMode mode;
|
||||||
|
|
||||||
/// Chosen release-tail in milliseconds (0..=500, step 25).
|
|
||||||
final int releaseTailMs;
|
final int releaseTailMs;
|
||||||
|
|
||||||
/// True when the user tapped the "bind key" button. The caller
|
|
||||||
/// is expected to open the focus-scoped capture dialog
|
|
||||||
/// afterwards.
|
|
||||||
final bool bindKeyRequested;
|
final bool bindKeyRequested;
|
||||||
|
final rust.BridgeAudioProcessingConfig audioConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Voice settings dialog widget.
|
/// Voice + audio processing settings dialog.
|
||||||
class VoiceSettingsDialog extends StatefulWidget {
|
class VoiceSettingsDialog extends StatefulWidget {
|
||||||
/// Construct a dialog seeded with the current settings.
|
|
||||||
const VoiceSettingsDialog({
|
const VoiceSettingsDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.initialMode,
|
required this.initialMode,
|
||||||
required this.initialReleaseTailMs,
|
required this.initialReleaseTailMs,
|
||||||
|
required this.initialAudioConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Currently active transmit mode.
|
|
||||||
final rust.BridgeTransmitMode initialMode;
|
final rust.BridgeTransmitMode initialMode;
|
||||||
|
|
||||||
/// Currently configured release tail in milliseconds.
|
|
||||||
final int initialReleaseTailMs;
|
final int initialReleaseTailMs;
|
||||||
|
final rust.BridgeAudioProcessingConfig initialAudioConfig;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
||||||
@@ -62,83 +59,134 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
late rust.BridgeTransmitMode _mode;
|
late rust.BridgeTransmitMode _mode;
|
||||||
late double _releaseTail;
|
late double _releaseTail;
|
||||||
|
|
||||||
|
// Audio processing state — mirrors BridgeAudioProcessingConfig fields.
|
||||||
|
late bool _nsEnabled;
|
||||||
|
late bool _aecEnabled;
|
||||||
|
late bool _agcEnabled;
|
||||||
|
late bool _hpfEnabled;
|
||||||
|
late bool _limiterEnabled;
|
||||||
|
late rust.BridgeVadBackend _vadBackend;
|
||||||
|
late rust.BridgeIosVoiceProcessingMode _iosMode;
|
||||||
|
late bool _debugWavDump;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_mode = widget.initialMode;
|
_mode = widget.initialMode;
|
||||||
_releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble();
|
_releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble();
|
||||||
|
|
||||||
|
final c = widget.initialAudioConfig;
|
||||||
|
_nsEnabled = c.ns != rust.BridgeEffectOwner.off;
|
||||||
|
_aecEnabled = c.aec != rust.BridgeEffectOwner.off;
|
||||||
|
_agcEnabled = c.agc != rust.BridgeEffectOwner.off;
|
||||||
|
_hpfEnabled = c.hpfEnabled;
|
||||||
|
_limiterEnabled = c.limiterEnabled;
|
||||||
|
_vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
|
||||||
|
? rust.BridgeVadBackend.webrtcVad
|
||||||
|
: c.vadBackend;
|
||||||
|
_iosMode = c.iosMode;
|
||||||
|
_debugWavDump = c.debugWavDumpEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||||
|
final c = widget.initialAudioConfig;
|
||||||
|
final isSonora =
|
||||||
|
_iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
|
||||||
|
// In VPIO mode, enabled effects are platform-owned. Sonora ownership is
|
||||||
|
// reserved for the experimental raw path so config validation stays honest.
|
||||||
|
final aecOwner = isSonora
|
||||||
|
? (_aecEnabled
|
||||||
|
? rust.BridgeEffectOwner.sonora
|
||||||
|
: rust.BridgeEffectOwner.off)
|
||||||
|
: rust.BridgeEffectOwner.platform; // VPIO always owns AEC
|
||||||
|
final nsOwner = isSonora
|
||||||
|
? (_nsEnabled
|
||||||
|
? rust.BridgeEffectOwner.sonora
|
||||||
|
: rust.BridgeEffectOwner.off)
|
||||||
|
: (_nsEnabled
|
||||||
|
? rust.BridgeEffectOwner.platform
|
||||||
|
: rust.BridgeEffectOwner.off);
|
||||||
|
final agcOwner = isSonora
|
||||||
|
? (_agcEnabled
|
||||||
|
? rust.BridgeEffectOwner.sonora
|
||||||
|
: rust.BridgeEffectOwner.off)
|
||||||
|
: (_agcEnabled
|
||||||
|
? rust.BridgeEffectOwner.platform
|
||||||
|
: rust.BridgeEffectOwner.off);
|
||||||
|
final vadBackend = _vadBackend == rust.BridgeVadBackend.disabled
|
||||||
|
? rust.BridgeVadBackend.webrtcVad
|
||||||
|
: _vadBackend;
|
||||||
|
return rust.BridgeAudioProcessingConfig(
|
||||||
|
route: c.route,
|
||||||
|
iosMode: _iosMode,
|
||||||
|
processingBackend: isSonora
|
||||||
|
? rust.BridgeAudioBackend.sonora
|
||||||
|
: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||||
|
vadBackend: vadBackend,
|
||||||
|
aec: aecOwner,
|
||||||
|
ns: nsOwner,
|
||||||
|
agc: agcOwner,
|
||||||
|
hpfEnabled: _hpfEnabled,
|
||||||
|
limiterEnabled: _limiterEnabled,
|
||||||
|
vadHangoverMs: c.vadHangoverMs,
|
||||||
|
vadPreRollMs: c.vadPreRollMs,
|
||||||
|
vadMinTxMs: c.vadMinTxMs,
|
||||||
|
debugWavDumpEnabled: _debugWavDump,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppL10n.of(context);
|
final l10n = AppL10n.of(context);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final platformVpio =
|
||||||
|
_iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(l10n.voiceSettingsTitle),
|
title: Text(l10n.voiceSettingsTitle),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 360,
|
width: 400,
|
||||||
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
// ── Transmit mode ──────────────────────────────────────
|
||||||
l10n.voiceModeLabel,
|
_sectionHeader(theme, l10n.voiceModeLabel),
|
||||||
style: theme.textTheme.titleSmall,
|
_radioTile<rust.BridgeTransmitMode>(
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
RadioListTile<rust.BridgeTransmitMode>(
|
|
||||||
dense: true,
|
|
||||||
value: rust.BridgeTransmitMode.ptt,
|
value: rust.BridgeTransmitMode.ptt,
|
||||||
groupValue: _mode,
|
groupValue: _mode,
|
||||||
title: Text(l10n.voiceModePtt),
|
title: Text(l10n.voiceModePtt),
|
||||||
onChanged: (v) => setState(() => _mode = v!),
|
onSelected: (v) => _mode = v,
|
||||||
),
|
),
|
||||||
RadioListTile<rust.BridgeTransmitMode>(
|
_radioTile<rust.BridgeTransmitMode>(
|
||||||
dense: true,
|
|
||||||
value: rust.BridgeTransmitMode.continuous,
|
value: rust.BridgeTransmitMode.continuous,
|
||||||
groupValue: _mode,
|
groupValue: _mode,
|
||||||
title: Text(l10n.voiceModeContinuous),
|
title: Text(l10n.voiceModeContinuous),
|
||||||
onChanged: (v) => setState(() => _mode = v!),
|
onSelected: (v) => _mode = v,
|
||||||
),
|
),
|
||||||
RadioListTile<rust.BridgeTransmitMode>(
|
_radioTile<rust.BridgeTransmitMode>(
|
||||||
dense: true,
|
|
||||||
value: rust.BridgeTransmitMode.voiceActivity,
|
value: rust.BridgeTransmitMode.voiceActivity,
|
||||||
groupValue: _mode,
|
groupValue: _mode,
|
||||||
title: Text(l10n.voiceModeVoiceActivity),
|
title: Text(l10n.voiceModeVoiceActivity),
|
||||||
secondary: Text(
|
onSelected: (v) => _mode = v,
|
||||||
l10n.voiceModeComingSoon,
|
|
||||||
style: theme.textTheme.bodySmall,
|
|
||||||
),
|
),
|
||||||
// VoiceActivity is reserved per DEC-030 — keep the
|
|
||||||
// tile visible but disabled per SDD-095.
|
// ── PTT options ────────────────────────────────────────
|
||||||
onChanged: null,
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
// Bind-key + release-tail are PTT-only concepts. Hide
|
|
||||||
// them entirely when the user has switched to a
|
|
||||||
// non-PTT mode so the dialog stays focused on what's
|
|
||||||
// actually configurable for that mode.
|
|
||||||
//
|
|
||||||
// Additionally on touch-only mobile hosts (iOS / iPadOS
|
|
||||||
// / Android) there is no hardware keyboard to bind a
|
|
||||||
// key on — the VoiceBar renders an on-screen Push to
|
|
||||||
// Talk button instead. Hide the Bind Key affordance
|
|
||||||
// there but keep the release-tail slider since it
|
|
||||||
// still applies to the on-screen button's behaviour.
|
|
||||||
if (_mode == rust.BridgeTransmitMode.ptt) ...[
|
if (_mode == rust.BridgeTransmitMode.ptt) ...[
|
||||||
if (!_isTouchOnlyPttHost) ...[
|
const Divider(height: 24),
|
||||||
|
if (!isTouchOnlyPttHost) ...[
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.keyboard),
|
icon: const Icon(Icons.keyboard),
|
||||||
label: Text(l10n.voiceBindKeyAction),
|
label: Text(l10n.voiceBindKeyAction),
|
||||||
onPressed: () {
|
onPressed: () => Navigator.of(context).pop(
|
||||||
Navigator.of(context).pop(
|
|
||||||
VoiceSettingsResult(
|
VoiceSettingsResult(
|
||||||
mode: _mode,
|
mode: _mode,
|
||||||
releaseTailMs: _releaseTail.round(),
|
releaseTailMs: _releaseTail.round(),
|
||||||
bindKeyRequested: true,
|
bindKeyRequested: true,
|
||||||
|
audioConfig: _buildConfig(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
@@ -153,7 +201,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
value: _releaseTail,
|
value: _releaseTail,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 500,
|
max: 500,
|
||||||
divisions: 20, // step 25 ms
|
divisions: 20,
|
||||||
label:
|
label:
|
||||||
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
|
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
|
||||||
onChanged: (v) => setState(() => _releaseTail = v),
|
onChanged: (v) => setState(() => _releaseTail = v),
|
||||||
@@ -170,7 +218,114 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// ── Audio processing ───────────────────────────────────
|
||||||
|
const Divider(height: 24),
|
||||||
|
_sectionHeader(theme, 'Audio processing'),
|
||||||
|
|
||||||
|
// iOS mode selector (iOS only)
|
||||||
|
if (_isIos) ...[
|
||||||
|
_subHeader(theme, 'Processing backend'),
|
||||||
|
_radioTile<rust.BridgeIosVoiceProcessingMode>(
|
||||||
|
value:
|
||||||
|
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||||
|
groupValue: _iosMode,
|
||||||
|
title: const Text('Platform (VPIO)'),
|
||||||
|
subtitle: _tileSubtitle('Apple AEC · NS · AGC'),
|
||||||
|
onSelected: (v) => _iosMode = v,
|
||||||
|
),
|
||||||
|
_radioTile<rust.BridgeIosVoiceProcessingMode>(
|
||||||
|
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
|
||||||
|
groupValue: _iosMode,
|
||||||
|
title: const Text('Sonora (experimental)'),
|
||||||
|
subtitle: _tileSubtitle('Rust AEC3 · NS · AGC2'),
|
||||||
|
onSelected: (v) => _iosMode = v,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// DSP toggles
|
||||||
|
_subHeader(theme, 'DSP stages'),
|
||||||
|
_switchTile(
|
||||||
|
title: 'Noise suppression (NS)',
|
||||||
|
subtitle: 'Wiener filter · stationary noise',
|
||||||
|
value: _nsEnabled,
|
||||||
|
onSelected: (v) => _nsEnabled = v,
|
||||||
|
),
|
||||||
|
_switchTile(
|
||||||
|
title: 'Echo cancellation (AEC3)',
|
||||||
|
subtitle: platformVpio
|
||||||
|
? 'Managed by platform VPIO'
|
||||||
|
: 'Adaptive NLMS · 80 ms tail',
|
||||||
|
value: _aecEnabled,
|
||||||
|
// AEC is always on in VPIO mode — disable the toggle.
|
||||||
|
onSelected: platformVpio ? null : (v) => _aecEnabled = v,
|
||||||
|
),
|
||||||
|
_switchTile(
|
||||||
|
title: 'Auto gain control (AGC2)',
|
||||||
|
subtitle: 'RNN VAD-gated · −18 dBFS target',
|
||||||
|
value: _agcEnabled,
|
||||||
|
onSelected: (v) => _agcEnabled = v,
|
||||||
|
),
|
||||||
|
_switchTile(
|
||||||
|
title: 'High-pass filter (HPF)',
|
||||||
|
subtitle: '80 Hz Butterworth · DC removal',
|
||||||
|
value: _hpfEnabled,
|
||||||
|
onSelected: (v) => _hpfEnabled = v,
|
||||||
|
),
|
||||||
|
_switchTile(
|
||||||
|
title: 'Peak limiter',
|
||||||
|
subtitle: '−1 dBFS soft-knee · 2 ms look-ahead',
|
||||||
|
value: _limiterEnabled,
|
||||||
|
onSelected: (v) => _limiterEnabled = v,
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── VAD ────────────────────────────────────────────────
|
||||||
|
const Divider(height: 24),
|
||||||
|
_sectionHeader(theme, 'Voice activity detection (VAD)'),
|
||||||
|
|
||||||
|
_subHeader(theme, 'Backend'),
|
||||||
|
_radioTile<rust.BridgeVadBackend>(
|
||||||
|
value: rust.BridgeVadBackend.webrtcVad,
|
||||||
|
groupValue: _vadBackend,
|
||||||
|
title: const Text('WebRTC VAD'),
|
||||||
|
subtitle: _tileSubtitle(
|
||||||
|
'Fast · energy-based · always available',
|
||||||
|
),
|
||||||
|
onSelected: (v) => _vadBackend = v,
|
||||||
|
),
|
||||||
|
_radioTile<rust.BridgeVadBackend>(
|
||||||
|
value: rust.BridgeVadBackend.sileroOnnx,
|
||||||
|
groupValue: _vadBackend,
|
||||||
|
title: const Text('Silero v6 (ONNX)'),
|
||||||
|
subtitle: _tileSubtitle(
|
||||||
|
'Neural · 32 ms frames · requires model file',
|
||||||
|
),
|
||||||
|
onSelected: (v) => _vadBackend = v,
|
||||||
|
),
|
||||||
|
_radioTile<rust.BridgeVadBackend>(
|
||||||
|
value: rust.BridgeVadBackend.tenVad,
|
||||||
|
groupValue: _vadBackend,
|
||||||
|
title: const Text('TEN VAD'),
|
||||||
|
subtitle: _tileSubtitle(
|
||||||
|
'Neural · 16 kHz · native runtime optional',
|
||||||
|
),
|
||||||
|
onSelected: (v) => _vadBackend = v,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// ── Debug ──────────────────────────────────────────────
|
||||||
|
const Divider(height: 24),
|
||||||
|
_sectionHeader(theme, 'Debug'),
|
||||||
|
_switchTile(
|
||||||
|
title: 'WAV dump',
|
||||||
|
subtitle: 'Record raw/processed mic to temp dir',
|
||||||
|
value: _debugWavDump,
|
||||||
|
onSelected: (v) => _debugWavDump = v,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -184,6 +339,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
mode: _mode,
|
mode: _mode,
|
||||||
releaseTailMs: _releaseTail.round(),
|
releaseTailMs: _releaseTail.round(),
|
||||||
bindKeyRequested: false,
|
bindKeyRequested: false,
|
||||||
|
audioConfig: _buildConfig(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(l10n.pttConfigureSaveAction),
|
child: Text(l10n.pttConfigureSaveAction),
|
||||||
@@ -191,4 +347,54 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _radioTile<T>({
|
||||||
|
required T value,
|
||||||
|
required T groupValue,
|
||||||
|
required Widget title,
|
||||||
|
Widget? subtitle,
|
||||||
|
required ValueChanged<T> onSelected,
|
||||||
|
}) => RadioListTile<T>(
|
||||||
|
dense: true,
|
||||||
|
value: value,
|
||||||
|
groupValue: groupValue,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v == null) return;
|
||||||
|
setState(() => onSelected(v));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _switchTile({
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required bool value,
|
||||||
|
required ValueChanged<bool>? onSelected,
|
||||||
|
}) => SwitchListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: _tileSubtitle(subtitle),
|
||||||
|
value: value,
|
||||||
|
onChanged: onSelected == null ? null : (v) => setState(() => onSelected(v)),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _tileSubtitle(String text) =>
|
||||||
|
Text(text, style: const TextStyle(fontSize: 11));
|
||||||
|
|
||||||
|
Widget _sectionHeader(ThemeData theme, String text) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Text(text, style: theme.textTheme.titleSmall),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _subHeader(ThemeData theme, String text) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 2),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,6 +309,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
|
haptic_kit:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: haptic_kit
|
||||||
|
sha256: "39efffa513c9f8ce3cdded8a4423797f69d71c9281779b83727337f3ee1ed9b8"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ dependencies:
|
|||||||
# more "am I testing the right build?" question during the
|
# more "am I testing the right build?" question during the
|
||||||
# iOS audio test cycle.
|
# iOS audio test cycle.
|
||||||
package_info_plus: ^10.1.0
|
package_info_plus: ^10.1.0
|
||||||
|
# Touch-only PTT feedback for mobile voice UX (P0 voice basics,
|
||||||
|
# DEC-003 iOS 13 floor; haptic_kit supports iOS 12+).
|
||||||
|
haptic_kit: ^1.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -105,10 +108,9 @@ flutter:
|
|||||||
# the material Icons class.
|
# the material Icons class.
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
assets:
|
||||||
# assets:
|
- assets/models/silero_vad.onnx
|
||||||
# - images/a_dot_burr.jpeg
|
- assets/models/ten_vad.onnx
|
||||||
# - images/a_dot_ham.jpeg
|
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|||||||
@@ -22,12 +22,20 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
import 'package:chanora_flutter/src/rust/frb_generated.dart';
|
import 'package:chanora_flutter/src/rust/frb_generated.dart';
|
||||||
|
|
||||||
|
const _runE2e = bool.fromEnvironment('CHANORA_RUN_E2E');
|
||||||
|
const _skipReason =
|
||||||
|
'Set --dart-define=CHANORA_RUN_E2E=true with a built native bridge to run '
|
||||||
|
'network end-to-end acceptance.';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
|
if (!_runE2e) return;
|
||||||
await RustLib.init();
|
await RustLib.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('connect/snapshot/disconnect against cn.teamspeak.app', () async {
|
test(
|
||||||
|
'connect/snapshot/disconnect against cn.teamspeak.app',
|
||||||
|
() async {
|
||||||
// Defensive: in case a previous test left a connection open.
|
// Defensive: in case a previous test left a connection open.
|
||||||
try {
|
try {
|
||||||
await rust.disconnect();
|
await rust.disconnect();
|
||||||
@@ -56,5 +64,8 @@ void main() {
|
|||||||
|
|
||||||
final connectedAfter = await rust.isConnected();
|
final connectedAfter = await rust.isConnected();
|
||||||
expect(connectedAfter, isFalse);
|
expect(connectedAfter, isFalse);
|
||||||
}, timeout: const Timeout(Duration(seconds: 30)));
|
},
|
||||||
|
timeout: const Timeout(Duration(seconds: 30)),
|
||||||
|
skip: _runE2e ? false : _skipReason,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,20 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
import 'package:chanora_flutter/src/rust/frb_generated.dart';
|
import 'package:chanora_flutter/src/rust/frb_generated.dart';
|
||||||
|
|
||||||
|
const _runE2e = bool.fromEnvironment('CHANORA_RUN_E2E');
|
||||||
|
const _skipReason =
|
||||||
|
'Set --dart-define=CHANORA_RUN_E2E=true with a built native bridge to run '
|
||||||
|
'network end-to-end acceptance.';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
|
if (!_runE2e) return;
|
||||||
await RustLib.init();
|
await RustLib.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('connect → start_audio → PTT cycle → disconnect', () async {
|
test(
|
||||||
|
'connect → start_audio → PTT cycle → disconnect',
|
||||||
|
() async {
|
||||||
// Defensive cleanup in case a previous test left state.
|
// Defensive cleanup in case a previous test left state.
|
||||||
try {
|
try {
|
||||||
await rust.disconnect();
|
await rust.disconnect();
|
||||||
@@ -77,6 +85,11 @@ void main() {
|
|||||||
expect(connectedAfter, isFalse);
|
expect(connectedAfter, isFalse);
|
||||||
|
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('Beta E2E: TX=${s1.framesSent} frames, RX=${s1.framesReceived} frames');
|
print(
|
||||||
}, timeout: const Timeout(Duration(seconds: 30)));
|
'Beta E2E: TX=${s1.framesSent} frames, RX=${s1.framesReceived} frames',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
timeout: const Timeout(Duration(seconds: 30)),
|
||||||
|
skip: _runE2e ? false : _skipReason,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,35 +11,224 @@
|
|||||||
// Verification-plan rows: SWE4-UV-014, SWE4-UV-019, SWE4-UV-020 (swe4-unit-verification-plan.md).
|
// Verification-plan rows: SWE4-UV-014, SWE4-UV-019, SWE4-UV-020 (swe4-unit-verification-plan.md).
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: deprecated_member_use
|
||||||
|
|
||||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:chanora_flutter/services/android_permissions_service.dart';
|
||||||
|
import 'package:chanora_flutter/services/ios_permissions_service.dart';
|
||||||
|
import 'package:chanora_flutter/widgets/permission_state_banner.dart';
|
||||||
|
import 'package:chanora_flutter/widgets/voice_compact.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('renders English banner', (tester) async {
|
testWidgets('renders English banner', (tester) async {
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
supportedLocales: AppL10n.supportedLocales,
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
home: Builder(builder: (ctx) {
|
home: Builder(
|
||||||
|
builder: (ctx) {
|
||||||
final l10n = AppL10n.of(ctx);
|
final l10n = AppL10n.of(ctx);
|
||||||
return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
|
return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
|
||||||
}),
|
},
|
||||||
));
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.textContaining('Beta build'), findsOneWidget);
|
expect(find.textContaining('Beta build'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders Simplified Chinese banner', (tester) async {
|
testWidgets('renders Simplified Chinese banner', (tester) async {
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
locale: const Locale('zh'),
|
locale: const Locale('zh'),
|
||||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
supportedLocales: AppL10n.supportedLocales,
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
home: Builder(builder: (ctx) {
|
home: Builder(
|
||||||
|
builder: (ctx) {
|
||||||
final l10n = AppL10n.of(ctx);
|
final l10n = AppL10n.of(ctx);
|
||||||
return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
|
return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
|
||||||
}),
|
},
|
||||||
));
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.textContaining('Beta 版本'), findsOneWidget);
|
expect(find.textContaining('Beta 版本'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('localizes diagnostic export save action', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Builder(
|
||||||
|
builder: (ctx) {
|
||||||
|
final l10n = AppL10n.of(ctx);
|
||||||
|
return Scaffold(
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Text(l10n.diagnosticsSaveAction),
|
||||||
|
Text(l10n.diagnosticsSaved('/tmp/chanora-diagnostics.txt')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Save export'), findsOneWidget);
|
||||||
|
expect(find.textContaining('chanora-diagnostics.txt'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('on-screen PTT exposes hold-to-talk semantics', (tester) async {
|
||||||
|
final semantics = tester.ensureSemantics();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: VoicePttButton(active: false, onHeldChanged: (_) {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final node = tester.getSemantics(find.byType(VoicePttButton));
|
||||||
|
expect(node.label, 'Hold to talk');
|
||||||
|
expect(node.hint, 'Press and hold to transmit voice; release to stop.');
|
||||||
|
expect(node.hasFlag(SemanticsFlag.isButton), isTrue);
|
||||||
|
expect(node.hasFlag(SemanticsFlag.isLiveRegion), isTrue);
|
||||||
|
semantics.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('permission banner grants denied microphone access', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final state = ValueNotifier(AndroidRecordAudioPermissionState.denied);
|
||||||
|
var grantCount = 0;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: PermissionStateBanner.fromCallbacks(
|
||||||
|
recordAudioState: state,
|
||||||
|
ensureRecordAudio: () async {
|
||||||
|
grantCount += 1;
|
||||||
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
|
},
|
||||||
|
openAppSettings: () async {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Grant'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Grant'));
|
||||||
|
expect(grantCount, 1);
|
||||||
|
state.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('permission banner opens settings after permanent denial', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final state = ValueNotifier(
|
||||||
|
AndroidRecordAudioPermissionState.permanentlyDenied,
|
||||||
|
);
|
||||||
|
var settingsCount = 0;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: PermissionStateBanner.fromCallbacks(
|
||||||
|
recordAudioState: state,
|
||||||
|
ensureRecordAudio: () async =>
|
||||||
|
AndroidRecordAudioPermissionState.permanentlyDenied,
|
||||||
|
openAppSettings: () async {
|
||||||
|
settingsCount += 1;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Open System Settings'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Open System Settings'));
|
||||||
|
expect(settingsCount, 1);
|
||||||
|
state.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('on-screen PTT reports press and release gestures', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final states = <bool>[];
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: VoicePttButton(active: false, onHeldChanged: states.add),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final center = tester.getCenter(find.byType(VoicePttButton));
|
||||||
|
final gesture = await tester.startGesture(center);
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(states, [true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('iOS permission service maps channel states and settings', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
const channel = MethodChannel(iosPlatformChannelName);
|
||||||
|
final methods = <String>[];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
methods.add(call.method);
|
||||||
|
switch (call.method) {
|
||||||
|
case methodGetMicrophonePermissionState:
|
||||||
|
return 'NotDetermined';
|
||||||
|
case methodRequestMicrophonePermission:
|
||||||
|
return 'Granted';
|
||||||
|
case methodIosOpenAppSettings:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final service = IosPermissionsService(channel: channel);
|
||||||
|
await service.start();
|
||||||
|
expect(
|
||||||
|
service.recordAudioState.value,
|
||||||
|
AndroidRecordAudioPermissionState.denied,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await service.ensureRecordAudio(),
|
||||||
|
AndroidRecordAudioPermissionState.granted,
|
||||||
|
);
|
||||||
|
await service.openAppSettings();
|
||||||
|
|
||||||
|
expect(methods, [
|
||||||
|
methodGetMicrophonePermissionState,
|
||||||
|
methodRequestMicrophonePermission,
|
||||||
|
methodIosOpenAppSettings,
|
||||||
|
]);
|
||||||
|
|
||||||
|
service.dispose();
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-32
@@ -54,8 +54,9 @@ use chanora_state::channel_join::{
|
|||||||
pub mod ptt;
|
pub mod ptt;
|
||||||
|
|
||||||
pub use chanora_audio::{
|
pub use chanora_audio::{
|
||||||
AudioEngine, AudioEngineConfig, AudioTransmitGate, PttBackendDescriptor, PttCapabilityLevel,
|
AudioBackend, AudioEngine, AudioEngineConfig, AudioProcessingConfig, AudioProcessingStats,
|
||||||
ReleaseTailTimer, TransmitMode, TransmitModeSelector,
|
AudioRoute, AudioTransmitGate, EffectOwner, IosVoiceProcessingMode, PttBackendDescriptor,
|
||||||
|
PttCapabilityLevel, ReleaseTailTimer, TransmitMode, TransmitModeSelector, VadBackend,
|
||||||
};
|
};
|
||||||
pub use chanora_audio::{PttBinding, PttInputClass};
|
pub use chanora_audio::{PttBinding, PttInputClass};
|
||||||
pub use chanora_diagnostics::{
|
pub use chanora_diagnostics::{
|
||||||
@@ -704,7 +705,7 @@ impl ChanoraSession {
|
|||||||
/// Fails if not connected. Idempotent — calling twice replaces
|
/// Fails if not connected. Idempotent — calling twice replaces
|
||||||
/// the engine. Stores the config so the supervisor can restart
|
/// the engine. Stores the config so the supervisor can restart
|
||||||
/// audio after a reconnect.
|
/// audio after a reconnect.
|
||||||
pub async fn start_audio(&self, cfg: AudioEngineConfig) -> Result<(), CoreError> {
|
pub async fn start_audio(&self, mut cfg: AudioEngineConfig) -> Result<(), CoreError> {
|
||||||
let mut guard = self.inner.lock().await;
|
let mut guard = self.inner.lock().await;
|
||||||
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
||||||
|
|
||||||
@@ -725,6 +726,7 @@ impl ChanoraSession {
|
|||||||
.take_voice_in()
|
.take_voice_in()
|
||||||
.ok_or(CoreError::Invariant("voice_in already taken"))?;
|
.ok_or(CoreError::Invariant("voice_in already taken"))?;
|
||||||
let gate = AudioTransmitGate::new(cfg.ptt_initial);
|
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(
|
let new_engine = match chanora_audio::AudioEngine::start_with_gate(
|
||||||
cfg.clone(),
|
cfg.clone(),
|
||||||
voice_out,
|
voice_out,
|
||||||
@@ -996,10 +998,10 @@ impl ChanoraSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update self-mute state. `input` mutes the microphone, `output`
|
/// Update self-mute state. `input` mutes the microphone, `output`
|
||||||
/// mutes the local speaker for remote clients. Pass `None` to
|
/// mutes the local speaker. Pass `None` to leave a field
|
||||||
/// leave a field unchanged. Adjusting the local output mute also
|
/// unchanged. Adjusting the local output mute also updates the
|
||||||
/// updates the audio engine's master output gain so playback
|
/// audio engine's master output gain so playback silences
|
||||||
/// silences immediately, independent of the server's broadcast.
|
/// immediately, independent of the server's broadcast.
|
||||||
pub async fn set_self_muted(
|
pub async fn set_self_muted(
|
||||||
&self,
|
&self,
|
||||||
input: Option<bool>,
|
input: Option<bool>,
|
||||||
@@ -1007,37 +1009,24 @@ impl ChanoraSession {
|
|||||||
) -> Result<(), CoreError> {
|
) -> Result<(), CoreError> {
|
||||||
let mut guard = self.inner.lock().await;
|
let mut guard = self.inner.lock().await;
|
||||||
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
||||||
// Server-side output mute/deafen makes TeamSpeak/tsclientlib
|
|
||||||
// consider the client unable to send audio. That is correct
|
|
||||||
// for a server-visible "deafened" state, but our P0 speaker
|
|
||||||
// button is a local playback mute. Keep output mute off the
|
|
||||||
// server-side output/deafen flag, but fold it into the mic
|
|
||||||
// disabled state below because P0 product semantics are:
|
|
||||||
// speaker disabled also means microphone disabled.
|
|
||||||
if let Some(muted) = input {
|
if let Some(muted) = input {
|
||||||
state.local_input_muted = muted;
|
state.local_input_muted = muted;
|
||||||
}
|
}
|
||||||
if let Some(muted) = output {
|
if let Some(muted) = output {
|
||||||
state.local_output_muted = muted;
|
state.local_output_muted = muted;
|
||||||
}
|
}
|
||||||
let mic_disabled = state.local_input_muted || state.local_output_muted;
|
state.protocol.set_muted(input, output).await?;
|
||||||
state.protocol.set_muted(Some(mic_disabled), None).await?;
|
|
||||||
if let Some(muted) = output {
|
if let Some(muted) = output {
|
||||||
if let Some(audio) = state.audio.as_ref() {
|
if let Some(audio) = state.audio.as_ref() {
|
||||||
audio.set_output_muted(muted);
|
audio.set_output_muted(muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// When server-side input mute is engaged we must ALSO stop
|
// Input mute must also stop local outbound voice production
|
||||||
// producing outbound voice frames locally — otherwise the
|
// so the transmit selector stays in sync with the server-side
|
||||||
// Opus encoder happily writes packets, the protocol layer
|
// mic mute. Output mute is playback-only and must not affect
|
||||||
// hands them to tsclientlib, tsclientlib refuses them
|
// the mic gate.
|
||||||
// because its own ClientMuted flag is set, and logs
|
let mic_disabled = state.local_input_muted;
|
||||||
// "Sending audio while muted" once per 20 ms frame. That
|
self.voice_selector.set_hard_mute(mic_disabled);
|
||||||
// flooded the log to 200 MB on the Korean test host.
|
|
||||||
// Clamp the transmit-mode selector's hard_mute input so
|
|
||||||
// the gate goes false too.
|
|
||||||
self.voice_selector
|
|
||||||
.set_hard_mute(mic_disabled);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,18 +1048,98 @@ impl ChanoraSession {
|
|||||||
Ok((audio.frames_sent(), audio.frames_received(), audio.ptt()))
|
Ok((audio.frames_sent(), audio.frames_received(), audio.ptt()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// iOS route-change hook (SDD-100). No-op when audio is not
|
/// Apply the Rust-owned P1 audio-processing configuration.
|
||||||
/// running.
|
pub async fn set_audio_processing_config(
|
||||||
pub async fn ios_handle_route_change(&self) -> Result<(), CoreError> {
|
&self,
|
||||||
|
config: AudioProcessingConfig,
|
||||||
|
) -> Result<(), CoreError> {
|
||||||
let guard = self.inner.lock().await;
|
let guard = self.inner.lock().await;
|
||||||
if let Some(state) = guard.as_ref() {
|
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
if let Some(audio) = state.audio.as_ref() {
|
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
|
||||||
|
let new_config = config.clone();
|
||||||
|
let current = audio.audio_processing_config_snapshot();
|
||||||
|
audio.set_audio_processing_config(config)?;
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
{
|
||||||
|
if current.route != new_config.route
|
||||||
|
|| current.ios_mode != new_config.ios_mode
|
||||||
|
|| current.processing_backend != new_config.processing_backend
|
||||||
|
{
|
||||||
audio.ios_restart_voice_unit()?;
|
audio.ios_restart_voice_unit()?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the current audio-processing configuration.
|
||||||
|
///
|
||||||
|
/// Returns the live config snapshot from the audio engine, or a
|
||||||
|
/// default config when no session / audio engine is active.
|
||||||
|
pub async fn get_audio_processing_config(&self) -> Result<AudioProcessingConfig, CoreError> {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
if let Some(state) = guard.as_ref() {
|
||||||
|
if let Some(audio) = state.audio.as_ref() {
|
||||||
|
return Ok(audio.audio_processing_config_snapshot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(AudioProcessingConfig::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read P1 audio-processing diagnostics.
|
||||||
|
pub async fn audio_processing_stats(&self) -> Result<AudioProcessingStats, CoreError> {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
|
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
|
||||||
|
Ok(audio.audio_processing_stats())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the preferred Silero ONNX VAD model path.
|
||||||
|
///
|
||||||
|
/// This does not require an active connection. Running iOS audio
|
||||||
|
/// backends observe the model-path epoch and reload on the next
|
||||||
|
/// capture frame when Silero is selected.
|
||||||
|
pub async fn set_vad_model_path(&self, path: String) -> Result<(), CoreError> {
|
||||||
|
chanora_audio::vad::set_silero_model_path(&path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS route-change hook (SDD-100). No-op when audio is not running.
|
||||||
|
pub async fn ios_handle_route_change(&self, route: AudioRoute) -> Result<(), CoreError> {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
if let Some(state) = guard.as_ref() {
|
||||||
|
if let Some(audio) = state.audio.as_ref() {
|
||||||
|
let mut config = audio.audio_processing_config_snapshot();
|
||||||
|
config.route = route;
|
||||||
|
audio.set_audio_processing_config(config)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable async WAV debug dump for the active audio
|
||||||
|
/// session (DIAG_002 / DIAG_003). No-op when audio is not started.
|
||||||
|
pub async fn set_audio_debug_wav_dump(&self, enabled: bool) -> Result<(), CoreError> {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
if let Some(state) = guard.as_ref() {
|
||||||
|
if let Some(audio) = state.audio.as_ref() {
|
||||||
|
let mut config = audio.audio_processing_config_snapshot();
|
||||||
|
config.debug_wav_dump_enabled = enabled;
|
||||||
|
audio.set_audio_processing_config(config)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS media-services-reset hook (SDD-101). Rebuilds the audio
|
||||||
|
/// unit using the supplied route so the processing policy is
|
||||||
|
/// correct after the OS-level media reset.
|
||||||
|
pub async fn ios_handle_media_services_reset(
|
||||||
|
&self,
|
||||||
|
route: AudioRoute,
|
||||||
|
) -> Result<(), CoreError> {
|
||||||
|
self.ios_handle_route_change(route).await
|
||||||
|
}
|
||||||
|
|
||||||
/// iOS interruption-began hook (SDD-101). No-op when audio is
|
/// iOS interruption-began hook (SDD-101). No-op when audio is
|
||||||
/// not running.
|
/// not running.
|
||||||
pub async fn ios_handle_interruption_began(&self) -> Result<(), CoreError> {
|
pub async fn ios_handle_interruption_began(&self) -> Result<(), CoreError> {
|
||||||
@@ -1519,6 +1588,7 @@ const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
|||||||
/// declares the connection lost.
|
/// declares the connection lost.
|
||||||
const WATCHDOG_MAX_MISSES: u32 = 3;
|
const WATCHDOG_MAX_MISSES: u32 = 3;
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn supervisor_loop(
|
async fn supervisor_loop(
|
||||||
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
||||||
events_tx: broadcast::Sender<SessionEvent>,
|
events_tx: broadcast::Sender<SessionEvent>,
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ reqwest = { version = "0.13", default-features = false, features = ["charset", "
|
|||||||
# Default features keep `audio_toolbox` + `core_audio`, both required
|
# Default features keep `audio_toolbox` + `core_audio`, both required
|
||||||
# for AudioUnit construction + property access.
|
# for AudioUnit construction + property access.
|
||||||
coreaudio-rs = "0.14"
|
coreaudio-rs = "0.14"
|
||||||
|
# Grand Central Dispatch bindings — used to run AudioUnit initialize/start
|
||||||
|
# on the main queue to avoid the VPIO RPC timeout on iOS simulator.
|
||||||
|
dispatch2 = "0.3"
|
||||||
|
# ndarray is required by ort's tensor construction API.
|
||||||
|
ndarray = "0.16"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "ios")'.dependencies]
|
||||||
|
# ONNX Runtime Rust binding for Silero VAD v6 (P1 VAD_002). The official
|
||||||
|
# iOS CocoaPod ships ONNX Runtime as a static framework, so iOS links it
|
||||||
|
# into chanora_bridge at build time instead of loading a dylib at runtime.
|
||||||
|
ort = { version = "2.0.0-rc.10", default-features = false, features = ["std", "ndarray"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
# macOS keeps dynamic loading so developer machines can provide ORT via
|
||||||
|
# ORT_DYLIB_PATH without forcing a bundled runtime into desktop builds.
|
||||||
|
ort = { version = "2.0.0-rc.10", default-features = false, features = ["load-dynamic", "ndarray"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
|
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ mod common;
|
|||||||
use common::{synthetic_opus_bytes, synthetic_opus_frame};
|
use common::{synthetic_opus_bytes, synthetic_opus_frame};
|
||||||
|
|
||||||
fn bench_opus_encode_latency(c: &mut Criterion) {
|
fn bench_opus_encode_latency(c: &mut Criterion) {
|
||||||
let mut enc = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
let enc = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
||||||
.expect("opus encoder init");
|
.expect("opus encoder init");
|
||||||
let pcm = synthetic_opus_frame();
|
let pcm = synthetic_opus_frame();
|
||||||
let mut out = vec![0u8; 1275];
|
let mut out = vec![0u8; 1275];
|
||||||
|
|||||||
@@ -15,15 +15,24 @@
|
|||||||
// builds and other benches are unaffected per Cargo's per-bench
|
// builds and other benches are unaffected per Cargo's per-bench
|
||||||
// compilation model.
|
// compilation model.
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
||||||
|
fn main() {}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
#[global_allocator]
|
#[global_allocator]
|
||||||
static ALLOC: dhat::Alloc = dhat::Alloc;
|
static ALLOC: dhat::Alloc = dhat::Alloc;
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
use chanora_audio::bench_seam::CaptureBenchHandle;
|
use chanora_audio::bench_seam::CaptureBenchHandle;
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
mod common;
|
mod common;
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
use common::{synthetic_capture_buffer, FRAME_SAMPLES};
|
use common::{synthetic_capture_buffer, FRAME_SAMPLES};
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
fn bench_capture_alloc_count(c: &mut Criterion) {
|
fn bench_capture_alloc_count(c: &mut Criterion) {
|
||||||
// Build the dhat profiler in test mode so it is process-local
|
// Build the dhat profiler in test mode so it is process-local
|
||||||
// and does not write a JSON heap-dump file. Held for the
|
// and does not write a JSON heap-dump file. Held for the
|
||||||
@@ -90,6 +99,7 @@ fn bench_capture_alloc_count(c: &mut Criterion) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
fn bench_capture_callback_wall_clock(c: &mut Criterion) {
|
fn bench_capture_callback_wall_clock(c: &mut Criterion) {
|
||||||
let mut handle = CaptureBenchHandle::new(48_000, 1);
|
let mut handle = CaptureBenchHandle::new(48_000, 1);
|
||||||
let buf = synthetic_capture_buffer(FRAME_SAMPLES, 1);
|
let buf = synthetic_capture_buffer(FRAME_SAMPLES, 1);
|
||||||
@@ -108,9 +118,11 @@ fn bench_capture_callback_wall_clock(c: &mut Criterion) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
criterion_group!(
|
criterion_group!(
|
||||||
realtime_capture,
|
realtime_capture,
|
||||||
bench_capture_alloc_count,
|
bench_capture_alloc_count,
|
||||||
bench_capture_callback_wall_clock
|
bench_capture_callback_wall_clock
|
||||||
);
|
);
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
criterion_main!(realtime_capture);
|
criterion_main!(realtime_capture);
|
||||||
|
|||||||
@@ -8,12 +8,20 @@
|
|||||||
// feeding a 1-second buffer per iteration. Throughput is reported
|
// feeding a 1-second buffer per iteration. Throughput is reported
|
||||||
// as samples/sec via criterion's `Throughput::Elements`.
|
// as samples/sec via criterion's `Throughput::Elements`.
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
|
||||||
|
fn main() {}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
use chanora_audio::bench_seam::CaptureBenchHandle;
|
use chanora_audio::bench_seam::CaptureBenchHandle;
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput};
|
use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput};
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
mod common;
|
mod common;
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
use common::synthetic_capture_buffer;
|
use common::synthetic_capture_buffer;
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
fn bench_resampler_throughput(c: &mut Criterion) {
|
fn bench_resampler_throughput(c: &mut Criterion) {
|
||||||
let mut group = c.benchmark_group("resampler_throughput");
|
let mut group = c.benchmark_group("resampler_throughput");
|
||||||
|
|
||||||
@@ -39,5 +47,7 @@ fn bench_resampler_throughput(c: &mut Criterion) {
|
|||||||
group.finish();
|
group.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
criterion_group!(resampler, bench_resampler_throughput);
|
criterion_group!(resampler, bench_resampler_throughput);
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
criterion_main!(resampler);
|
criterion_main!(resampler);
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use audiopus::coder::Encoder as OpusEncoder;
|
use audiopus::coder::Encoder as OpusEncoder;
|
||||||
use audiopus::{Application as OpusApp, Channels as OpusChannels, SampleRate as OpusSampleRate};
|
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::mobile_voice_backend::{
|
use crate::mobile_voice_backend::{
|
||||||
@@ -53,7 +52,7 @@ use crate::mobile_voice_backend::{
|
|||||||
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
|
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
|
||||||
SharingModeChoice,
|
SharingModeChoice,
|
||||||
};
|
};
|
||||||
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
use chanora_protocol::OutPacket;
|
||||||
use tsclientlib::audio::AudioHandler;
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
|
||||||
use crate::{engine::SessionAudioId, AudioError};
|
use crate::{engine::SessionAudioId, AudioError};
|
||||||
@@ -71,19 +70,11 @@ use oboe::{
|
|||||||
// `mobile_voice_backend` so the trait can expose `take_event_rx`
|
// `mobile_voice_backend` so the trait can expose `take_event_rx`
|
||||||
// (SDD-111 item 1) cross-platform.
|
// (SDD-111 item 1) cross-platform.
|
||||||
|
|
||||||
/// 20 ms at 48 kHz mono — one Opus frame's worth of samples.
|
|
||||||
/// Matches the iOS and desktop constants; duplicated here so this
|
|
||||||
/// module is fully self-contained and cfg-gate-clean.
|
|
||||||
const FRAME_SAMPLES: usize = 960;
|
|
||||||
|
|
||||||
/// Maximum size of an encoded Opus frame in bytes (RFC 6716 §3.2.1).
|
|
||||||
const MAX_OPUS_FRAME: usize = 1275;
|
|
||||||
|
|
||||||
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
|
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
|
||||||
//
|
//
|
||||||
// Mirrors the iOS `IosCaptureState` and the cpal-side `CaptureState`.
|
// Mirrors the iOS `IosCaptureState` and the cpal-side `CaptureState`.
|
||||||
// Oboe delivers 48 kHz mono i16 PCM; we apply mic gain, accumulate to
|
// Oboe delivers 48 kHz mono i16 PCM; we apply mic gain, accumulate to
|
||||||
// FRAME_SAMPLES, encode to Opus 32 kbps (complexity 10, inband FEC, 5 % PLC),
|
// FRAME_20MS_SAMPLES, encode to Opus 32 kbps (complexity 10, inband FEC, 5 % PLC),
|
||||||
// and try-send the resulting packet on `voice_out_tx`.
|
// and try-send the resulting packet on `voice_out_tx`.
|
||||||
|
|
||||||
struct AndroidCaptureState {
|
struct AndroidCaptureState {
|
||||||
@@ -91,7 +82,7 @@ struct AndroidCaptureState {
|
|||||||
/// Accumulator for 48 kHz mono PCM. 2x capacity to absorb
|
/// Accumulator for 48 kHz mono PCM. 2x capacity to absorb
|
||||||
/// cpal-style buffer-size jitter without reallocating.
|
/// cpal-style buffer-size jitter without reallocating.
|
||||||
pcm_accum: Vec<i16>,
|
pcm_accum: Vec<i16>,
|
||||||
opus_out: [u8; MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
@@ -105,33 +96,11 @@ impl AndroidCaptureState {
|
|||||||
frames_sent: Arc<AtomicU32>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
) -> Result<Self, AudioError> {
|
) -> Result<Self, AudioError> {
|
||||||
let mut encoder =
|
let encoder = crate::opus_voice::new_voip_encoder("android")?;
|
||||||
OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
|
|
||||||
.map_err(|e| AudioError::Opus(format!("encoder new (android): {e}")))?;
|
|
||||||
if let Err(e) = encoder.set_bitrate(audiopus::Bitrate::BitsPerSecond(32_000)) {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "opus(android): set_bitrate(32000) failed");
|
|
||||||
}
|
|
||||||
if let Err(e) = encoder.set_complexity(10) {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "opus(android): set_complexity(10) failed");
|
|
||||||
}
|
|
||||||
if let Err(e) = encoder.set_inband_fec(true) {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "opus(android): set_inband_fec(true) failed");
|
|
||||||
}
|
|
||||||
if let Err(e) = encoder.set_packet_loss_perc(5) {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "opus(android): set_packet_loss_perc(5) failed");
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
bitrate_bps = 32_000,
|
|
||||||
complexity = 10,
|
|
||||||
inband_fec = true,
|
|
||||||
packet_loss_perc = 5,
|
|
||||||
"android Oboe opus encoder tuned for VoIP"
|
|
||||||
);
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
encoder,
|
encoder,
|
||||||
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
|
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||||
opus_out: [0u8; MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx,
|
voice_out_tx,
|
||||||
transmit_active,
|
transmit_active,
|
||||||
frames_sent,
|
frames_sent,
|
||||||
@@ -139,7 +108,7 @@ impl AndroidCaptureState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume i16 mono frames from Oboe, accumulate to FRAME_SAMPLES,
|
/// Consume i16 mono frames from Oboe, accumulate to FRAME_20MS_SAMPLES,
|
||||||
/// encode + send when PTT is held. Oboe delivers at the device's
|
/// encode + send when PTT is held. Oboe delivers at the device's
|
||||||
/// native sample rate (always 48 kHz for modern Android per SRS-210),
|
/// native sample rate (always 48 kHz for modern Android per SRS-210),
|
||||||
/// so no resampling is needed.
|
/// so no resampling is needed.
|
||||||
@@ -159,28 +128,30 @@ impl AndroidCaptureState {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// Drain complete 20 ms frames.
|
// Drain complete 20 ms frames.
|
||||||
while self.pcm_accum.len() >= FRAME_SAMPLES {
|
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||||
let mut frame = [0i16; FRAME_SAMPLES];
|
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||||
frame.copy_from_slice(&self.pcm_accum[..FRAME_SAMPLES]);
|
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||||
self.pcm_accum.drain(..FRAME_SAMPLES);
|
self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
|
||||||
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
|
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
|
||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
let packet = OutAudio::new(&AudioData::C2S {
|
crate::opus_voice::send_voip_frame(
|
||||||
id: 0,
|
&self.voice_out_tx,
|
||||||
codec: CodecType::OpusVoice,
|
&self.frames_sent,
|
||||||
data: &self.opus_out[..len],
|
&self.opus_out,
|
||||||
});
|
len,
|
||||||
match self.voice_out_tx.try_send(packet) {
|
|| {
|
||||||
Ok(()) => {
|
warn!(
|
||||||
self.frames_sent.fetch_add(1, Ordering::Relaxed);
|
target: "chanora_audio",
|
||||||
}
|
"android Oboe: voice_out queue full; dropping frame"
|
||||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
);
|
||||||
warn!(target: "chanora_audio", "android Oboe: voice_out queue full; dropping frame");
|
},
|
||||||
}
|
|| {
|
||||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
debug!(
|
||||||
debug!(target: "chanora_audio", "android Oboe: voice_out closed; capture pipeline stopping");
|
target: "chanora_audio",
|
||||||
}
|
"android Oboe: voice_out closed; capture pipeline stopping"
|
||||||
}
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(target: "chanora_audio", error = %e, "android Oboe opus encode failed");
|
warn!(target: "chanora_audio", error = %e, "android Oboe opus encode failed");
|
||||||
@@ -266,22 +237,12 @@ impl AudioOutputCallback for OutputCallback {
|
|||||||
}
|
}
|
||||||
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
||||||
let muted = self.output_muted.load(Ordering::Relaxed);
|
let muted = self.output_muted.load(Ordering::Relaxed);
|
||||||
let mut peak: i16 = 0;
|
let _ = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
||||||
for (i, dst) in frames.iter_mut().enumerate() {
|
&scratch[..needed],
|
||||||
if muted {
|
frames,
|
||||||
*dst = 0;
|
gain,
|
||||||
continue;
|
muted,
|
||||||
}
|
);
|
||||||
let l = scratch[i * 2];
|
|
||||||
let r = scratch[i * 2 + 1];
|
|
||||||
let mono = (l + r) * 0.5 * gain;
|
|
||||||
let clamped = mono.clamp(-1.0, 1.0);
|
|
||||||
let sample = (clamped * i16::MAX as f32) as i16;
|
|
||||||
*dst = sample;
|
|
||||||
if sample.unsigned_abs() > peak.unsigned_abs() {
|
|
||||||
peak = sample;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
DataCallbackResult::Continue
|
DataCallbackResult::Continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
//! P1 audio-processing configuration and statistics.
|
||||||
|
//!
|
||||||
|
//! iOS P1 ships the platform VoiceProcessingIO path by default. Rust
|
||||||
|
//! software AEC/NS/AGC backends are represented in the schema so the
|
||||||
|
//! bridge can reject unsafe combinations instead of silently enabling
|
||||||
|
//! double processing.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use crate::AudioError;
|
||||||
|
|
||||||
|
/// Physical/logical audio route class used for route-aware policy.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AudioRoute {
|
||||||
|
/// Built-in speakerphone path.
|
||||||
|
Speaker,
|
||||||
|
/// Built-in receiver/earpiece path.
|
||||||
|
Earpiece,
|
||||||
|
/// Wired headset or USB headset.
|
||||||
|
WiredHeadset,
|
||||||
|
/// Bluetooth Hands-Free Profile duplex route.
|
||||||
|
BluetoothHfp,
|
||||||
|
/// Bluetooth A2DP output-only route.
|
||||||
|
BluetoothA2dp,
|
||||||
|
/// Route could not be classified yet.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioRoute {
|
||||||
|
/// Stable bridge/debug string.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Speaker => "speaker",
|
||||||
|
Self::Earpiece => "earpiece",
|
||||||
|
Self::WiredHeadset => "wired_headset",
|
||||||
|
Self::BluetoothHfp => "bluetooth_hfp",
|
||||||
|
Self::BluetoothA2dp => "bluetooth_a2dp",
|
||||||
|
Self::Unknown => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from the Swift-side route class string (case-insensitive).
|
||||||
|
/// Unrecognised strings map to `Unknown`.
|
||||||
|
pub fn from_route_class(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"Speaker" | "speaker" => Self::Speaker,
|
||||||
|
"Earpiece" | "earpiece" => Self::Earpiece,
|
||||||
|
"WiredHeadset" | "wired_headset" => Self::WiredHeadset,
|
||||||
|
"BluetoothHfp" | "bluetooth_hfp" => Self::BluetoothHfp,
|
||||||
|
"BluetoothA2dp" | "bluetooth_a2dp" => Self::BluetoothA2dp,
|
||||||
|
_ => Self::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS voice-processing mode.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum IosVoiceProcessingMode {
|
||||||
|
/// Shipping default: Apple VoiceProcessingIO owns AEC/NS/AGC.
|
||||||
|
PlatformVoiceProcessing,
|
||||||
|
/// Experimental Sonora capture-processing path.
|
||||||
|
SonoraExperimental,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processing backend selected by policy/config.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AudioBackend {
|
||||||
|
/// Platform voice processing, VPIO on iOS.
|
||||||
|
PlatformVoiceProcessing,
|
||||||
|
/// Rust-native Sonora backend.
|
||||||
|
Sonora,
|
||||||
|
/// Future WebRTC APM backend.
|
||||||
|
WebrtcApm,
|
||||||
|
/// No processing.
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioBackend {
|
||||||
|
/// Stable bridge/debug string.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::PlatformVoiceProcessing => "platform_voice_processing",
|
||||||
|
Self::Sonora => "sonora",
|
||||||
|
Self::WebrtcApm => "webrtc_apm",
|
||||||
|
Self::Noop => "noop",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VAD backend selected by policy/config.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VadBackend {
|
||||||
|
/// Silero ONNX VAD. P1 schema default when model/runtime exist.
|
||||||
|
SileroOnnx,
|
||||||
|
/// TEN VAD backend. Native TEN runtime is optional; unavailable
|
||||||
|
/// builds fall back to the realtime-safe WebRTC detector.
|
||||||
|
TenVad,
|
||||||
|
/// WebRTC-style fallback VAD.
|
||||||
|
WebrtcVad,
|
||||||
|
/// Debug-only energy VAD.
|
||||||
|
EnergyDebug,
|
||||||
|
/// VAD disabled.
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VadBackend {
|
||||||
|
/// Stable bridge/debug string.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::SileroOnnx => "silero_vad_onnx",
|
||||||
|
Self::TenVad => "ten_vad",
|
||||||
|
Self::WebrtcVad => "webrtc_vad",
|
||||||
|
Self::EnergyDebug => "energy_debug",
|
||||||
|
Self::Disabled => "disabled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effect owner for AEC/NS/AGC policy fields.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EffectOwner {
|
||||||
|
/// Owned by platform voice processing.
|
||||||
|
Platform,
|
||||||
|
/// Owned by Sonora.
|
||||||
|
Sonora,
|
||||||
|
/// Owned by WebRTC APM.
|
||||||
|
WebrtcApm,
|
||||||
|
/// Conservative route-managed setting.
|
||||||
|
Conservative,
|
||||||
|
/// Disabled.
|
||||||
|
Off,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Voice-processing configuration owned by the Rust audio engine.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct AudioProcessingConfig {
|
||||||
|
/// Current route class.
|
||||||
|
pub route: AudioRoute,
|
||||||
|
/// iOS processing mode.
|
||||||
|
pub ios_mode: IosVoiceProcessingMode,
|
||||||
|
/// Processing backend.
|
||||||
|
pub processing_backend: AudioBackend,
|
||||||
|
/// VAD backend.
|
||||||
|
pub vad_backend: VadBackend,
|
||||||
|
/// AEC owner.
|
||||||
|
pub aec: EffectOwner,
|
||||||
|
/// Noise suppression owner.
|
||||||
|
pub ns: EffectOwner,
|
||||||
|
/// AGC owner.
|
||||||
|
pub agc: EffectOwner,
|
||||||
|
/// High-pass filter enabled.
|
||||||
|
pub hpf_enabled: bool,
|
||||||
|
/// Limiter enabled.
|
||||||
|
pub limiter_enabled: bool,
|
||||||
|
/// Hangover after speech closes.
|
||||||
|
pub vad_hangover_ms: u32,
|
||||||
|
/// Pre-roll before open.
|
||||||
|
pub vad_pre_roll_ms: u32,
|
||||||
|
/// Minimum transmit duration after open.
|
||||||
|
pub vad_min_tx_ms: u32,
|
||||||
|
/// Debug WAV dumps enabled.
|
||||||
|
pub debug_wav_dump_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioProcessingConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
route: AudioRoute::Speaker,
|
||||||
|
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||||
|
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
||||||
|
vad_backend: VadBackend::SileroOnnx,
|
||||||
|
aec: EffectOwner::Platform,
|
||||||
|
// iOS VPIO owns NS/AGC on the default shipping path. Rust/Sonora
|
||||||
|
// effects are opt-in through the experimental raw route only.
|
||||||
|
ns: EffectOwner::Platform,
|
||||||
|
agc: EffectOwner::Platform,
|
||||||
|
hpf_enabled: true,
|
||||||
|
limiter_enabled: true,
|
||||||
|
vad_hangover_ms: crate::voice_activity::VAD_HANGOVER_MS,
|
||||||
|
vad_pre_roll_ms: 160,
|
||||||
|
vad_min_tx_ms: crate::voice_activity::VAD_MIN_TX_MS,
|
||||||
|
debug_wav_dump_enabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioProcessingConfig {
|
||||||
|
/// Validate P1 iOS invariants before applying a config.
|
||||||
|
pub fn validate_for_ios(&self) -> Result<(), AudioError> {
|
||||||
|
if self.route == AudioRoute::BluetoothA2dp {
|
||||||
|
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||||
|
"bluetooth_a2dp is output-only and cannot transmit duplex voice".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.ios_mode == IosVoiceProcessingMode::PlatformVoiceProcessing
|
||||||
|
&& (self.processing_backend == AudioBackend::Sonora
|
||||||
|
|| self.aec == EffectOwner::Sonora
|
||||||
|
|| self.ns == EffectOwner::Sonora
|
||||||
|
|| self.agc == EffectOwner::Sonora)
|
||||||
|
{
|
||||||
|
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||||
|
"Sonora cannot be enabled with iOS VoiceProcessingIO".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental {
|
||||||
|
if self.processing_backend != AudioBackend::Sonora {
|
||||||
|
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||||
|
"ios Sonora experimental mode requires the Sonora processing backend"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Demote a failed VAD backend to the WebRTC fallback.
|
||||||
|
///
|
||||||
|
/// Returns `true` when the config changed.
|
||||||
|
pub fn disable_failed_vad_backend(&mut self, failed_backend: VadBackend) -> bool {
|
||||||
|
if self.vad_backend == failed_backend && failed_backend != VadBackend::WebrtcVad {
|
||||||
|
self.vad_backend = VadBackend::WebrtcVad;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_is_valid_for_ios_vpio() {
|
||||||
|
let config = AudioProcessingConfig::default();
|
||||||
|
|
||||||
|
assert!(config.validate_for_ios().is_ok());
|
||||||
|
assert_eq!(
|
||||||
|
config.processing_backend,
|
||||||
|
AudioBackend::PlatformVoiceProcessing
|
||||||
|
);
|
||||||
|
assert_eq!(config.aec, EffectOwner::Platform);
|
||||||
|
assert_eq!(config.ns, EffectOwner::Platform);
|
||||||
|
assert_eq!(config.agc, EffectOwner::Platform);
|
||||||
|
assert_eq!(config.vad_backend, VadBackend::SileroOnnx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_voice_processing_rejects_sonora_effects() {
|
||||||
|
let config = AudioProcessingConfig {
|
||||||
|
ns: EffectOwner::Sonora,
|
||||||
|
..AudioProcessingConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(config.validate_for_ios().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ten_vad_has_stable_debug_string() {
|
||||||
|
assert_eq!(VadBackend::TenVad.as_str(), "ten_vad");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sonora_experimental_allows_full_sonora_chain() {
|
||||||
|
let config = AudioProcessingConfig {
|
||||||
|
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
|
||||||
|
processing_backend: AudioBackend::Sonora,
|
||||||
|
aec: EffectOwner::Sonora,
|
||||||
|
ns: EffectOwner::Sonora,
|
||||||
|
agc: EffectOwner::Sonora,
|
||||||
|
..AudioProcessingConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(config.validate_for_ios().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sonora_experimental_rejects_non_sonora_backend() {
|
||||||
|
let config = AudioProcessingConfig {
|
||||||
|
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
|
||||||
|
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
||||||
|
aec: EffectOwner::Sonora,
|
||||||
|
ns: EffectOwner::Sonora,
|
||||||
|
agc: EffectOwner::Sonora,
|
||||||
|
..AudioProcessingConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(config.validate_for_ios().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disable_failed_vad_backend_demotes_to_webrtc() {
|
||||||
|
let mut config = AudioProcessingConfig {
|
||||||
|
vad_backend: VadBackend::SileroOnnx,
|
||||||
|
..AudioProcessingConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(config.disable_failed_vad_backend(VadBackend::SileroOnnx));
|
||||||
|
assert_eq!(config.vad_backend, VadBackend::WebrtcVad);
|
||||||
|
assert!(!config.disable_failed_vad_backend(VadBackend::SileroOnnx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime audio processing stats exposed to bridge/UI diagnostics.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AudioProcessingStats {
|
||||||
|
/// Input dBFS before processing.
|
||||||
|
pub input_dbfs: f32,
|
||||||
|
/// Render dBFS before playout.
|
||||||
|
pub render_dbfs: f32,
|
||||||
|
/// Processed capture dBFS.
|
||||||
|
pub processed_dbfs: f32,
|
||||||
|
/// Latest VAD probability or fallback confidence.
|
||||||
|
pub vad_probability: f32,
|
||||||
|
/// VAD active state.
|
||||||
|
pub vad_active: bool,
|
||||||
|
/// Current resolved transmit state.
|
||||||
|
pub transmitting: bool,
|
||||||
|
/// VAD backend.
|
||||||
|
pub vad_backend: VadBackend,
|
||||||
|
/// Whether fallback VAD is active.
|
||||||
|
pub vad_fallback_active: bool,
|
||||||
|
/// Processing backend.
|
||||||
|
pub processing_backend: AudioBackend,
|
||||||
|
/// iOS mode.
|
||||||
|
pub ios_voice_processing_mode: IosVoiceProcessingMode,
|
||||||
|
/// Route class.
|
||||||
|
pub audio_route: AudioRoute,
|
||||||
|
/// Actual sample rate.
|
||||||
|
pub actual_sample_rate_hz: u32,
|
||||||
|
/// Actual IO buffer frame count.
|
||||||
|
pub actual_io_buffer_frames: u32,
|
||||||
|
/// Input overrun count.
|
||||||
|
pub input_overruns: u64,
|
||||||
|
/// Output underrun count.
|
||||||
|
pub output_underruns: u64,
|
||||||
|
/// Callback xrun count.
|
||||||
|
pub callback_xruns: u64,
|
||||||
|
/// Clipped sample count.
|
||||||
|
pub clipped_samples: u64,
|
||||||
|
/// Sonora enabled.
|
||||||
|
pub sonora_enabled: bool,
|
||||||
|
/// Platform voice processing enabled.
|
||||||
|
pub platform_voice_processing_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock-free stats storage shared with callbacks.
|
||||||
|
pub struct SharedAudioProcessingStats {
|
||||||
|
input_dbfs: AtomicU32,
|
||||||
|
render_dbfs: AtomicU32,
|
||||||
|
processed_dbfs: AtomicU32,
|
||||||
|
vad_probability: AtomicU32,
|
||||||
|
vad_active: AtomicBool,
|
||||||
|
transmitting: AtomicBool,
|
||||||
|
vad_fallback_active: AtomicBool,
|
||||||
|
input_overruns: AtomicU64,
|
||||||
|
output_underruns: AtomicU64,
|
||||||
|
callback_xruns: AtomicU64,
|
||||||
|
clipped_samples: AtomicU64,
|
||||||
|
actual_sample_rate_hz: AtomicU32,
|
||||||
|
actual_io_buffer_frames: AtomicU32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SharedAudioProcessingStats {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
input_dbfs: AtomicU32::new((-120.0_f32).to_bits()),
|
||||||
|
render_dbfs: AtomicU32::new((-120.0_f32).to_bits()),
|
||||||
|
processed_dbfs: AtomicU32::new((-120.0_f32).to_bits()),
|
||||||
|
vad_probability: AtomicU32::new(0.0_f32.to_bits()),
|
||||||
|
vad_active: AtomicBool::new(false),
|
||||||
|
transmitting: AtomicBool::new(false),
|
||||||
|
vad_fallback_active: AtomicBool::new(false),
|
||||||
|
input_overruns: AtomicU64::new(0),
|
||||||
|
output_underruns: AtomicU64::new(0),
|
||||||
|
callback_xruns: AtomicU64::new(0),
|
||||||
|
clipped_samples: AtomicU64::new(0),
|
||||||
|
actual_sample_rate_hz: AtomicU32::new(crate::frame::SAMPLE_RATE_HZ),
|
||||||
|
actual_io_buffer_frames: AtomicU32::new(crate::frame::FRAME_20MS_SAMPLES as u32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedAudioProcessingStats {
|
||||||
|
/// Store capture levels and VAD state.
|
||||||
|
pub fn update_capture(
|
||||||
|
&self,
|
||||||
|
input_dbfs: f32,
|
||||||
|
processed_dbfs: f32,
|
||||||
|
probability: f32,
|
||||||
|
vad_active: bool,
|
||||||
|
transmitting: bool,
|
||||||
|
) {
|
||||||
|
self.input_dbfs
|
||||||
|
.store(input_dbfs.to_bits(), Ordering::Relaxed);
|
||||||
|
self.processed_dbfs
|
||||||
|
.store(processed_dbfs.to_bits(), Ordering::Relaxed);
|
||||||
|
self.vad_probability
|
||||||
|
.store(probability.clamp(0.0, 1.0).to_bits(), Ordering::Relaxed);
|
||||||
|
self.vad_active.store(vad_active, Ordering::Relaxed);
|
||||||
|
self.transmitting.store(transmitting, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store render level and callback buffer size.
|
||||||
|
pub fn update_render(&self, dbfs: f32, io_buffer_frames: u32) {
|
||||||
|
self.render_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
|
||||||
|
self.actual_io_buffer_frames
|
||||||
|
.store(io_buffer_frames, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increment output underrun count.
|
||||||
|
pub fn increment_output_underrun(&self) {
|
||||||
|
self.output_underruns.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increment callback xrun count.
|
||||||
|
pub fn increment_callback_xrun(&self) {
|
||||||
|
self.callback_xruns.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add clipped samples.
|
||||||
|
pub fn add_clipped_samples(&self, count: u64) {
|
||||||
|
self.clipped_samples.fetch_add(count, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store whether the selected VAD backend is currently using a fallback.
|
||||||
|
pub fn set_vad_fallback_active(&self, active: bool) {
|
||||||
|
self.vad_fallback_active.store(active, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an owned stats snapshot with config-derived labels.
|
||||||
|
pub fn snapshot(&self, config: &AudioProcessingConfig) -> AudioProcessingStats {
|
||||||
|
AudioProcessingStats {
|
||||||
|
input_dbfs: f32::from_bits(self.input_dbfs.load(Ordering::Relaxed)),
|
||||||
|
render_dbfs: f32::from_bits(self.render_dbfs.load(Ordering::Relaxed)),
|
||||||
|
processed_dbfs: f32::from_bits(self.processed_dbfs.load(Ordering::Relaxed)),
|
||||||
|
vad_probability: f32::from_bits(self.vad_probability.load(Ordering::Relaxed)),
|
||||||
|
vad_active: self.vad_active.load(Ordering::Relaxed),
|
||||||
|
transmitting: self.transmitting.load(Ordering::Relaxed),
|
||||||
|
vad_backend: config.vad_backend,
|
||||||
|
vad_fallback_active: self.vad_fallback_active.load(Ordering::Relaxed),
|
||||||
|
processing_backend: config.processing_backend,
|
||||||
|
ios_voice_processing_mode: config.ios_mode,
|
||||||
|
audio_route: config.route,
|
||||||
|
actual_sample_rate_hz: self.actual_sample_rate_hz.load(Ordering::Relaxed),
|
||||||
|
actual_io_buffer_frames: self.actual_io_buffer_frames.load(Ordering::Relaxed),
|
||||||
|
input_overruns: self.input_overruns.load(Ordering::Relaxed),
|
||||||
|
output_underruns: self.output_underruns.load(Ordering::Relaxed),
|
||||||
|
callback_xruns: self.callback_xruns.load(Ordering::Relaxed),
|
||||||
|
clipped_samples: self.clipped_samples.load(Ordering::Relaxed),
|
||||||
|
sonora_enabled: config.processing_backend == AudioBackend::Sonora,
|
||||||
|
platform_voice_processing_enabled: config.processing_backend
|
||||||
|
== AudioBackend::PlatformVoiceProcessing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
//! Async WAV debug dump writer for P1 diagnostics.
|
||||||
|
//!
|
||||||
|
//! Captures three streams for offline analysis:
|
||||||
|
//! * `raw_mic` — before AudioProcessor (INV_007: never from callback)
|
||||||
|
//! * `render_reference` — remote mixer output before playout
|
||||||
|
//! * `processed_mic` — after AudioProcessor
|
||||||
|
//!
|
||||||
|
//! ## Design
|
||||||
|
//!
|
||||||
|
//! The realtime callback MUST NOT write to disk (INV_007). Instead it
|
||||||
|
//! pushes 10 ms f32 frames onto a bounded `std::sync::mpsc` channel.
|
||||||
|
//! A background `tokio::task` drains the channel and writes WAV data.
|
||||||
|
//!
|
||||||
|
//! The channel is bounded (capacity = 500 frames ≈ 5 s of audio per
|
||||||
|
//! stream). If the writer falls behind, frames are dropped rather than
|
||||||
|
//! blocking the callback thread.
|
||||||
|
//!
|
||||||
|
//! WAV files are written to the OS temp directory with a filename that
|
||||||
|
//! encodes the stream name, route, backend, and a timestamp so
|
||||||
|
//! multiple sessions don't overwrite each other.
|
||||||
|
//!
|
||||||
|
//! ## Usage
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! let writer = WavDebugRecorder::start(route, backend);
|
||||||
|
//! // In realtime callback (non-blocking):
|
||||||
|
//! writer.push_raw_mic(&frame);
|
||||||
|
//! writer.push_render_reference(&frame);
|
||||||
|
//! writer.push_processed_mic(&frame);
|
||||||
|
//! // On session end:
|
||||||
|
//! writer.stop(); // flushes and closes files
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::audio_processing::{AudioBackend, AudioRoute};
|
||||||
|
use crate::frame::FRAME_10MS_SAMPLES;
|
||||||
|
|
||||||
|
/// Maximum number of 10 ms frames buffered per stream before drops.
|
||||||
|
const CHANNEL_CAPACITY: usize = 500;
|
||||||
|
|
||||||
|
/// Sample rate for WAV output (matches the capture pipeline).
|
||||||
|
const WAV_SAMPLE_RATE: u32 = 48_000;
|
||||||
|
|
||||||
|
/// Identifies which debug stream a frame belongs to.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum StreamId {
|
||||||
|
RawMic,
|
||||||
|
RenderReference,
|
||||||
|
ProcessedMic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single 10 ms frame tagged with its stream.
|
||||||
|
struct DebugFrame {
|
||||||
|
stream: StreamId,
|
||||||
|
samples: Box<[f32; FRAME_10MS_SAMPLES]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle for pushing frames from the realtime callback.
|
||||||
|
///
|
||||||
|
/// All push methods are non-blocking: if the channel is full the
|
||||||
|
/// frame is silently dropped and a counter is incremented.
|
||||||
|
pub struct WavDebugRecorder {
|
||||||
|
tx: mpsc::SyncSender<DebugFrame>,
|
||||||
|
/// Frames dropped due to full channel (diagnostic only).
|
||||||
|
drops: std::sync::atomic::AtomicU64,
|
||||||
|
/// Whether the recorder is active (set to false on stop).
|
||||||
|
active: std::sync::atomic::AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WavDebugRecorder {
|
||||||
|
/// Start the async WAV writer task. Returns a handle for pushing
|
||||||
|
/// frames from the realtime callback.
|
||||||
|
///
|
||||||
|
/// `route` and `backend` are embedded in the output filenames.
|
||||||
|
pub fn start(route: AudioRoute, backend: AudioBackend) -> std::sync::Arc<Self> {
|
||||||
|
let (tx, rx) = mpsc::sync_channel::<DebugFrame>(CHANNEL_CAPACITY);
|
||||||
|
let recorder = std::sync::Arc::new(Self {
|
||||||
|
tx,
|
||||||
|
drops: std::sync::atomic::AtomicU64::new(0),
|
||||||
|
active: std::sync::atomic::AtomicBool::new(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
let ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let route_str = route.as_str().to_string();
|
||||||
|
let backend_str = backend.as_str().to_string();
|
||||||
|
|
||||||
|
// Spawn a blocking task so the WAV writer doesn't compete
|
||||||
|
// with the tokio async executor for CPU time.
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("chanora-wav-writer".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
wav_writer_task(rx, &route_str, &backend_str, ts);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a raw mic frame (before AudioProcessor). Non-blocking.
|
||||||
|
pub fn push_raw_mic(&self, samples: &[f32; FRAME_10MS_SAMPLES]) {
|
||||||
|
self.push(StreamId::RawMic, samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a render-reference frame (remote mixer output before playout).
|
||||||
|
/// Non-blocking.
|
||||||
|
pub fn push_render_reference(&self, samples: &[f32; FRAME_10MS_SAMPLES]) {
|
||||||
|
self.push(StreamId::RenderReference, samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a processed mic frame (after AudioProcessor). Non-blocking.
|
||||||
|
pub fn push_processed_mic(&self, samples: &[f32; FRAME_10MS_SAMPLES]) {
|
||||||
|
self.push(StreamId::ProcessedMic, samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the recorder. Drops the sender so the writer task drains
|
||||||
|
/// and closes the WAV files.
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.active
|
||||||
|
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
// The sender is not dropped here because `self` is behind Arc.
|
||||||
|
// The writer task will exit when all senders are dropped (i.e.
|
||||||
|
// when the Arc is dropped). This is intentional: the task
|
||||||
|
// drains any remaining frames before closing files.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of frames dropped due to a full channel.
|
||||||
|
pub fn drop_count(&self) -> u64 {
|
||||||
|
self.drops.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&self, stream: StreamId, samples: &[f32; FRAME_10MS_SAMPLES]) {
|
||||||
|
if !self.active.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut boxed = Box::new([0.0_f32; FRAME_10MS_SAMPLES]);
|
||||||
|
boxed.copy_from_slice(samples);
|
||||||
|
let frame = DebugFrame {
|
||||||
|
stream,
|
||||||
|
samples: boxed,
|
||||||
|
};
|
||||||
|
if self.tx.try_send(frame).is_err() {
|
||||||
|
self.drops
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- WAV writer task ----------
|
||||||
|
|
||||||
|
struct WavFile {
|
||||||
|
path: PathBuf,
|
||||||
|
file: std::fs::File,
|
||||||
|
samples_written: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WavFile {
|
||||||
|
fn create(dir: &std::path::Path, name: &str) -> Option<Self> {
|
||||||
|
let path = dir.join(name);
|
||||||
|
match std::fs::File::create(&path) {
|
||||||
|
Ok(mut file) => {
|
||||||
|
// Write a placeholder WAV header; we'll patch it on close.
|
||||||
|
if write_wav_header(&mut file, 0).is_ok() {
|
||||||
|
Some(Self {
|
||||||
|
path,
|
||||||
|
file,
|
||||||
|
samples_written: 0,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(target: "chanora_audio", error = %e, path = %path.display(), "wav debug: failed to create file");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_samples(&mut self, samples: &[f32]) {
|
||||||
|
for &s in samples {
|
||||||
|
let i16_val = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||||
|
let _ = self.file.write_all(&i16_val.to_le_bytes());
|
||||||
|
}
|
||||||
|
self.samples_written += samples.len() as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize(mut self) {
|
||||||
|
// Seek back to the start and rewrite the header with the
|
||||||
|
// correct data size.
|
||||||
|
use std::io::Seek;
|
||||||
|
if self.file.seek(std::io::SeekFrom::Start(0)).is_ok() {
|
||||||
|
let _ = write_wav_header(&mut self.file, self.samples_written);
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
path = %self.path.display(),
|
||||||
|
samples = self.samples_written,
|
||||||
|
"wav debug: file closed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_wav_header(file: &mut std::fs::File, num_samples: u32) -> std::io::Result<()> {
|
||||||
|
// PCM WAV header: 44 bytes.
|
||||||
|
// Channels: 1 (mono), sample rate: 48000, bit depth: 16.
|
||||||
|
let channels: u16 = 1;
|
||||||
|
let sample_rate: u32 = WAV_SAMPLE_RATE;
|
||||||
|
let bits_per_sample: u16 = 16;
|
||||||
|
let byte_rate = sample_rate * channels as u32 * bits_per_sample as u32 / 8;
|
||||||
|
let block_align = channels * bits_per_sample / 8;
|
||||||
|
let data_size = num_samples * channels as u32 * bits_per_sample as u32 / 8;
|
||||||
|
let chunk_size = 36 + data_size;
|
||||||
|
|
||||||
|
file.write_all(b"RIFF")?;
|
||||||
|
file.write_all(&chunk_size.to_le_bytes())?;
|
||||||
|
file.write_all(b"WAVE")?;
|
||||||
|
file.write_all(b"fmt ")?;
|
||||||
|
file.write_all(&16u32.to_le_bytes())?; // subchunk1 size
|
||||||
|
file.write_all(&1u16.to_le_bytes())?; // PCM format
|
||||||
|
file.write_all(&channels.to_le_bytes())?;
|
||||||
|
file.write_all(&sample_rate.to_le_bytes())?;
|
||||||
|
file.write_all(&byte_rate.to_le_bytes())?;
|
||||||
|
file.write_all(&block_align.to_le_bytes())?;
|
||||||
|
file.write_all(&bits_per_sample.to_le_bytes())?;
|
||||||
|
file.write_all(b"data")?;
|
||||||
|
file.write_all(&data_size.to_le_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wav_writer_task(rx: mpsc::Receiver<DebugFrame>, route: &str, backend: &str, ts: u64) {
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let prefix = format!("chanora_debug_{route}_{backend}_{ts}");
|
||||||
|
|
||||||
|
let mut raw_mic = WavFile::create(&dir, &format!("{prefix}_raw_mic.wav"));
|
||||||
|
let mut render_ref = WavFile::create(&dir, &format!("{prefix}_render_reference.wav"));
|
||||||
|
let mut processed = WavFile::create(&dir, &format!("{prefix}_processed_mic.wav"));
|
||||||
|
|
||||||
|
info!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
dir = %dir.display(),
|
||||||
|
prefix = %prefix,
|
||||||
|
"wav debug: writer started"
|
||||||
|
);
|
||||||
|
|
||||||
|
for frame in rx {
|
||||||
|
match frame.stream {
|
||||||
|
StreamId::RawMic => {
|
||||||
|
if let Some(f) = raw_mic.as_mut() {
|
||||||
|
f.write_samples(&*frame.samples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StreamId::RenderReference => {
|
||||||
|
if let Some(f) = render_ref.as_mut() {
|
||||||
|
f.write_samples(&*frame.samples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StreamId::ProcessedMic => {
|
||||||
|
if let Some(f) = processed.as_mut() {
|
||||||
|
f.write_samples(&*frame.samples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel closed — finalize all files.
|
||||||
|
if let Some(f) = raw_mic {
|
||||||
|
f.finalize();
|
||||||
|
}
|
||||||
|
if let Some(f) = render_ref {
|
||||||
|
f.finalize();
|
||||||
|
}
|
||||||
|
if let Some(f) = processed {
|
||||||
|
f.finalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(target: "chanora_audio", "wav debug: writer task exited");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recorder_starts_and_stops_without_panic() {
|
||||||
|
let rec =
|
||||||
|
WavDebugRecorder::start(AudioRoute::Speaker, AudioBackend::PlatformVoiceProcessing);
|
||||||
|
let frame = [0.1_f32; FRAME_10MS_SAMPLES];
|
||||||
|
rec.push_raw_mic(&frame);
|
||||||
|
rec.push_render_reference(&frame);
|
||||||
|
rec.push_processed_mic(&frame);
|
||||||
|
rec.stop();
|
||||||
|
// Drop the Arc to let the writer task drain.
|
||||||
|
drop(rec);
|
||||||
|
// Give the writer thread a moment to finish.
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_count_increments_when_channel_full() {
|
||||||
|
// Use a tiny channel by creating a recorder and flooding it.
|
||||||
|
// We can't easily test the bounded channel directly, but we
|
||||||
|
// can verify the drop counter starts at zero.
|
||||||
|
let rec = WavDebugRecorder::start(AudioRoute::Speaker, AudioBackend::Noop);
|
||||||
|
assert_eq!(rec.drop_count(), 0);
|
||||||
|
rec.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wav_header_is_44_bytes() {
|
||||||
|
// Write to a temp file to test the header.
|
||||||
|
let tmp = std::env::temp_dir().join("chanora_test_wav_header.wav");
|
||||||
|
let mut f = std::fs::File::create(&tmp).unwrap();
|
||||||
|
write_wav_header(&mut f, 960).unwrap();
|
||||||
|
drop(f);
|
||||||
|
let data = std::fs::read(&tmp).unwrap();
|
||||||
|
assert_eq!(data.len(), 44, "WAV header must be 44 bytes");
|
||||||
|
assert_eq!(&data[0..4], b"RIFF");
|
||||||
|
assert_eq!(&data[8..12], b"WAVE");
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
+233
-136
@@ -33,44 +33,21 @@ use tracing::{debug, info};
|
|||||||
))]
|
))]
|
||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
|
||||||
|
use chanora_protocol::{InboundVoice, OutPacket};
|
||||||
|
|
||||||
|
use crate::AudioError;
|
||||||
|
|
||||||
#[cfg(all(
|
#[cfg(all(
|
||||||
not(target_os = "ios"),
|
not(target_os = "ios"),
|
||||||
not(target_os = "macos"),
|
not(target_os = "macos"),
|
||||||
not(target_os = "android")
|
not(target_os = "android")
|
||||||
))]
|
))]
|
||||||
use audiopus::coder::Encoder as OpusEncoder;
|
use audiopus::coder::Encoder as OpusEncoder;
|
||||||
#[cfg(all(
|
|
||||||
not(target_os = "ios"),
|
|
||||||
not(target_os = "macos"),
|
|
||||||
not(target_os = "android")
|
|
||||||
))]
|
|
||||||
use audiopus::{
|
|
||||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
|
||||||
SampleRate as OpusSampleRate,
|
|
||||||
};
|
|
||||||
|
|
||||||
use tsclientlib::audio::AudioHandler;
|
|
||||||
|
|
||||||
// `AudioData`, `CodecType`, `OutAudio` are referenced only by the
|
|
||||||
// cpal capture pipeline's Opus encode path (`CaptureState::encode_and_send`).
|
|
||||||
// `InboundVoice` + `OutPacket` are used by every platform — the
|
|
||||||
// inbound forwarder task pumps `InboundVoice` into AudioHandler on
|
|
||||||
// iOS too, and `OutPacket` flows out of the capture pipeline once
|
|
||||||
// commit 3 lands. Cfg-gate the cpal-only ones to keep iOS warnings
|
|
||||||
// clean.
|
|
||||||
#[cfg(all(
|
|
||||||
not(target_os = "ios"),
|
|
||||||
not(target_os = "macos"),
|
|
||||||
not(target_os = "android")
|
|
||||||
))]
|
|
||||||
use chanora_protocol::{AudioData, CodecType, OutAudio};
|
|
||||||
use chanora_protocol::{InboundVoice, OutPacket};
|
|
||||||
|
|
||||||
use crate::AudioError;
|
|
||||||
|
|
||||||
/// Stable Chanora-side identifier for AudioHandler bookkeeping.
|
/// Stable Chanora-side identifier for AudioHandler bookkeeping.
|
||||||
/// We only ever have one connection at a time (DEC-006), so this is
|
/// We only ever have one connection at a time (DEC-006), so this is
|
||||||
/// trivially unique.
|
/// trivially unique.
|
||||||
@@ -89,11 +66,8 @@ pub struct SessionAudioId(pub u64);
|
|||||||
const SAMPLE_RATE: u32 = 48_000;
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
const FRAME_SAMPLES: usize = 48_000 / 50; // 960
|
const FRAME_SAMPLES: usize = 48_000 / 50; // 960
|
||||||
#[allow(dead_code)]
|
|
||||||
const MAX_OPUS_FRAME: usize = 1275;
|
|
||||||
|
|
||||||
/// Engine configuration.
|
/// Engine configuration.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AudioEngineConfig {
|
pub struct AudioEngineConfig {
|
||||||
/// Input gain applied before encoding (1.0 = pass-through).
|
/// Input gain applied before encoding (1.0 = pass-through).
|
||||||
pub mic_gain: f32,
|
pub mic_gain: f32,
|
||||||
@@ -117,6 +91,24 @@ pub struct AudioEngineConfig {
|
|||||||
/// is rejected on Android because the P0 path intentionally has
|
/// is rejected on Android because the P0 path intentionally has
|
||||||
/// no generic mobile-audio fallback.
|
/// no generic mobile-audio fallback.
|
||||||
pub mobile_voice_preset: bool,
|
pub mobile_voice_preset: bool,
|
||||||
|
/// Optional selector used by P1 VoiceActivity to publish VAD state.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for AudioEngineConfig {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("AudioEngineConfig")
|
||||||
|
.field("mic_gain", &self.mic_gain)
|
||||||
|
.field("ptt_initial", &self.ptt_initial)
|
||||||
|
.field("effects", &self.effects)
|
||||||
|
.field("mobile_voice_preset", &self.mobile_voice_preset)
|
||||||
|
.field(
|
||||||
|
"voice_activity_selector",
|
||||||
|
&self.voice_activity_selector.as_ref().map(|_| "present"),
|
||||||
|
)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AudioEngineConfig {
|
impl Default for AudioEngineConfig {
|
||||||
@@ -126,6 +118,7 @@ impl Default for AudioEngineConfig {
|
|||||||
ptt_initial: false,
|
ptt_initial: false,
|
||||||
effects: crate::AudioEffects::default(),
|
effects: crate::AudioEffects::default(),
|
||||||
mobile_voice_preset: true,
|
mobile_voice_preset: true,
|
||||||
|
voice_activity_selector: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,6 +143,16 @@ pub struct AudioEngine {
|
|||||||
/// independent of the server-side mute the protocol layer
|
/// independent of the server-side mute the protocol layer
|
||||||
/// broadcasts.
|
/// broadcasts.
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
mic_gain: f32,
|
||||||
|
|
||||||
// Streams must be dropped to stop audio. Both are `!Send` because
|
// Streams must be dropped to stop audio. Both are `!Send` because
|
||||||
// cpal's Stream isn't Send on some backends; we keep them in an
|
// cpal's Stream isn't Send on some backends; we keep them in an
|
||||||
@@ -177,7 +180,7 @@ pub struct AudioEngine {
|
|||||||
))]
|
))]
|
||||||
_output_stream: Mutex<Option<cpal::Stream>>,
|
_output_stream: Mutex<Option<cpal::Stream>>,
|
||||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
_ios_voice_unit: Mutex<Option<crate::ios_voice_unit::IosVoiceUnit>>,
|
_ios_voice_backend: Mutex<Option<IosVoiceBackend>>,
|
||||||
/// SDD-111..SDD-115: Android Oboe voice backend. Owns the input
|
/// SDD-111..SDD-115: Android Oboe voice backend. Owns the input
|
||||||
/// and output streams, SDD-113 hardware-effect handles, and the
|
/// and output streams, SDD-113 hardware-effect handles, and the
|
||||||
/// foreground-service lifecycle; tearing it down on engine drop
|
/// foreground-service lifecycle; tearing it down on engine drop
|
||||||
@@ -237,6 +240,105 @@ pub struct AudioEngine {
|
|||||||
unsafe impl Send for AudioEngine {}
|
unsafe impl Send for AudioEngine {}
|
||||||
unsafe impl Sync for AudioEngine {}
|
unsafe impl Sync for AudioEngine {}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
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"))]
|
||||||
|
impl IosVoiceBackend {
|
||||||
|
fn pause(&mut self) -> Result<(), AudioError> {
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
{
|
||||||
|
match self {
|
||||||
|
Self::Vpio(unit) => unit.pause(),
|
||||||
|
Self::Raw(unit) => unit.pause(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resume(&mut self) -> Result<(), AudioError> {
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
{
|
||||||
|
match self {
|
||||||
|
Self::Vpio(unit) => unit.resume(),
|
||||||
|
Self::Raw(unit) => unit.resume(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn open_ios_voice_backend(
|
||||||
|
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
|
output_gain: Arc<AtomicU32>,
|
||||||
|
output_muted: Arc<AtomicBool>,
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
transmit_flag_for_capture: Arc<AtomicBool>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
mic_gain: f32,
|
||||||
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
) -> Result<IosVoiceBackend, AudioError> {
|
||||||
|
let _cfg = audio_processing_config.lock().unwrap().clone();
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
{
|
||||||
|
if _cfg.ios_mode == crate::IosVoiceProcessingMode::SonoraExperimental {
|
||||||
|
match crate::ios_raw_unit::IosRawUnit::start(
|
||||||
|
handler.clone(),
|
||||||
|
output_gain.clone(),
|
||||||
|
output_muted.clone(),
|
||||||
|
voice_out_tx.clone(),
|
||||||
|
transmit_flag_for_capture.clone(),
|
||||||
|
frames_sent.clone(),
|
||||||
|
mic_gain,
|
||||||
|
voice_activity_selector.clone(),
|
||||||
|
audio_processing_config.clone(),
|
||||||
|
audio_processing_stats.clone(),
|
||||||
|
) {
|
||||||
|
Ok(unit) => {
|
||||||
|
info!(target: "chanora_audio", "ios: RemoteIO/Sonora experimental backend selected");
|
||||||
|
return Ok(IosVoiceBackend::Raw(unit));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
error = %e,
|
||||||
|
"ios: RemoteIO/Sonora backend failed; falling back to VoiceProcessingIO"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let unit = crate::ios_voice_unit::IosVoiceUnit::start(
|
||||||
|
handler,
|
||||||
|
output_gain,
|
||||||
|
output_muted,
|
||||||
|
voice_out_tx,
|
||||||
|
transmit_flag_for_capture,
|
||||||
|
frames_sent,
|
||||||
|
mic_gain,
|
||||||
|
voice_activity_selector,
|
||||||
|
audio_processing_config,
|
||||||
|
audio_processing_stats,
|
||||||
|
)?;
|
||||||
|
Ok(IosVoiceBackend::Vpio(unit))
|
||||||
|
}
|
||||||
|
|
||||||
impl AudioEngine {
|
impl AudioEngine {
|
||||||
/// Start the engine: open capture + playback streams, spawn the
|
/// Start the engine: open capture + playback streams, spawn the
|
||||||
/// inbound-voice forwarder, return a handle.
|
/// inbound-voice forwarder, return a handle.
|
||||||
@@ -259,6 +361,7 @@ impl AudioEngine {
|
|||||||
voice_in_rx: mpsc::Receiver<InboundVoice>,
|
voice_in_rx: mpsc::Receiver<InboundVoice>,
|
||||||
transmit_gate: crate::ptt::AudioTransmitGate,
|
transmit_gate: crate::ptt::AudioTransmitGate,
|
||||||
) -> Result<Self, AudioError> {
|
) -> Result<Self, AudioError> {
|
||||||
|
#[allow(clippy::needless_return)]
|
||||||
// Apple platforms route to a separate backend (VoiceProcessingIO
|
// Apple platforms route to a separate backend (VoiceProcessingIO
|
||||||
// via coreaudio-rs) because cpal does not expose the native
|
// via coreaudio-rs) because cpal does not expose the native
|
||||||
// voice-processing AudioUnit controls Chanora needs for VoIP.
|
// voice-processing AudioUnit controls Chanora needs for VoIP.
|
||||||
@@ -353,6 +456,8 @@ impl AudioEngine {
|
|||||||
let frames_received = Arc::new(AtomicU32::new(0));
|
let frames_received = Arc::new(AtomicU32::new(0));
|
||||||
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
||||||
let output_muted = Arc::new(AtomicBool::new(false));
|
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());
|
||||||
|
|
||||||
// ---------- Capture ----------
|
// ---------- Capture ----------
|
||||||
// Capture is best-effort. If the platform default input
|
// Capture is best-effort. If the platform default input
|
||||||
@@ -525,6 +630,8 @@ impl AudioEngine {
|
|||||||
frames_received,
|
frames_received,
|
||||||
output_gain,
|
output_gain,
|
||||||
output_muted,
|
output_muted,
|
||||||
|
audio_processing_config,
|
||||||
|
audio_processing_stats,
|
||||||
_input_stream: Mutex::new(input_stream),
|
_input_stream: Mutex::new(input_stream),
|
||||||
_output_stream: Mutex::new(Some(output_stream)),
|
_output_stream: Mutex::new(Some(output_stream)),
|
||||||
shutdown_tx: Some(shutdown_tx),
|
shutdown_tx: Some(shutdown_tx),
|
||||||
@@ -549,6 +656,8 @@ impl AudioEngine {
|
|||||||
let frames_received = Arc::new(AtomicU32::new(0));
|
let frames_received = Arc::new(AtomicU32::new(0));
|
||||||
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
||||||
let output_muted = Arc::new(AtomicBool::new(false));
|
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_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||||
Arc::new(Mutex::new(AudioHandler::new()));
|
Arc::new(Mutex::new(AudioHandler::new()));
|
||||||
@@ -678,6 +787,8 @@ impl AudioEngine {
|
|||||||
frames_received,
|
frames_received,
|
||||||
output_gain,
|
output_gain,
|
||||||
output_muted,
|
output_muted,
|
||||||
|
audio_processing_config,
|
||||||
|
audio_processing_stats,
|
||||||
_android_voice_unit: Mutex::new(Some(android_voice_unit)),
|
_android_voice_unit: Mutex::new(Some(android_voice_unit)),
|
||||||
audio_mode_stack: Mutex::new(audio_mode_stack),
|
audio_mode_stack: Mutex::new(audio_mode_stack),
|
||||||
shutdown_tx: Some(shutdown_tx),
|
shutdown_tx: Some(shutdown_tx),
|
||||||
@@ -725,23 +836,26 @@ impl AudioEngine {
|
|||||||
let frames_received = Arc::new(AtomicU32::new(0));
|
let frames_received = Arc::new(AtomicU32::new(0));
|
||||||
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
||||||
let output_muted = Arc::new(AtomicBool::new(false));
|
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_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||||
Arc::new(Mutex::new(AudioHandler::new()));
|
Arc::new(Mutex::new(AudioHandler::new()));
|
||||||
|
let voice_out_tx_for_backend = voice_out_tx.clone();
|
||||||
|
|
||||||
// Construct the VPIO unit. Commit 1 ships a no-op callback
|
// Construct the live iOS voice backend. Platform VPIO stays
|
||||||
// pair; commits 3 + 4 land the real capture + playback
|
// the default shipping path; Sonora/RemoteIO remains opt-in.
|
||||||
// wiring. Construction failure here is fatal (mirrors how
|
let ios_voice_backend = open_ios_voice_backend(
|
||||||
// the cpal output-stream construction failure is fatal in
|
|
||||||
// the non-iOS path).
|
|
||||||
let ios_voice_unit = crate::ios_voice_unit::IosVoiceUnit::start(
|
|
||||||
audio_handler.clone(),
|
audio_handler.clone(),
|
||||||
output_gain.clone(),
|
output_gain.clone(),
|
||||||
output_muted.clone(),
|
output_muted.clone(),
|
||||||
voice_out_tx,
|
voice_out_tx_for_backend,
|
||||||
transmit_flag_for_capture,
|
transmit_flag_for_capture,
|
||||||
frames_sent.clone(),
|
frames_sent.clone(),
|
||||||
cfg.mic_gain,
|
cfg.mic_gain,
|
||||||
|
cfg.voice_activity_selector.clone(),
|
||||||
|
audio_processing_config.clone(),
|
||||||
|
audio_processing_stats.clone(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Capture is always considered active on iOS — VPIO's
|
// Capture is always considered active on iOS — VPIO's
|
||||||
@@ -791,7 +905,13 @@ impl AudioEngine {
|
|||||||
frames_received,
|
frames_received,
|
||||||
output_gain,
|
output_gain,
|
||||||
output_muted,
|
output_muted,
|
||||||
_ios_voice_unit: Mutex::new(Some(ios_voice_unit)),
|
audio_processing_config,
|
||||||
|
audio_processing_stats,
|
||||||
|
audio_handler,
|
||||||
|
voice_out_tx,
|
||||||
|
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
||||||
|
mic_gain: cfg.mic_gain,
|
||||||
|
_ios_voice_backend: Mutex::new(Some(ios_voice_backend)),
|
||||||
shutdown_tx: Some(shutdown_tx),
|
shutdown_tx: Some(shutdown_tx),
|
||||||
capture_active,
|
capture_active,
|
||||||
ptt_watchdog,
|
ptt_watchdog,
|
||||||
@@ -826,7 +946,7 @@ impl AudioEngine {
|
|||||||
}
|
}
|
||||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
{
|
{
|
||||||
let _ = self._ios_voice_unit.lock().unwrap().take();
|
let _ = self._ios_voice_backend.lock().unwrap().take();
|
||||||
}
|
}
|
||||||
// SDD-115 reverse-order teardown on Android:
|
// SDD-115 reverse-order teardown on Android:
|
||||||
// 1) close the voice unit (releases SDD-113 hardware
|
// 1) close the voice unit (releases SDD-113 hardware
|
||||||
@@ -900,15 +1020,25 @@ impl AudioEngine {
|
|||||||
/// iOS-only: restart the underlying VoiceProcessingIO unit after
|
/// iOS-only: restart the underlying VoiceProcessingIO unit after
|
||||||
/// route changes.
|
/// route changes.
|
||||||
pub fn ios_restart_voice_unit(&self) -> Result<(), AudioError> {
|
pub fn ios_restart_voice_unit(&self) -> Result<(), AudioError> {
|
||||||
#[cfg(target_os = "ios")]
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
{
|
{
|
||||||
let mut guard = self._ios_voice_unit.lock().unwrap();
|
let backend = open_ios_voice_backend(
|
||||||
let unit = guard
|
self.audio_handler.clone(),
|
||||||
.as_mut()
|
self.output_gain.clone(),
|
||||||
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
|
self.output_muted.clone(),
|
||||||
return unit.restart();
|
self.voice_out_tx.clone(),
|
||||||
|
self.transmit_gate.flag_arc(),
|
||||||
|
self.frames_sent.clone(),
|
||||||
|
self.mic_gain,
|
||||||
|
self.voice_activity_selector.clone(),
|
||||||
|
self.audio_processing_config.clone(),
|
||||||
|
self.audio_processing_stats.clone(),
|
||||||
|
)?;
|
||||||
|
let mut guard = self._ios_voice_backend.lock().unwrap();
|
||||||
|
*guard = Some(backend);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "ios"))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -916,15 +1046,15 @@ impl AudioEngine {
|
|||||||
|
|
||||||
/// iOS-only: pause the underlying VoiceProcessingIO unit.
|
/// iOS-only: pause the underlying VoiceProcessingIO unit.
|
||||||
pub fn ios_pause_voice_unit(&self) -> Result<(), AudioError> {
|
pub fn ios_pause_voice_unit(&self) -> Result<(), AudioError> {
|
||||||
#[cfg(target_os = "ios")]
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
{
|
{
|
||||||
let mut guard = self._ios_voice_unit.lock().unwrap();
|
let mut guard = self._ios_voice_backend.lock().unwrap();
|
||||||
let unit = guard
|
let unit = guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
|
.ok_or_else(|| AudioError::Backend("ios voice backend not running".to_string()))?;
|
||||||
return unit.pause();
|
unit.pause()
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "ios"))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -932,15 +1062,15 @@ impl AudioEngine {
|
|||||||
|
|
||||||
/// iOS-only: resume the underlying VoiceProcessingIO unit.
|
/// iOS-only: resume the underlying VoiceProcessingIO unit.
|
||||||
pub fn ios_resume_voice_unit(&self) -> Result<(), AudioError> {
|
pub fn ios_resume_voice_unit(&self) -> Result<(), AudioError> {
|
||||||
#[cfg(target_os = "ios")]
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
{
|
{
|
||||||
let mut guard = self._ios_voice_unit.lock().unwrap();
|
let mut guard = self._ios_voice_backend.lock().unwrap();
|
||||||
let unit = guard
|
let unit = guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
|
.ok_or_else(|| AudioError::Backend("ios voice backend not running".to_string()))?;
|
||||||
return unit.resume();
|
unit.resume()
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "ios"))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1014,6 +1144,29 @@ impl AudioEngine {
|
|||||||
self.frames_received.load(Ordering::Relaxed)
|
self.frames_received.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current audio-processing config snapshot.
|
||||||
|
pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig {
|
||||||
|
self.audio_processing_config.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a voice-processing config after validating iOS invariants.
|
||||||
|
pub fn set_audio_processing_config(
|
||||||
|
&self,
|
||||||
|
config: crate::AudioProcessingConfig,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
config.validate_for_ios()?;
|
||||||
|
let mut guard = self.audio_processing_config.lock().unwrap();
|
||||||
|
*guard = config;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current voice-processing stats snapshot.
|
||||||
|
pub fn audio_processing_stats(&self) -> crate::AudioProcessingStats {
|
||||||
|
let config = self.audio_processing_config.lock().unwrap().clone();
|
||||||
|
self.audio_processing_stats.snapshot(&config)
|
||||||
|
}
|
||||||
|
|
||||||
/// Latest Android voice-audio diagnostics snapshot (SDD-112 item
|
/// Latest Android voice-audio diagnostics snapshot (SDD-112 item
|
||||||
/// 10 / SDD-113 item 7 / SDD-116 item 3). On non-Android targets
|
/// 10 / SDD-113 item 7 / SDD-116 item 3). On non-Android targets
|
||||||
/// this always returns `None`. On Android it returns `Some(...)`
|
/// this always returns `None`. On Android it returns `Some(...)`
|
||||||
@@ -1091,58 +1244,7 @@ fn try_open_capture(
|
|||||||
in_stream_cfg.buffer_size = cpal::BufferSize::Default;
|
in_stream_cfg.buffer_size = cpal::BufferSize::Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut opus_enc = OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
|
let opus_enc = crate::opus_voice::new_voip_encoder("cpal capture")?;
|
||||||
.map_err(|e| AudioError::Opus(format!("encoder new: {e}")))?;
|
|
||||||
|
|
||||||
// Opus VOIP tuning. Defaults give us 'auto' bitrate (can drop
|
|
||||||
// to ~6 kbps during silence \u2014 which sounds garbled when
|
|
||||||
// talking resumes) and inband FEC disabled. On lossy mobile
|
|
||||||
// networks (cellular / iPhone WiFi roaming), packet loss
|
|
||||||
// without FEC produces audible clicks + cut-out frames.
|
|
||||||
//
|
|
||||||
// Settings derived from the Opus IETF VoIP recommendations
|
|
||||||
// (RFC 6716 \u00a7 7.1) and Discord's voice client tuning:
|
|
||||||
//
|
|
||||||
// * Bitrate 32 kbps : sweet spot for mono voice. Lower
|
|
||||||
// than 24 kbps starts to sound watery; higher than
|
|
||||||
// 64 kbps wastes bandwidth without perceptual gain on a
|
|
||||||
// human voice. Discord uses 64 kbps; mumble defaults to
|
|
||||||
// 40 kbps; we pick 32 kbps as a conservative VoIP value
|
|
||||||
// that survives 100 kbps uplinks comfortably.
|
|
||||||
// * Complexity 10 : max quality. The CPU cost on a modern
|
|
||||||
// iPhone (A14+) or any desktop is negligible (~0.5 % of
|
|
||||||
// a single core for 48 kHz mono).
|
|
||||||
// * Inband FEC on : opus inserts a low-bitrate redundancy
|
|
||||||
// copy of the previous frame inside the current packet
|
|
||||||
// so a single dropped packet can be reconstructed from
|
|
||||||
// the next one. Essential on lossy mobile.
|
|
||||||
// * Packet loss perc 5 % : tells the encoder to expect 5 %
|
|
||||||
// loss and pre-emptively budget bits for FEC. Higher
|
|
||||||
// values trade audio quality for resilience.
|
|
||||||
//
|
|
||||||
// Errors here are non-fatal: log + continue. The encoder
|
|
||||||
// works with defaults if any setter fails on an exotic
|
|
||||||
// libopus build.
|
|
||||||
if let Err(e) = opus_enc.set_bitrate(OpusBitrate::BitsPerSecond(32_000)) {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "opus: set_bitrate(32000) failed");
|
|
||||||
}
|
|
||||||
if let Err(e) = opus_enc.set_complexity(10) {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "opus: set_complexity(10) failed");
|
|
||||||
}
|
|
||||||
if let Err(e) = opus_enc.set_inband_fec(true) {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "opus: set_inband_fec(true) failed");
|
|
||||||
}
|
|
||||||
if let Err(e) = opus_enc.set_packet_loss_perc(5) {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "opus: set_packet_loss_perc(5) failed");
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
bitrate_bps = 32_000,
|
|
||||||
complexity = 10,
|
|
||||||
inband_fec = true,
|
|
||||||
packet_loss_perc = 5,
|
|
||||||
"opus encoder tuned for VoIP"
|
|
||||||
);
|
|
||||||
|
|
||||||
let capture_state = Arc::new(Mutex::new(CaptureState::new(
|
let capture_state = Arc::new(Mutex::new(CaptureState::new(
|
||||||
opus_enc,
|
opus_enc,
|
||||||
@@ -1184,7 +1286,7 @@ struct CaptureState {
|
|||||||
/// roughly at the period rate (~100 Hz for a 10 ms period on
|
/// roughly at the period rate (~100 Hz for a 10 ms period on
|
||||||
/// Linux ALSA defaults).
|
/// Linux ALSA defaults).
|
||||||
resample_last: f32,
|
resample_last: f32,
|
||||||
opus_out: [u8; MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
/// The PTT transmission gate. Read once per outbound frame; the
|
/// The PTT transmission gate. Read once per outbound frame; the
|
||||||
/// CaptureState never mutates this flag.
|
/// CaptureState never mutates this flag.
|
||||||
@@ -1224,7 +1326,7 @@ impl CaptureState {
|
|||||||
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
|
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
|
||||||
resample_pos: 0.0,
|
resample_pos: 0.0,
|
||||||
resample_last: 0.0,
|
resample_last: 0.0,
|
||||||
opus_out: [0u8; MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx,
|
voice_out_tx,
|
||||||
transmit_active,
|
transmit_active,
|
||||||
frames_sent,
|
frames_sent,
|
||||||
@@ -1310,22 +1412,21 @@ impl CaptureState {
|
|||||||
.encode_float(&frame[..], &mut self.opus_out[..])
|
.encode_float(&frame[..], &mut self.opus_out[..])
|
||||||
{
|
{
|
||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
let packet = OutAudio::new(&AudioData::C2S {
|
crate::opus_voice::send_voip_frame(
|
||||||
id: 0,
|
&self.voice_out_tx,
|
||||||
codec: CodecType::OpusVoice,
|
&self.frames_sent,
|
||||||
data: &self.opus_out[..len],
|
&self.opus_out,
|
||||||
});
|
len,
|
||||||
match self.voice_out_tx.try_send(packet) {
|
|| {
|
||||||
Ok(()) => {
|
warn!(
|
||||||
self.frames_sent.fetch_add(1, Ordering::Relaxed);
|
target: "chanora_audio",
|
||||||
}
|
"voice_out queue full; dropping frame"
|
||||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
);
|
||||||
warn!(target: "chanora_audio", "voice_out queue full; dropping frame");
|
},
|
||||||
}
|
|| {
|
||||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
|
||||||
warn!(target: "chanora_audio", "voice_out closed; stopping send");
|
warn!(target: "chanora_audio", "voice_out closed; stopping send");
|
||||||
}
|
},
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(target: "chanora_audio", error = %e, "opus encode failed");
|
error!(target: "chanora_audio", error = %e, "opus encode failed");
|
||||||
@@ -1851,10 +1952,7 @@ pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
|
|||||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub mod bench_seam {
|
pub mod bench_seam {
|
||||||
use super::{
|
use super::{Arc, AtomicBool, AtomicU32, CaptureState, OpusEncoder, OutPacket};
|
||||||
Arc, AtomicBool, AtomicU32, CaptureState, OpusApp, OpusChannels, OpusEncoder,
|
|
||||||
OpusSampleRate, OutPacket,
|
|
||||||
};
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
/// Opaque handle wrapping a CaptureState plus the dummy mpsc
|
/// Opaque handle wrapping a CaptureState plus the dummy mpsc
|
||||||
@@ -1880,8 +1978,7 @@ pub mod bench_seam {
|
|||||||
/// (typically 1 or 2).
|
/// (typically 1 or 2).
|
||||||
pub fn new(in_sample_rate: u32, in_channels: usize) -> Self {
|
pub fn new(in_sample_rate: u32, in_channels: usize) -> Self {
|
||||||
let encoder =
|
let encoder =
|
||||||
OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
|
crate::opus_voice::new_voip_encoder("cpal bench").expect("opus encoder init");
|
||||||
.expect("opus encoder init");
|
|
||||||
let (tx, rx) = mpsc::channel::<OutPacket>(64);
|
let (tx, rx) = mpsc::channel::<OutPacket>(64);
|
||||||
let transmit_active = Arc::new(AtomicBool::new(true));
|
let transmit_active = Arc::new(AtomicBool::new(true));
|
||||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
//! Canonical P1 voice frame helpers.
|
||||||
|
//!
|
||||||
|
//! The network contract remains 48 kHz mono, 20 ms Opus frames. P1
|
||||||
|
//! processing works internally on 10 ms f32 frames so VAD and future
|
||||||
|
//! processors can share a stable frame size without changing the
|
||||||
|
//! transport layer.
|
||||||
|
|
||||||
|
/// P1 sample rate in Hz.
|
||||||
|
pub const SAMPLE_RATE_HZ: u32 = 48_000;
|
||||||
|
/// Network frame duration in milliseconds.
|
||||||
|
pub const NETWORK_FRAME_MS: u32 = 20;
|
||||||
|
/// Processing frame duration in milliseconds.
|
||||||
|
pub const PROCESSING_FRAME_MS: u32 = 10;
|
||||||
|
/// Samples in one 10 ms mono frame at 48 kHz.
|
||||||
|
pub const FRAME_10MS_SAMPLES: usize = 480;
|
||||||
|
/// Samples in one 20 ms mono frame at 48 kHz.
|
||||||
|
pub const FRAME_20MS_SAMPLES: usize = 960;
|
||||||
|
|
||||||
|
/// 10 ms, 48 kHz, mono f32 processing frame.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct AudioFrame10ms {
|
||||||
|
/// Samples normalized to `[-1.0, 1.0]`.
|
||||||
|
pub samples: [f32; FRAME_10MS_SAMPLES],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 20 ms, 48 kHz, mono f32 network-frame-sized buffer.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct AudioFrame20ms {
|
||||||
|
/// Samples normalized to `[-1.0, 1.0]`.
|
||||||
|
pub samples: [f32; FRAME_20MS_SAMPLES],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioFrame20ms {
|
||||||
|
/// Convert one 20 ms frame into two 10 ms processing frames.
|
||||||
|
pub fn split(&self) -> (AudioFrame10ms, AudioFrame10ms) {
|
||||||
|
let mut first = [0.0; FRAME_10MS_SAMPLES];
|
||||||
|
let mut second = [0.0; FRAME_10MS_SAMPLES];
|
||||||
|
first.copy_from_slice(&self.samples[..FRAME_10MS_SAMPLES]);
|
||||||
|
second.copy_from_slice(&self.samples[FRAME_10MS_SAMPLES..]);
|
||||||
|
(
|
||||||
|
AudioFrame10ms { samples: first },
|
||||||
|
AudioFrame10ms { samples: second },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioFrame10ms {
|
||||||
|
/// Merge two 10 ms processing frames back into the 20 ms network
|
||||||
|
/// cadence used by the existing Opus path.
|
||||||
|
pub fn merge(first: &Self, second: &Self) -> AudioFrame20ms {
|
||||||
|
let mut samples = [0.0; FRAME_20MS_SAMPLES];
|
||||||
|
samples[..FRAME_10MS_SAMPLES].copy_from_slice(&first.samples);
|
||||||
|
samples[FRAME_10MS_SAMPLES..].copy_from_slice(&second.samples);
|
||||||
|
AudioFrame20ms { samples }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute RMS dBFS for diagnostics and fallback VAD.
|
||||||
|
pub fn dbfs(&self) -> f32 {
|
||||||
|
dbfs(&self.samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert i16 PCM to normalized f32 PCM.
|
||||||
|
pub fn i16_to_f32(sample: i16) -> f32 {
|
||||||
|
sample as f32 / i16::MAX as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert normalized f32 PCM to saturated i16 PCM.
|
||||||
|
pub fn f32_to_i16(sample: f32) -> i16 {
|
||||||
|
(sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RMS dBFS for a normalized f32 slice. Silence returns `-120.0`.
|
||||||
|
pub fn dbfs(samples: &[f32]) -> f32 {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return -120.0;
|
||||||
|
}
|
||||||
|
let sum = samples.iter().map(|s| s * s).sum::<f32>();
|
||||||
|
let rms = (sum / samples.len() as f32).sqrt();
|
||||||
|
if rms <= 0.000_001 {
|
||||||
|
-120.0
|
||||||
|
} else {
|
||||||
|
20.0 * rms.log10()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_merge_preserves_samples() {
|
||||||
|
let mut samples = [0.0; FRAME_20MS_SAMPLES];
|
||||||
|
for (i, s) in samples.iter_mut().enumerate() {
|
||||||
|
*s = i as f32 / FRAME_20MS_SAMPLES as f32;
|
||||||
|
}
|
||||||
|
let original = AudioFrame20ms { samples };
|
||||||
|
let (a, b) = original.split();
|
||||||
|
assert_eq!(AudioFrame10ms::merge(&a, &b), original);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
//! Optional raw iOS RemoteIO path for the Sonora 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 Rust's Sonora DSP chain 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 Sonora AEC3 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 tokio::sync::mpsc;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
|
||||||
|
use crate::engine::SessionAudioId;
|
||||||
|
use crate::processor::AudioProcessor;
|
||||||
|
use crate::AudioError;
|
||||||
|
use chanora_protocol::OutPacket;
|
||||||
|
|
||||||
|
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.
|
||||||
|
struct RenderReferenceBuffer {
|
||||||
|
buf: Box<[[f32; 480]; 4]>,
|
||||||
|
write_idx: std::sync::atomic::AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderReferenceBuffer {
|
||||||
|
fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
buf: Box::new([[0.0; 480]; 4]),
|
||||||
|
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write one 10 ms render-reference frame. Realtime-safe.
|
||||||
|
fn write(&self, frame: &[f32; 480]) {
|
||||||
|
let idx = self.write_idx.load(Ordering::Relaxed);
|
||||||
|
// SAFETY: only one writer (render callback); torn reads
|
||||||
|
// are bounded to one frame of AEC degradation.
|
||||||
|
unsafe {
|
||||||
|
let slot = &self.buf[idx] as *const [f32; 480] as *mut [f32; 480];
|
||||||
|
(*slot).copy_from_slice(frame);
|
||||||
|
}
|
||||||
|
self.write_idx.store((idx + 1) % 4, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the most recently completed render-reference frame.
|
||||||
|
fn read_latest(&self) -> [f32; 480] {
|
||||||
|
let wi = self.write_idx.load(Ordering::Relaxed);
|
||||||
|
let ri = (wi + 3) % 4;
|
||||||
|
self.buf[ri]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: accessed from two audio callback threads; data races are
|
||||||
|
// bounded to one frame of AEC quality degradation.
|
||||||
|
unsafe impl Send for RenderReferenceBuffer {}
|
||||||
|
unsafe impl Sync for RenderReferenceBuffer {}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// Capture pipeline state //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
struct RawCaptureState {
|
||||||
|
encoder: OpusEncoder,
|
||||||
|
pcm_accum: Vec<i16>,
|
||||||
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
transmit_active: Arc<AtomicBool>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
mic_gain: f32,
|
||||||
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||||
|
vad_state: crate::voice_activity::VoiceActivityStateMachine,
|
||||||
|
/// Processing config — retained for route-change reloads; not read in the hot path.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
|
sonora_processor: crate::processor::SonoraProcessor,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
render_reference: Arc<RenderReferenceBuffer>,
|
||||||
|
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
|
pending_10ms_len: usize,
|
||||||
|
wav_recorder: Option<Arc<crate::debug_wav::WavDebugRecorder>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawCaptureState {
|
||||||
|
fn new(
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
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>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
render_reference: Arc<RenderReferenceBuffer>,
|
||||||
|
) -> Result<Self, AudioError> {
|
||||||
|
let encoder = crate::opus_voice::new_voip_encoder("ios raw")?;
|
||||||
|
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,
|
||||||
|
transmit_active,
|
||||||
|
frames_sent,
|
||||||
|
mic_gain,
|
||||||
|
voice_activity_selector,
|
||||||
|
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||||
|
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
|
||||||
|
audio_processing_config,
|
||||||
|
sonora_processor: crate::processor::SonoraProcessor::with_config(
|
||||||
|
crate::processor::sonora::SonoraConfig::with_aec3(),
|
||||||
|
),
|
||||||
|
audio_processing_stats,
|
||||||
|
render_reference,
|
||||||
|
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
|
pending_10ms_len: 0,
|
||||||
|
wav_recorder: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disable_failed_vad_backend(&mut self, failed_backend: crate::VadBackend) {
|
||||||
|
if let Ok(mut cfg) = self.audio_processing_config.try_lock() {
|
||||||
|
let _ = cfg.disable_failed_vad_backend(failed_backend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.pending_10ms_len = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||||
|
self.pcm_accum.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode complete 20 ms Opus frames.
|
||||||
|
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.frames_sent,
|
||||||
|
&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);
|
||||||
|
|
||||||
|
// WAV tap: raw mic (before processing).
|
||||||
|
if let Some(ref rec) = self.wav_recorder {
|
||||||
|
rec.push_raw_mic(&frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// INV_012: feed render reference to Sonora AEC3 before capture.
|
||||||
|
let render_ref = self.render_reference.read_latest();
|
||||||
|
self.sonora_processor.process_render(&render_ref);
|
||||||
|
self.sonora_processor.process_capture(&mut frame);
|
||||||
|
|
||||||
|
// WAV tap: processed mic (after Sonora).
|
||||||
|
if let Some(ref rec) = self.wav_recorder {
|
||||||
|
rec.push_processed_mic(&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,
|
||||||
|
));
|
||||||
|
self.vad_state.configure(
|
||||||
|
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
||||||
|
vad_hangover,
|
||||||
|
crate::voice_activity::VAD_MIN_TX_MS,
|
||||||
|
);
|
||||||
|
let mut used_fallback_vad = false;
|
||||||
|
let vad = if vad_backend == crate::VadBackend::Disabled {
|
||||||
|
crate::vad::VadOutput {
|
||||||
|
probability: 1.0,
|
||||||
|
speech: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
used_fallback_vad = matches!(
|
||||||
|
vad_backend,
|
||||||
|
crate::VadBackend::SileroOnnx | crate::VadBackend::TenVad
|
||||||
|
);
|
||||||
|
if used_fallback_vad {
|
||||||
|
self.disable_failed_vad_backend(vad_backend);
|
||||||
|
}
|
||||||
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
|
};
|
||||||
|
self.audio_processing_stats
|
||||||
|
.set_vad_fallback_active(used_fallback_vad);
|
||||||
|
let active = self.vad_state.update(vad.speech);
|
||||||
|
if let Some(sel) = &self.voice_activity_selector {
|
||||||
|
sel.set_voice_activity_open(active);
|
||||||
|
}
|
||||||
|
self.audio_processing_stats.update_capture(
|
||||||
|
input_dbfs,
|
||||||
|
crate::frame::dbfs(&frame),
|
||||||
|
vad.probability,
|
||||||
|
active,
|
||||||
|
self.transmit_active.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
|
||||||
|
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gain = self.mic_gain;
|
||||||
|
if (gain - 1.0).abs() < f32::EPSILON {
|
||||||
|
self.pcm_accum
|
||||||
|
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
||||||
|
} else {
|
||||||
|
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
||||||
|
let scaled = crate::frame::f32_to_i16(s) as f32 * gain;
|
||||||
|
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// 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.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn start(
|
||||||
|
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
|
output_gain: Arc<AtomicU32>,
|
||||||
|
output_muted: Arc<AtomicBool>,
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
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>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
) -> Result<Self, AudioError> {
|
||||||
|
// INV_010: reject if config requests VPIO (that's IosVoiceUnit's job).
|
||||||
|
{
|
||||||
|
let cfg = audio_processing_config.lock().unwrap();
|
||||||
|
if cfg.ios_mode == crate::IosVoiceProcessingMode::PlatformVoiceProcessing {
|
||||||
|
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||||
|
"IosRawUnit requires SonoraExperimental 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(
|
||||||
|
voice_out_tx,
|
||||||
|
transmit_active,
|
||||||
|
frames_sent,
|
||||||
|
mic_gain,
|
||||||
|
voice_activity_selector,
|
||||||
|
audio_processing_config,
|
||||||
|
audio_processing_stats.clone(),
|
||||||
|
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: Vec<f32> = Vec::with_capacity(2048);
|
||||||
|
let stats_render = 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 stereo_n = n * 2;
|
||||||
|
if scratch.len() < stereo_n {
|
||||||
|
scratch.resize(stereo_n, 0.0);
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
let mono_n = n.min(480);
|
||||||
|
let mut ref_frame = [0.0_f32; 480];
|
||||||
|
crate::voice_render::downmix_stereo_f32_to_mono_f32(
|
||||||
|
&scratch[..stereo_n],
|
||||||
|
&mut ref_frame[..mono_n],
|
||||||
|
);
|
||||||
|
render_ref_buf.write(&ref_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_mono_i16(
|
||||||
|
&scratch[..stereo_n],
|
||||||
|
out,
|
||||||
|
gain,
|
||||||
|
muted,
|
||||||
|
);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,10 +75,6 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use audiopus::coder::Encoder as OpusEncoder;
|
use audiopus::coder::Encoder as OpusEncoder;
|
||||||
use audiopus::{
|
|
||||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
|
||||||
SampleRate as OpusSampleRate,
|
|
||||||
};
|
|
||||||
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
||||||
use coreaudio::audio_unit::render_callback::{self, data};
|
use coreaudio::audio_unit::render_callback::{self, data};
|
||||||
use coreaudio::audio_unit::IOType;
|
use coreaudio::audio_unit::IOType;
|
||||||
@@ -89,22 +85,7 @@ use tsclientlib::audio::AudioHandler;
|
|||||||
|
|
||||||
use crate::engine::SessionAudioId;
|
use crate::engine::SessionAudioId;
|
||||||
use crate::AudioError;
|
use crate::AudioError;
|
||||||
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
use chanora_protocol::OutPacket;
|
||||||
|
|
||||||
/// 20 ms at 48 kHz mono — one Opus frame's worth of samples.
|
|
||||||
/// Aligning the AudioUnit IO buffer to this frame size keeps the
|
|
||||||
/// jitter-buffer / encoder handshake tight (no fractional-frame
|
|
||||||
/// reads inside fill_buffer or accumulator drift inside the
|
|
||||||
/// capture pipeline).
|
|
||||||
const FRAME_SAMPLES_MONO: usize = 960;
|
|
||||||
|
|
||||||
/// Maximum size of an encoded Opus frame in bytes (per RFC 6716
|
|
||||||
/// §3.2.1). Same constant the cpal-side `CaptureState` uses; we
|
|
||||||
/// duplicate it here instead of cross-importing from engine.rs
|
|
||||||
/// because engine.rs's copy is cfg-gated to non-iOS for cpal-only
|
|
||||||
/// reasons. Post-step-5 review may dedupe by promoting both to a
|
|
||||||
/// shared `crate::framing` module.
|
|
||||||
const MAX_OPUS_FRAME: usize = 1275;
|
|
||||||
|
|
||||||
/// Sample rate every layer above us assumes. Matches the Opus
|
/// Sample rate every layer above us assumes. Matches the Opus
|
||||||
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
|
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
|
||||||
@@ -124,6 +105,11 @@ const OUTPUT_BUS: Element = Element::Output;
|
|||||||
/// samples in.
|
/// samples in.
|
||||||
const INPUT_BUS: Element = Element::Input;
|
const INPUT_BUS: Element = Element::Input;
|
||||||
|
|
||||||
|
/// Pre-roll buffer capacity: 160 ms / 10 ms = 16 frames.
|
||||||
|
/// Stores processed i16 frames so the first syllable is not lost
|
||||||
|
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
|
||||||
|
const PRE_ROLL_FRAMES: usize = 16;
|
||||||
|
|
||||||
/// Capture pipeline state owned by the VPIO input callback. The
|
/// Capture pipeline state owned by the VPIO input callback. The
|
||||||
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
|
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
|
||||||
/// downmix or resample needed — VPIO's hardware-side mix-down
|
/// downmix or resample needed — VPIO's hardware-side mix-down
|
||||||
@@ -146,17 +132,36 @@ const INPUT_BUS: Element = Element::Input;
|
|||||||
/// shared with `AudioEngine`.
|
/// shared with `AudioEngine`.
|
||||||
struct IosCaptureState {
|
struct IosCaptureState {
|
||||||
encoder: OpusEncoder,
|
encoder: OpusEncoder,
|
||||||
/// 48 kHz mono PCM scratch accumulating to FRAME_SAMPLES_MONO
|
/// 48 kHz mono PCM scratch accumulating to FRAME_20MS_SAMPLES
|
||||||
/// per encode. Capacity 2x to absorb cpal-style buffer-size
|
/// per encode. Capacity 2x to absorb cpal-style buffer-size
|
||||||
/// jitter without reallocating.
|
/// jitter without reallocating.
|
||||||
pcm_accum: Vec<i16>,
|
pcm_accum: Vec<i16>,
|
||||||
opus_out: [u8; MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
/// PTT transmission gate. Read once per outbound frame; this
|
|
||||||
/// struct never mutates the flag (SAD-075 / SDD-089).
|
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||||
|
/// Background Silero worker — enqueues frames off the realtime
|
||||||
|
/// callback and publishes the latest probability atomically.
|
||||||
|
silero_vad_worker: Option<crate::vad::silero_onnx::SileroOnnxVadWorker>,
|
||||||
|
/// Last VAD backend we configured — used to detect backend changes.
|
||||||
|
current_vad_backend: crate::VadBackend,
|
||||||
|
/// Last observed configured Silero model epoch.
|
||||||
|
silero_model_epoch: u64,
|
||||||
|
vad_state: crate::voice_activity::VoiceActivityStateMachine,
|
||||||
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
|
sonora_processor: crate::processor::SonoraProcessor,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
|
pending_10ms_len: usize,
|
||||||
|
pre_roll_buf: [[i16; crate::frame::FRAME_10MS_SAMPLES]; PRE_ROLL_FRAMES],
|
||||||
|
pre_roll_head: usize,
|
||||||
|
pre_roll_count: usize,
|
||||||
|
pre_roll_flushed: bool,
|
||||||
|
capture_frame_seq: u64,
|
||||||
|
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IosCaptureState {
|
impl IosCaptureState {
|
||||||
@@ -164,54 +169,55 @@ impl IosCaptureState {
|
|||||||
/// Encoder configuration is the same as cpal-side
|
/// Encoder configuration is the same as cpal-side
|
||||||
/// `try_open_capture` (engine.rs) so audio quality is platform-
|
/// `try_open_capture` (engine.rs) so audio quality is platform-
|
||||||
/// neutral.
|
/// neutral.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn new(
|
fn new(
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
||||||
) -> Result<Self, AudioError> {
|
) -> Result<Self, AudioError> {
|
||||||
let mut encoder =
|
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
|
||||||
OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
|
|
||||||
.map_err(|e| AudioError::Opus(format!("encoder new (ios): {e}")))?;
|
|
||||||
|
|
||||||
// VoIP-tuned settings — bitrate 32 kbps, complexity 10,
|
|
||||||
// inband FEC on, packet-loss-perc 5. Soft-fail each setter
|
|
||||||
// with a warn log to match the cpal-side behaviour: an
|
|
||||||
// unusual libopus build that rejects one setter shouldn't
|
|
||||||
// tank the whole pipeline. Full rationale + RFC citations
|
|
||||||
// are in engine.rs::try_open_capture line ~640.
|
|
||||||
if let Err(e) = encoder.set_bitrate(OpusBitrate::BitsPerSecond(32_000)) {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "opus(ios): set_bitrate(32000) failed");
|
|
||||||
}
|
|
||||||
if let Err(e) = encoder.set_complexity(10) {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "opus(ios): set_complexity(10) failed");
|
|
||||||
}
|
|
||||||
if let Err(e) = encoder.set_inband_fec(true) {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "opus(ios): set_inband_fec(true) failed");
|
|
||||||
}
|
|
||||||
if let Err(e) = encoder.set_packet_loss_perc(5) {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "opus(ios): set_packet_loss_perc(5) failed");
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
bitrate_bps = 32_000,
|
|
||||||
complexity = 10,
|
|
||||||
inband_fec = true,
|
|
||||||
packet_loss_perc = 5,
|
|
||||||
"ios VPIO opus encoder tuned for VoIP"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
encoder,
|
encoder,
|
||||||
pcm_accum: Vec::with_capacity(FRAME_SAMPLES_MONO * 2),
|
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||||
opus_out: [0u8; MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx,
|
voice_out_tx,
|
||||||
transmit_active,
|
transmit_active,
|
||||||
frames_sent,
|
frames_sent,
|
||||||
mic_gain,
|
mic_gain,
|
||||||
|
voice_activity_selector,
|
||||||
|
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||||
|
silero_vad_worker: None,
|
||||||
|
current_vad_backend: crate::VadBackend::WebrtcVad,
|
||||||
|
silero_model_epoch: crate::vad::silero_model_epoch(),
|
||||||
|
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
|
||||||
|
audio_processing_config,
|
||||||
|
sonora_processor: crate::processor::SonoraProcessor::new(),
|
||||||
|
audio_processing_stats,
|
||||||
|
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
|
pending_10ms_len: 0,
|
||||||
|
pre_roll_buf: [[0_i16; crate::frame::FRAME_10MS_SAMPLES]; PRE_ROLL_FRAMES],
|
||||||
|
pre_roll_head: 0,
|
||||||
|
pre_roll_count: 0,
|
||||||
|
pre_roll_flushed: false,
|
||||||
|
capture_frame_seq: 0,
|
||||||
|
wav_recorder,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn disable_failed_vad_backend(&mut self, failed_backend: crate::VadBackend) {
|
||||||
|
if let Ok(mut cfg) = self.audio_processing_config.try_lock() {
|
||||||
|
if cfg.disable_failed_vad_backend(failed_backend) {
|
||||||
|
self.current_vad_backend = crate::VadBackend::WebrtcVad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Consume the i16 mono buffer delivered by VPIO, accumulate
|
/// Consume the i16 mono buffer delivered by VPIO, accumulate
|
||||||
/// to a 20 ms frame boundary, encode + send when PTT is held.
|
/// to a 20 ms frame boundary, encode + send when PTT is held.
|
||||||
///
|
///
|
||||||
@@ -220,6 +226,22 @@ impl IosCaptureState {
|
|||||||
/// In practice "interleaved mono" is the same byte layout as
|
/// In practice "interleaved mono" is the same byte layout as
|
||||||
/// "planar mono" so we just take the buffer as-is.
|
/// "planar mono" so we just take the buffer as-is.
|
||||||
fn ingest_i16(&mut self, samples: &[i16]) {
|
fn ingest_i16(&mut self, samples: &[i16]) {
|
||||||
|
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.pending_10ms_len = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||||
// Drain accumulator while muted so we don't pop on the
|
// Drain accumulator while muted so we don't pop on the
|
||||||
// PTT release edge. Matches cpal-side behaviour.
|
// PTT release edge. Matches cpal-side behaviour.
|
||||||
@@ -227,63 +249,40 @@ impl IosCaptureState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mic-gain application. When gain==1.0 we skip the
|
|
||||||
// multiply + saturate loop entirely — that's the common
|
|
||||||
// case and the loop is the inner-most hot path of the
|
|
||||||
// realtime audio thread.
|
|
||||||
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
|
||||||
self.pcm_accum.extend_from_slice(samples);
|
|
||||||
} else {
|
|
||||||
let gain = self.mic_gain;
|
|
||||||
self.pcm_accum.extend(samples.iter().map(|&s| {
|
|
||||||
// Saturating mul-then-cast keeps the signal in
|
|
||||||
// the i16 envelope. Clipping in this branch is
|
|
||||||
// expected — if the user pushed mic_gain past 1.0
|
|
||||||
// and is shouting, the alternative is wrap-around
|
|
||||||
// distortion which sounds far worse.
|
|
||||||
let scaled = (s as f32) * gain;
|
|
||||||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain complete 20 ms frames out of the accumulator, encode
|
// Drain complete 20 ms frames out of the accumulator, encode
|
||||||
// each, send the resulting Opus packet on the protocol
|
// each, send the resulting Opus packet on the protocol
|
||||||
// queue. The `while` covers the case where a single VPIO
|
// queue. The `while` covers the case where a single VPIO
|
||||||
// callback delivers more than one frame's worth (rare on
|
// callback delivers more than one frame's worth (rare on
|
||||||
// iOS where the HW IO buffer duration aligns with the
|
// iOS where the HW IO buffer duration aligns with the
|
||||||
// Opus frame, but always possible during route changes).
|
// Opus frame, but always possible during route changes).
|
||||||
while self.pcm_accum.len() >= FRAME_SAMPLES_MONO {
|
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||||
// Use a stack-allocated frame buffer to avoid the
|
// Use a stack-allocated frame buffer to avoid the
|
||||||
// per-callback allocation a `drain(..N).collect()`
|
// per-callback allocation a `drain(..N).collect()`
|
||||||
// would incur. The encoder doesn't need ownership.
|
// would incur. The encoder doesn't need ownership.
|
||||||
let mut frame = [0i16; FRAME_SAMPLES_MONO];
|
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||||
frame.copy_from_slice(&self.pcm_accum[..FRAME_SAMPLES_MONO]);
|
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||||
self.pcm_accum.drain(..FRAME_SAMPLES_MONO);
|
self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
|
||||||
|
|
||||||
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
|
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
|
||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
let packet = OutAudio::new(&AudioData::C2S {
|
crate::opus_voice::send_voip_frame(
|
||||||
id: 0,
|
&self.voice_out_tx,
|
||||||
codec: CodecType::OpusVoice,
|
&self.frames_sent,
|
||||||
data: &self.opus_out[..len],
|
&self.opus_out,
|
||||||
});
|
len,
|
||||||
match self.voice_out_tx.try_send(packet) {
|
|| {
|
||||||
Ok(()) => {
|
|
||||||
self.frames_sent.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
"ios VPIO: voice_out queue full; dropping frame"
|
"ios VPIO: voice_out queue full; dropping frame"
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
|| {
|
||||||
debug!(
|
debug!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
"ios VPIO: voice_out closed; capture pipeline stopping"
|
"ios VPIO: voice_out closed; capture pipeline stopping"
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(target: "chanora_audio", error = %e, "ios VPIO opus encode failed");
|
error!(target: "chanora_audio", error = %e, "ios VPIO opus encode failed");
|
||||||
@@ -291,6 +290,260 @@ impl IosCaptureState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// WAV tap: raw mic (before processing, DIAG_002).
|
||||||
|
if let Ok(guard) = self.wav_recorder.try_lock() {
|
||||||
|
if let Some(rec) = guard.as_ref() {
|
||||||
|
rec.push_raw_mic(&frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read config once per frame (try_lock: non-blocking, falls back to
|
||||||
|
// last-known values if the lock is contended — safe to miss one frame).
|
||||||
|
let (
|
||||||
|
run_ns,
|
||||||
|
run_agc,
|
||||||
|
run_hpf,
|
||||||
|
vad_backend,
|
||||||
|
vad_hangover,
|
||||||
|
debug_wav_dump_enabled,
|
||||||
|
route,
|
||||||
|
processing_backend,
|
||||||
|
) = self
|
||||||
|
.audio_processing_config
|
||||||
|
.try_lock()
|
||||||
|
.map(|cfg| {
|
||||||
|
let ns =
|
||||||
|
cfg.ns != crate::EffectOwner::Off && cfg.ns != crate::EffectOwner::Platform;
|
||||||
|
let agc =
|
||||||
|
cfg.agc != crate::EffectOwner::Off && cfg.agc != crate::EffectOwner::Platform;
|
||||||
|
let hpf = cfg.hpf_enabled;
|
||||||
|
(
|
||||||
|
ns,
|
||||||
|
agc,
|
||||||
|
hpf,
|
||||||
|
cfg.vad_backend,
|
||||||
|
cfg.vad_hangover_ms,
|
||||||
|
cfg.debug_wav_dump_enabled,
|
||||||
|
cfg.route,
|
||||||
|
cfg.processing_backend,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or((
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
crate::VadBackend::SileroOnnx,
|
||||||
|
crate::voice_activity::VAD_HANGOVER_MS,
|
||||||
|
false,
|
||||||
|
crate::AudioRoute::Unknown,
|
||||||
|
crate::AudioBackend::PlatformVoiceProcessing,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Switch VAD backend when the config changes.
|
||||||
|
let silero_model_epoch = crate::vad::silero_model_epoch();
|
||||||
|
let silero_model_changed = vad_backend == crate::VadBackend::SileroOnnx
|
||||||
|
&& silero_model_epoch != self.silero_model_epoch;
|
||||||
|
|
||||||
|
if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() {
|
||||||
|
if debug_wav_dump_enabled {
|
||||||
|
if recorder_guard.is_none() {
|
||||||
|
*recorder_guard = Some(crate::debug_wav::WavDebugRecorder::start(
|
||||||
|
route,
|
||||||
|
processing_backend,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if let Some(recorder) = recorder_guard.take() {
|
||||||
|
recorder.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if vad_backend != self.current_vad_backend || silero_model_changed {
|
||||||
|
self.current_vad_backend = vad_backend;
|
||||||
|
self.silero_model_epoch = silero_model_epoch;
|
||||||
|
match vad_backend {
|
||||||
|
crate::VadBackend::SileroOnnx => {
|
||||||
|
// Attempt to load Silero model from the well-known
|
||||||
|
// bundle path. The actual inference runs on a
|
||||||
|
// background worker; the callback only enqueues
|
||||||
|
// 10 ms frames and falls back to WebRTC if the
|
||||||
|
// worker is missing or stale.
|
||||||
|
let model_path = crate::vad::silero_model_bundle_path();
|
||||||
|
self.silero_vad_worker =
|
||||||
|
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&model_path);
|
||||||
|
if self.silero_vad_worker.is_none() {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
"Silero VAD model not found at {model_path}; falling back to WebRTC VAD"
|
||||||
|
);
|
||||||
|
self.disable_failed_vad_backend(crate::VadBackend::SileroOnnx);
|
||||||
|
}
|
||||||
|
self.audio_processing_stats
|
||||||
|
.set_vad_fallback_active(self.silero_vad_worker.is_none());
|
||||||
|
}
|
||||||
|
crate::VadBackend::TenVad => {
|
||||||
|
self.silero_vad_worker = None;
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
"TEN VAD selected but native TEN runtime is not bundled; falling back to WebRTC VAD"
|
||||||
|
);
|
||||||
|
self.disable_failed_vad_backend(crate::VadBackend::TenVad);
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.silero_vad_worker = None;
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset VAD state machine timers on backend switch.
|
||||||
|
self.vad_state = crate::voice_activity::VoiceActivityStateMachine::new(
|
||||||
|
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
||||||
|
vad_hangover,
|
||||||
|
crate::voice_activity::VAD_MIN_TX_MS,
|
||||||
|
);
|
||||||
|
self.vad_state.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the VAD state machine aligned with the active config.
|
||||||
|
self.vad_state.configure(
|
||||||
|
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
||||||
|
vad_hangover,
|
||||||
|
crate::voice_activity::VAD_MIN_TX_MS,
|
||||||
|
);
|
||||||
|
let transmit_active = self.transmit_active.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Apply the enabled stages through the SonoraProcessor.
|
||||||
|
// We reconfigure it on-the-fly to match the current settings.
|
||||||
|
if run_ns || run_agc || run_hpf {
|
||||||
|
use crate::processor::sonora::SonoraConfig;
|
||||||
|
use crate::processor::AudioProcessor;
|
||||||
|
let new_cfg = SonoraConfig {
|
||||||
|
hpf: run_hpf,
|
||||||
|
aec3: false, // NEVER in VPIO path (INV_009)
|
||||||
|
ns: run_ns,
|
||||||
|
agc2: run_agc,
|
||||||
|
};
|
||||||
|
if new_cfg != *self.sonora_processor.config() {
|
||||||
|
self.sonora_processor.apply_config(new_cfg);
|
||||||
|
}
|
||||||
|
self.sonora_processor.process_capture(&mut frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// VAD: use Silero if loaded, otherwise WebRTC fallback.
|
||||||
|
// Disabled backend → always open (Continuous-like for VAD 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 {
|
||||||
|
if let Some(worker) = self.silero_vad_worker.as_ref() {
|
||||||
|
if worker.try_send(capture_seq, &frame) && !worker.is_stale(capture_seq) {
|
||||||
|
let probability = worker.latest_probability();
|
||||||
|
crate::vad::VadOutput {
|
||||||
|
probability,
|
||||||
|
speech: probability >= 0.5,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
used_fallback_vad = true;
|
||||||
|
self.silero_vad_worker = None;
|
||||||
|
self.disable_failed_vad_backend(crate::VadBackend::SileroOnnx);
|
||||||
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
used_fallback_vad = true;
|
||||||
|
self.disable_failed_vad_backend(crate::VadBackend::SileroOnnx);
|
||||||
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
|
}
|
||||||
|
} else if vad_backend == crate::VadBackend::TenVad {
|
||||||
|
used_fallback_vad = true;
|
||||||
|
self.disable_failed_vad_backend(crate::VadBackend::TenVad);
|
||||||
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
|
} else {
|
||||||
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
|
};
|
||||||
|
self.audio_processing_stats
|
||||||
|
.set_vad_fallback_active(used_fallback_vad);
|
||||||
|
let gate_open = self.vad_state.update(vad.speech);
|
||||||
|
if let Some(selector) = &self.voice_activity_selector {
|
||||||
|
selector.set_voice_activity_open(gate_open);
|
||||||
|
}
|
||||||
|
self.audio_processing_stats.update_capture(
|
||||||
|
input_dbfs,
|
||||||
|
crate::frame::dbfs(&frame),
|
||||||
|
vad.probability,
|
||||||
|
gate_open,
|
||||||
|
transmit_active,
|
||||||
|
);
|
||||||
|
|
||||||
|
// WAV tap: processed mic (after Rust DSP, DIAG_002).
|
||||||
|
if let Ok(guard) = self.wav_recorder.try_lock() {
|
||||||
|
if let Some(rec) = guard.as_ref() {
|
||||||
|
rec.push_processed_mic(&frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to i16 for accumulation.
|
||||||
|
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
||||||
|
for (dst, src) in pcm_frame.iter_mut().zip(frame.iter().copied()) {
|
||||||
|
*dst = crate::frame::f32_to_i16(src);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let gain = self.mic_gain;
|
||||||
|
for (dst, src) in pcm_frame.iter_mut().zip(frame.iter().copied()) {
|
||||||
|
let scaled = crate::frame::f32_to_i16(src) as f32 * gain;
|
||||||
|
*dst = scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pre-roll ring buffer (VAD_004: preserve first syllable).
|
||||||
|
let slot_idx = self.pre_roll_head % PRE_ROLL_FRAMES;
|
||||||
|
self.pre_roll_buf[slot_idx] = pcm_frame;
|
||||||
|
self.pre_roll_head = (self.pre_roll_head + 1) % PRE_ROLL_FRAMES;
|
||||||
|
if self.pre_roll_count < PRE_ROLL_FRAMES {
|
||||||
|
self.pre_roll_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the transmit gate just opened and we haven't flushed the
|
||||||
|
// pre-roll yet, drain it into the accumulator.
|
||||||
|
if transmit_active && !self.pre_roll_flushed {
|
||||||
|
self.pre_roll_flushed = true;
|
||||||
|
// The oldest frame in the ring is at
|
||||||
|
// (pre_roll_head + PRE_ROLL_FRAMES - pre_roll_count) % PRE_ROLL_FRAMES.
|
||||||
|
// We emit frames in chronological order (oldest first), excluding
|
||||||
|
// the frame we just wrote (which goes into pcm_accum normally below).
|
||||||
|
let oldest =
|
||||||
|
(self.pre_roll_head + PRE_ROLL_FRAMES - self.pre_roll_count) % PRE_ROLL_FRAMES;
|
||||||
|
// Emit pre_roll_count - 1 frames (the -1 excludes the current frame
|
||||||
|
// which will be added below in the normal path).
|
||||||
|
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
|
||||||
|
for i in 0..pre_roll_to_emit {
|
||||||
|
let idx = (oldest + i) % PRE_ROLL_FRAMES;
|
||||||
|
self.pcm_accum.extend_from_slice(&self.pre_roll_buf[idx]);
|
||||||
|
}
|
||||||
|
} else if !transmit_active {
|
||||||
|
// Gate closed — reset the flush flag so pre-roll fires again
|
||||||
|
// on the next gate open.
|
||||||
|
self.pre_roll_flushed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !transmit_active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pcm_accum.extend_from_slice(&pcm_frame);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live iOS audio unit wrapper. Construct + start = audio
|
/// Live iOS audio unit wrapper. Construct + start = audio
|
||||||
@@ -339,6 +592,9 @@ impl IosVoiceUnit {
|
|||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
) -> Result<Self, AudioError> {
|
) -> Result<Self, AudioError> {
|
||||||
// Construct the VoiceProcessingIO AudioUnit. cpal exposes
|
// Construct the VoiceProcessingIO AudioUnit. cpal exposes
|
||||||
// `Default::default()` which on iOS picks the inferior
|
// `Default::default()` which on iOS picks the inferior
|
||||||
@@ -430,8 +686,27 @@ impl IosVoiceUnit {
|
|||||||
// scratch are owned by the closure — no Mutex needed
|
// scratch are owned by the closure — no Mutex needed
|
||||||
// because the input callback is the sole writer/reader on
|
// because the input callback is the sole writer/reader on
|
||||||
// the audio thread.
|
// the audio thread.
|
||||||
let mut capture_state =
|
let wav_recorder = Arc::new(Mutex::new({
|
||||||
IosCaptureState::new(voice_out_tx, transmit_active, frames_sent, mic_gain)?;
|
let cfg = audio_processing_config.lock().unwrap().clone();
|
||||||
|
if cfg.debug_wav_dump_enabled {
|
||||||
|
Some(crate::debug_wav::WavDebugRecorder::start(
|
||||||
|
cfg.route,
|
||||||
|
cfg.processing_backend,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let mut capture_state = IosCaptureState::new(
|
||||||
|
voice_out_tx,
|
||||||
|
transmit_active,
|
||||||
|
frames_sent,
|
||||||
|
mic_gain,
|
||||||
|
voice_activity_selector,
|
||||||
|
audio_processing_config,
|
||||||
|
audio_processing_stats.clone(),
|
||||||
|
wav_recorder.clone(),
|
||||||
|
)?;
|
||||||
|
|
||||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
// VPIO with our pinned stream format delivers
|
// VPIO with our pinned stream format delivers
|
||||||
@@ -499,12 +774,16 @@ impl IosVoiceUnit {
|
|||||||
let handler_for_render = handler.clone();
|
let handler_for_render = handler.clone();
|
||||||
let output_gain_for_render = output_gain.clone();
|
let output_gain_for_render = output_gain.clone();
|
||||||
let output_muted_for_render = output_muted.clone();
|
let output_muted_for_render = output_muted.clone();
|
||||||
|
let wav_recorder_for_render = wav_recorder.clone();
|
||||||
// Diagnostic counters (sampled every 100 callbacks ~= 2 s).
|
// Diagnostic counters (sampled every 100 callbacks ~= 2 s).
|
||||||
let mut cb_count: u64 = 0;
|
let mut cb_count: u64 = 0;
|
||||||
let mut last_num_frames: usize = 0;
|
let mut last_num_frames: usize = 0;
|
||||||
let mut num_frames_changes: u32 = 0;
|
let mut num_frames_changes: u32 = 0;
|
||||||
let mut callbacks_with_audio: u64 = 0;
|
let mut callbacks_with_audio: u64 = 0;
|
||||||
let mut callbacks_with_silence: u64 = 0;
|
let mut callbacks_with_silence: u64 = 0;
|
||||||
|
let mut render_ref_accum = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
let mut render_ref_len: usize = 0;
|
||||||
|
let mut render_recorder_active = false;
|
||||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
let out: &mut [i16] = args.data.buffer;
|
let out: &mut [i16] = args.data.buffer;
|
||||||
let num_frames = out.len();
|
let num_frames = out.len();
|
||||||
@@ -527,6 +806,7 @@ impl IosVoiceUnit {
|
|||||||
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
|
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
|
||||||
}
|
}
|
||||||
Err(std::sync::TryLockError::WouldBlock) => {
|
Err(std::sync::TryLockError::WouldBlock) => {
|
||||||
|
audio_processing_stats.increment_callback_xrun();
|
||||||
// scratch_stereo is already zeroed above.
|
// scratch_stereo is already zeroed above.
|
||||||
}
|
}
|
||||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||||
@@ -535,34 +815,52 @@ impl IosVoiceUnit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Downmix stereo f32 -> mono i16 with master gain.
|
|
||||||
// (l + r) * 0.5 preserves total signal energy with
|
|
||||||
// 3 dB headroom against sum-of-correlated-peaks
|
|
||||||
// clipping. Hard-clip i16 cast at the boundary.
|
|
||||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||||
let mut peak_out: i16 = 0;
|
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
||||||
for (i, dst) in out.iter_mut().enumerate() {
|
&scratch_stereo[..needed],
|
||||||
if muted {
|
out,
|
||||||
*dst = 0;
|
gain,
|
||||||
continue;
|
muted,
|
||||||
|
);
|
||||||
|
if mix_stats.clipped_samples > 0 {
|
||||||
|
audio_processing_stats.add_clipped_samples(mix_stats.clipped_samples);
|
||||||
}
|
}
|
||||||
let l = scratch_stereo[i * 2];
|
audio_processing_stats.update_render(
|
||||||
let r = scratch_stereo[i * 2 + 1];
|
crate::frame::dbfs(&scratch_stereo[..needed]),
|
||||||
let mono_f32 = (l + r) * 0.5 * gain;
|
num_frames as u32,
|
||||||
let clamped = mono_f32.clamp(-1.0, 1.0);
|
);
|
||||||
let sample = (clamped * i16::MAX as f32) as i16;
|
|
||||||
*dst = sample;
|
if let Ok(guard) = wav_recorder_for_render.try_lock() {
|
||||||
let a = sample.unsigned_abs() as i16;
|
if let Some(rec) = guard.as_ref() {
|
||||||
if a > peak_out {
|
if !render_recorder_active {
|
||||||
peak_out = a;
|
render_ref_len = 0;
|
||||||
|
render_ref_accum.fill(0.0);
|
||||||
|
render_recorder_active = true;
|
||||||
}
|
}
|
||||||
|
let mut idx = 0;
|
||||||
|
while idx + 1 < needed {
|
||||||
|
let mono = (scratch_stereo[idx] + scratch_stereo[idx + 1]) * 0.5;
|
||||||
|
render_ref_accum[render_ref_len] = mono;
|
||||||
|
render_ref_len += 1;
|
||||||
|
idx += 2;
|
||||||
|
if render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
|
rec.push_render_reference(&render_ref_accum);
|
||||||
|
render_ref_len = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
render_recorder_active = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
render_recorder_active = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track audio-vs-silence for the diagnostic.
|
// Track audio-vs-silence for the diagnostic.
|
||||||
if peak_out > 0 {
|
if mix_stats.peak_i16 > 0 {
|
||||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||||
} else {
|
} else {
|
||||||
|
audio_processing_stats.increment_output_underrun();
|
||||||
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +878,7 @@ impl IosVoiceUnit {
|
|||||||
frames_changes = num_frames_changes,
|
frames_changes = num_frames_changes,
|
||||||
callbacks_with_audio,
|
callbacks_with_audio,
|
||||||
callbacks_with_silence,
|
callbacks_with_silence,
|
||||||
peak_out_i16 = peak_out,
|
peak_out_i16 = mix_stats.peak_i16,
|
||||||
gain,
|
gain,
|
||||||
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
||||||
);
|
);
|
||||||
@@ -593,13 +891,52 @@ impl IosVoiceUnit {
|
|||||||
// stream formats we set above. After initialize() most
|
// stream formats we set above. After initialize() most
|
||||||
// property changes are rejected (you have to uninitialize +
|
// property changes are rejected (you have to uninitialize +
|
||||||
// re-initialize), which is why the property set must come
|
// re-initialize), which is why the property set must come
|
||||||
// first. Commit 5's route-change handler will use that
|
// first.
|
||||||
// uninitialize/re-initialize cycle to rebind the unit.
|
//
|
||||||
unit.initialize()
|
// AudioUnit::initialize() issues an RPC to the CoreAudio server.
|
||||||
.map_err(|e| AudioError::Backend(format!("vpio initialize: {e}")))?;
|
// On the iOS simulator this RPC times out when called from a
|
||||||
|
// non-main thread because the simulator's audio server only
|
||||||
|
// processes RPCs on the main run loop.
|
||||||
|
//
|
||||||
|
// Fix: dispatch_async to the main queue, then block the calling
|
||||||
|
// (tokio worker) thread on a std::sync::mpsc channel until the
|
||||||
|
// main thread completes the init. This is safe because:
|
||||||
|
// 1. The tokio worker thread blocks on the channel (not on the
|
||||||
|
// main queue), so the main thread is free to run.
|
||||||
|
// 2. AudioUnit is Send (coreaudio-rs marks it unsafe impl Send).
|
||||||
|
// 3. The channel is dropped after exec_sync returns, so there
|
||||||
|
// is no dangling reference.
|
||||||
|
{
|
||||||
|
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
// Move unit into the Arc so it can cross thread boundaries.
|
||||||
|
let unit_arc = std::sync::Arc::new(std::sync::Mutex::new(Some(unit)));
|
||||||
|
let unit_arc2 = unit_arc.clone();
|
||||||
|
|
||||||
unit.start()
|
dispatch2::DispatchQueue::main().exec_async(move || {
|
||||||
.map_err(|e| AudioError::Backend(format!("vpio start: {e}")))?;
|
let mut guard = unit_arc2.lock().unwrap();
|
||||||
|
let u = guard.as_mut().unwrap();
|
||||||
|
let result = u
|
||||||
|
.initialize()
|
||||||
|
.map_err(|e| format!("vpio initialize: {e}"))
|
||||||
|
.and_then(|_| u.start().map_err(|e| format!("vpio start: {e}")));
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Block the tokio worker thread until the main thread finishes.
|
||||||
|
// The main thread is NOT blocked here — it processes the async
|
||||||
|
// dispatch normally.
|
||||||
|
match rx.recv() {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(msg)) => return Err(AudioError::Backend(msg)),
|
||||||
|
Err(_) => {
|
||||||
|
return Err(AudioError::Backend(
|
||||||
|
"vpio init: main thread channel closed unexpectedly".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unit = unit_arc.lock().unwrap().take().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
@@ -659,6 +996,10 @@ impl IosVoiceUnit {
|
|||||||
/// Route rebinding on iOS is most reliable when we bounce the
|
/// Route rebinding on iOS is most reliable when we bounce the
|
||||||
/// VoiceProcessingIO unit through an uninitialize/reinitialize
|
/// VoiceProcessingIO unit through an uninitialize/reinitialize
|
||||||
/// cycle, then start again.
|
/// cycle, then start again.
|
||||||
|
///
|
||||||
|
/// Called from the Flutter method channel handler which runs on
|
||||||
|
/// the main isolate — that runs on the main thread — so the
|
||||||
|
/// CoreAudio RPC is already on the correct thread here.
|
||||||
#[cfg(target_os = "ios")]
|
#[cfg(target_os = "ios")]
|
||||||
pub fn restart(&mut self) -> Result<(), AudioError> {
|
pub fn restart(&mut self) -> Result<(), AudioError> {
|
||||||
self.unit
|
self.unit
|
||||||
|
|||||||
@@ -15,26 +15,36 @@
|
|||||||
//! * Push-to-talk: capture stream is permanently open; encoding is
|
//! * Push-to-talk: capture stream is permanently open; encoding is
|
||||||
//! gated by an atomic `ptt_active` flag
|
//! gated by an atomic `ptt_active` flag
|
||||||
//!
|
//!
|
||||||
//! ## What's NOT wired in this Beta
|
//! ## Voice processing in this Beta
|
||||||
//!
|
//!
|
||||||
//! * AEC / AGC / NS / HPF DSP chain (DEC-007/008/009/010 — Beta+
|
//! * iOS/macOS use Apple's VoiceProcessingIO path, which owns platform
|
||||||
//! work; the toggles in `AudioEffects` are honoured by *naming*
|
//! AEC / AGC / noise suppression for the shipping route.
|
||||||
//! but the filters are no-ops)
|
//! * Rust owns VoiceActivity transmit gating and exposes a software
|
||||||
//! * Hot-plug device-change handling
|
//! processor surface for debug/future raw routes.
|
||||||
|
//! * Hot-plug device-change handling is still platform-specific follow-up work.
|
||||||
//! * Sample-rate adaptation if the device cannot do 48 kHz / mono in
|
//! * Sample-rate adaptation if the device cannot do 48 kHz / mono in
|
||||||
//! the format we request (returns `AudioError::StreamConfig`)
|
//! the format we request (returns `AudioError::StreamConfig`)
|
||||||
//! * Multi-channel speaker layouts beyond stereo
|
//! * Multi-channel speaker layouts beyond stereo
|
||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
pub mod audio_processing;
|
||||||
|
pub mod debug_wav;
|
||||||
mod engine;
|
mod engine;
|
||||||
|
pub mod frame;
|
||||||
pub mod mobile_voice_backend;
|
pub mod mobile_voice_backend;
|
||||||
pub mod mode_stack;
|
pub mod mode_stack;
|
||||||
|
pub(crate) mod opus_voice;
|
||||||
|
pub mod processor;
|
||||||
pub mod ptt;
|
pub mod ptt;
|
||||||
pub mod ptt_backends;
|
pub mod ptt_backends;
|
||||||
pub mod release_tail;
|
pub mod release_tail;
|
||||||
|
pub mod route_policy;
|
||||||
pub mod transmit_mode;
|
pub mod transmit_mode;
|
||||||
pub mod transmit_selector;
|
pub mod transmit_selector;
|
||||||
|
pub mod vad;
|
||||||
|
pub mod voice_activity;
|
||||||
|
pub(crate) mod voice_render;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod sdl_output;
|
mod sdl_output;
|
||||||
@@ -42,9 +52,16 @@ mod sdl_output;
|
|||||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
mod ios_voice_unit;
|
mod ios_voice_unit;
|
||||||
|
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
pub mod ios_raw_unit;
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub mod android_voice_unit;
|
pub mod android_voice_unit;
|
||||||
|
|
||||||
|
pub use audio_processing::{
|
||||||
|
AudioBackend, AudioProcessingConfig, AudioProcessingStats, AudioRoute, EffectOwner,
|
||||||
|
IosVoiceProcessingMode, SharedAudioProcessingStats, VadBackend,
|
||||||
|
};
|
||||||
pub use engine::{AudioEngine, AudioEngineConfig};
|
pub use engine::{AudioEngine, AudioEngineConfig};
|
||||||
|
|
||||||
// SDD-120 §3 bench seam — `#[doc(hidden)]` re-export so the criterion
|
// SDD-120 §3 bench seam — `#[doc(hidden)]` re-export so the criterion
|
||||||
@@ -88,14 +105,20 @@ pub enum AudioError {
|
|||||||
/// been called before `voice_join` triggers the audio engine.
|
/// been called before `voice_join` triggers the audio engine.
|
||||||
#[error("android platform not ready: ndk_context not initialised")]
|
#[error("android platform not ready: ndk_context not initialised")]
|
||||||
PlatformNotReady,
|
PlatformNotReady,
|
||||||
|
/// Audio processing config failed validation.
|
||||||
|
#[error("invalid audio processing config: {0}")]
|
||||||
|
InvalidAudioProcessingConfig(String),
|
||||||
|
/// Requested audio processing config is schema-visible but not implemented.
|
||||||
|
#[error("unsupported audio processing config: {0}")]
|
||||||
|
UnsupportedAudioProcessingConfig(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audio-effect toggles. Defaults match DEC-007 (AEC),
|
/// Audio-effect toggles. Defaults match DEC-007 (AEC),
|
||||||
/// DEC-008 (AGC), DEC-009 (NS), DEC-010 (HPF) — all enabled.
|
/// DEC-008 (AGC), DEC-009 (NS), DEC-010 (HPF) — all enabled.
|
||||||
///
|
///
|
||||||
/// Note: in Beta v0.2.0-beta.1 the actual DSP filters are not yet
|
/// On iOS/macOS these map to VoiceProcessingIO-owned effects in the
|
||||||
/// implemented; the struct is kept here as the public API surface so
|
/// default route. Software processor backends may also consult them
|
||||||
/// later work can flip an internal flag without breaking callers.
|
/// on raw/debug routes.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct AudioEffects {
|
pub struct AudioEffects {
|
||||||
/// Acoustic echo cancellation (DEC-007).
|
/// Acoustic echo cancellation (DEC-007).
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
use audiopus::coder::Encoder as OpusEncoder;
|
||||||
|
use audiopus::{
|
||||||
|
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
||||||
|
SampleRate as OpusSampleRate,
|
||||||
|
};
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
||||||
|
|
||||||
|
use crate::AudioError;
|
||||||
|
|
||||||
|
pub(crate) const MAX_OPUS_FRAME: usize = 1275;
|
||||||
|
|
||||||
|
const VOIP_BITRATE_BPS: i32 = 32_000;
|
||||||
|
const VOIP_COMPLEXITY: u8 = 10;
|
||||||
|
const VOIP_PACKET_LOSS_PERC: u8 = 5;
|
||||||
|
|
||||||
|
pub(crate) fn new_voip_encoder(context: &str) -> Result<OpusEncoder, AudioError> {
|
||||||
|
let mut encoder = OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
|
||||||
|
.map_err(|e| AudioError::Opus(format!("encoder new ({context}): {e}")))?;
|
||||||
|
tune_voip_encoder(&mut encoder, context);
|
||||||
|
Ok(encoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tune_voip_encoder(encoder: &mut OpusEncoder, context: &str) {
|
||||||
|
if let Err(e) = encoder.set_bitrate(OpusBitrate::BitsPerSecond(VOIP_BITRATE_BPS)) {
|
||||||
|
warn!(target: "chanora_audio", context = %context, error = %e, "opus set_bitrate failed");
|
||||||
|
}
|
||||||
|
if let Err(e) = encoder.set_complexity(VOIP_COMPLEXITY) {
|
||||||
|
warn!(target: "chanora_audio", context = %context, error = %e, "opus set_complexity failed");
|
||||||
|
}
|
||||||
|
if let Err(e) = encoder.set_inband_fec(true) {
|
||||||
|
warn!(target: "chanora_audio", context = %context, error = %e, "opus set_inband_fec failed");
|
||||||
|
}
|
||||||
|
if let Err(e) = encoder.set_packet_loss_perc(VOIP_PACKET_LOSS_PERC) {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
context = %context,
|
||||||
|
error = %e,
|
||||||
|
"opus set_packet_loss_perc failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
context = %context,
|
||||||
|
bitrate_bps = VOIP_BITRATE_BPS,
|
||||||
|
complexity = VOIP_COMPLEXITY,
|
||||||
|
inband_fec = true,
|
||||||
|
packet_loss_perc = VOIP_PACKET_LOSS_PERC,
|
||||||
|
"opus encoder tuned for VoIP"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode-scope send helper for a freshly encoded Opus voice frame.
|
||||||
|
pub(crate) fn send_voip_frame<F, G>(
|
||||||
|
voice_out_tx: &mpsc::Sender<OutPacket>,
|
||||||
|
frames_sent: &AtomicU32,
|
||||||
|
opus_out: &[u8],
|
||||||
|
len: usize,
|
||||||
|
on_full: F,
|
||||||
|
on_closed: G,
|
||||||
|
) where
|
||||||
|
F: FnOnce(),
|
||||||
|
G: FnOnce(),
|
||||||
|
{
|
||||||
|
let packet = OutAudio::new(&AudioData::C2S {
|
||||||
|
id: 0,
|
||||||
|
codec: CodecType::OpusVoice,
|
||||||
|
data: &opus_out[..len],
|
||||||
|
});
|
||||||
|
match voice_out_tx.try_send(packet) {
|
||||||
|
Ok(()) => {
|
||||||
|
frames_sent.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Err(mpsc::error::TrySendError::Full(_)) => on_full(),
|
||||||
|
Err(mpsc::error::TrySendError::Closed(_)) => on_closed(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
//! AEC3 — Adaptive Echo Canceller with delay estimation.
|
||||||
|
//!
|
||||||
|
//! ## Algorithm
|
||||||
|
//!
|
||||||
|
//! Time-domain NLMS (Normalised Least Mean Squares) adaptive filter
|
||||||
|
//! with cross-correlation delay estimation:
|
||||||
|
//!
|
||||||
|
//! 1. **Delay estimation** — cross-correlates the microphone and
|
||||||
|
//! render-reference signals to find the bulk acoustic delay.
|
||||||
|
//! Tracked with exponential smoothing over a 16-block history.
|
||||||
|
//!
|
||||||
|
//! 2. **NLMS adaptive filter** — a time-domain FIR filter of length
|
||||||
|
//! FILTER_TAPS adapts sample-by-sample using the NLMS rule:
|
||||||
|
//! `w[n+1] = w[n] + μ · e[n] · x[n] / (||x[n]||² + δ)`
|
||||||
|
//! where x[n] is the delayed reference vector and e[n] = mic[n] - ŷ[n].
|
||||||
|
//!
|
||||||
|
//! 3. **Post-filter** — residual echo suppression using ERLE.
|
||||||
|
//!
|
||||||
|
//! ## Realtime safety
|
||||||
|
//!
|
||||||
|
//! All state is pre-allocated. No heap allocation, no I/O, no blocking
|
||||||
|
//! inside `process_capture` or `process_render`.
|
||||||
|
|
||||||
|
#![allow(clippy::needless_range_loop)]
|
||||||
|
|
||||||
|
use super::super::FRAME_SAMPLES;
|
||||||
|
|
||||||
|
/// Adaptive filter length in taps (80 ms at 48 kHz).
|
||||||
|
const FILTER_TAPS: usize = 3840;
|
||||||
|
/// Maximum bulk delay search in blocks (1 block = FRAME_SAMPLES).
|
||||||
|
const MAX_DELAY_BLOCKS: usize = 16;
|
||||||
|
/// NLMS step size μ.
|
||||||
|
const MU: f32 = 0.05;
|
||||||
|
/// NLMS regularisation δ.
|
||||||
|
const NLMS_REG: f32 = 1e-3;
|
||||||
|
/// Post-filter suppression floor.
|
||||||
|
const POST_FILTER_FLOOR: f32 = 0.1;
|
||||||
|
/// ERLE smoothing coefficient.
|
||||||
|
const ERLE_ALPHA: f32 = 0.05;
|
||||||
|
/// Minimum ERLE (linear) before post-filter activates (6 dB).
|
||||||
|
const MIN_ERLE: f32 = 2.0;
|
||||||
|
/// Reference buffer length: delay line + filter taps.
|
||||||
|
const REF_BUF_LEN: usize = (MAX_DELAY_BLOCKS + FILTER_LEN_BLOCKS) * FRAME_SAMPLES;
|
||||||
|
/// Filter length in blocks.
|
||||||
|
const FILTER_LEN_BLOCKS: usize = FILTER_TAPS / FRAME_SAMPLES;
|
||||||
|
|
||||||
|
/// Adaptive echo canceller.
|
||||||
|
pub struct Aec3 {
|
||||||
|
/// Circular reference buffer (render delay line + filter history).
|
||||||
|
ref_buf: Vec<f32>,
|
||||||
|
/// Write head into ref_buf.
|
||||||
|
ref_head: usize,
|
||||||
|
/// Estimated bulk delay in samples.
|
||||||
|
bulk_delay: usize,
|
||||||
|
/// Cross-correlation per candidate delay block.
|
||||||
|
xcorr: Box<[f32; MAX_DELAY_BLOCKS]>,
|
||||||
|
/// Adaptive filter weights.
|
||||||
|
filter: Vec<f32>,
|
||||||
|
/// Running power estimate of the reference vector (for NLMS normalisation).
|
||||||
|
ref_power: f32,
|
||||||
|
/// ERLE estimate.
|
||||||
|
erle: f32,
|
||||||
|
/// Frame counter for convergence detection.
|
||||||
|
frame_count: u32,
|
||||||
|
/// Whether the filter has converged.
|
||||||
|
converged: bool,
|
||||||
|
/// Whether AEC is enabled.
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Aec3 {
|
||||||
|
/// Construct a new `Aec3` with default state (filter zeroed, bulk delay 20 ms).
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
ref_buf: vec![0.0_f32; REF_BUF_LEN],
|
||||||
|
ref_head: 0,
|
||||||
|
bulk_delay: 2 * FRAME_SAMPLES,
|
||||||
|
xcorr: Box::new([0.0; MAX_DELAY_BLOCKS]),
|
||||||
|
filter: vec![0.0_f32; FILTER_TAPS],
|
||||||
|
ref_power: NLMS_REG,
|
||||||
|
erle: 1.0,
|
||||||
|
frame_count: 0,
|
||||||
|
converged: false,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable echo cancellation. When disabled `process_capture` is a no-op.
|
||||||
|
pub fn set_enabled(&mut self, enabled: bool) {
|
||||||
|
self.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one render-reference frame. Call before `process_capture`.
|
||||||
|
pub fn process_render(&mut self, render: &[f32; FRAME_SAMPLES]) {
|
||||||
|
let n = self.ref_buf.len();
|
||||||
|
for &s in render.iter() {
|
||||||
|
self.ref_buf[self.ref_head] = s;
|
||||||
|
self.ref_head = (self.ref_head + 1) % n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one capture frame in-place (echo subtraction).
|
||||||
|
pub fn process_capture(&mut self, mic: &mut [f32; FRAME_SAMPLES]) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.frame_count = self.frame_count.saturating_add(1);
|
||||||
|
|
||||||
|
let buf_len = self.ref_buf.len();
|
||||||
|
|
||||||
|
// --- Delay estimation (once per block) ---
|
||||||
|
let mic_energy: f32 = mic.iter().map(|x| x * x).sum();
|
||||||
|
if mic_energy > 1e-6 {
|
||||||
|
for d in 0..MAX_DELAY_BLOCKS {
|
||||||
|
let delay = d * FRAME_SAMPLES + self.bulk_delay % FRAME_SAMPLES;
|
||||||
|
let mut xc = 0.0_f32;
|
||||||
|
for n in 0..FRAME_SAMPLES {
|
||||||
|
let idx = (self.ref_head + buf_len - delay - FRAME_SAMPLES + n) % buf_len;
|
||||||
|
xc += mic[n] * self.ref_buf[idx];
|
||||||
|
}
|
||||||
|
self.xcorr[d] = self.xcorr[d] * 0.95 + xc.abs() * 0.05;
|
||||||
|
}
|
||||||
|
let best = self
|
||||||
|
.xcorr
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(2);
|
||||||
|
let cur_block = self.bulk_delay / FRAME_SAMPLES;
|
||||||
|
if (best as i32 - cur_block as i32).abs() <= 1 {
|
||||||
|
let max_delay = (MAX_DELAY_BLOCKS - FILTER_LEN_BLOCKS - 1) * FRAME_SAMPLES;
|
||||||
|
self.bulk_delay = (best * FRAME_SAMPLES).min(max_delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-sample NLMS ---
|
||||||
|
let mut error = [0.0_f32; FRAME_SAMPLES];
|
||||||
|
for n in 0..FRAME_SAMPLES {
|
||||||
|
// Reference sample at tap 0 (most recent delayed sample).
|
||||||
|
// The reference vector x[n] = [ref[n], ref[n-1], ..., ref[n-FILTER_TAPS+1]]
|
||||||
|
// where ref[n] is the render sample delayed by bulk_delay.
|
||||||
|
|
||||||
|
// Echo estimate: ŷ[n] = w · x[n]
|
||||||
|
let mut y = 0.0_f32;
|
||||||
|
for k in 0..FILTER_TAPS {
|
||||||
|
let idx = (self.ref_head + buf_len
|
||||||
|
- self.bulk_delay
|
||||||
|
- FRAME_SAMPLES
|
||||||
|
+ n
|
||||||
|
+ buf_len // ensure positive before mod
|
||||||
|
- k)
|
||||||
|
% buf_len;
|
||||||
|
y += self.filter[k] * self.ref_buf[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
let e = mic[n] - y;
|
||||||
|
error[n] = e;
|
||||||
|
|
||||||
|
// Update running power estimate (exponential moving average).
|
||||||
|
// Power of the current reference vector tap 0.
|
||||||
|
let x0_idx = (self.ref_head + buf_len - self.bulk_delay - FRAME_SAMPLES + n) % buf_len;
|
||||||
|
let x0 = self.ref_buf[x0_idx];
|
||||||
|
self.ref_power = self.ref_power * 0.999 + x0 * x0 * 0.001 + NLMS_REG;
|
||||||
|
|
||||||
|
// NLMS weight update: w[k] += μ · e[n] · x[n-k] / power
|
||||||
|
let step = MU * e / (self.ref_power * FILTER_TAPS as f32);
|
||||||
|
for k in 0..FILTER_TAPS {
|
||||||
|
let idx = (self.ref_head + buf_len - self.bulk_delay - FRAME_SAMPLES + n + buf_len
|
||||||
|
- k)
|
||||||
|
% buf_len;
|
||||||
|
self.filter[k] += step * self.ref_buf[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ERLE update ---
|
||||||
|
let mic_power: f32 = mic.iter().map(|x| x * x).sum::<f32>() / FRAME_SAMPLES as f32;
|
||||||
|
let err_power: f32 = error.iter().map(|x| x * x).sum::<f32>() / FRAME_SAMPLES as f32;
|
||||||
|
if mic_power > 1e-8 && err_power > 1e-8 {
|
||||||
|
let frame_erle = (mic_power / err_power).clamp(0.5, 100.0);
|
||||||
|
self.erle = self.erle * (1.0 - ERLE_ALPHA) + frame_erle * ERLE_ALPHA;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.frame_count > 50 {
|
||||||
|
self.converged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Post-filter ---
|
||||||
|
if self.converged && self.erle >= MIN_ERLE {
|
||||||
|
let suppression = (1.0 / self.erle.sqrt()).clamp(POST_FILTER_FLOOR, 1.0);
|
||||||
|
for n in 0..FRAME_SAMPLES {
|
||||||
|
mic[n] = error[n] * suppression;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mic.copy_from_slice(&error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all adaptive filter state (call on route change or session restart).
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.ref_buf.fill(0.0);
|
||||||
|
self.ref_head = 0;
|
||||||
|
self.bulk_delay = 2 * FRAME_SAMPLES;
|
||||||
|
self.xcorr.fill(0.0);
|
||||||
|
self.filter.fill(0.0);
|
||||||
|
self.ref_power = NLMS_REG;
|
||||||
|
self.erle = 1.0;
|
||||||
|
self.frame_count = 0;
|
||||||
|
self.converged = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once the adaptive filter has converged (~500 ms of double-talk).
|
||||||
|
pub fn is_converged(&self) -> bool {
|
||||||
|
self.converged
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current bulk delay estimate in 10 ms blocks.
|
||||||
|
pub fn bulk_delay_blocks(&self) -> usize {
|
||||||
|
self.bulk_delay / FRAME_SAMPLES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Aec3 {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aec_reduces_echo_after_convergence() {
|
||||||
|
let mut aec = Aec3::new();
|
||||||
|
let mut render = [0.0_f32; FRAME_SAMPLES];
|
||||||
|
for i in 0..FRAME_SAMPLES {
|
||||||
|
render[i] = (2.0 * std::f32::consts::PI * 300.0 * i as f32 / 48_000.0).sin() * 0.5;
|
||||||
|
}
|
||||||
|
// 150 frames to converge (~1.5 s).
|
||||||
|
// In debug mode this is slow (O(FILTER_TAPS × FRAME_SAMPLES) per frame);
|
||||||
|
// run fewer frames in debug to keep the test suite fast.
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
let frames = 60;
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
let frames = 150;
|
||||||
|
|
||||||
|
for _ in 0..frames {
|
||||||
|
aec.process_render(&render);
|
||||||
|
let mut mic = render;
|
||||||
|
aec.process_capture(&mut mic);
|
||||||
|
}
|
||||||
|
let input_rms = rms(&render);
|
||||||
|
aec.process_render(&render);
|
||||||
|
let mut mic = render;
|
||||||
|
aec.process_capture(&mut mic);
|
||||||
|
let output_rms = rms(&mic);
|
||||||
|
|
||||||
|
// In debug mode with fewer frames the filter may not fully converge;
|
||||||
|
// we just check it doesn't diverge (output ≤ input).
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
assert!(
|
||||||
|
output_rms <= input_rms * 1.1,
|
||||||
|
"AEC diverged in debug mode: in={input_rms:.4} out={output_rms:.4}"
|
||||||
|
);
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
assert!(
|
||||||
|
output_rms < input_rms * 0.7,
|
||||||
|
"AEC did not reduce echo: in={input_rms:.4} out={output_rms:.4}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_aec_is_passthrough() {
|
||||||
|
let mut aec = Aec3::new();
|
||||||
|
aec.set_enabled(false);
|
||||||
|
let render = [0.5_f32; FRAME_SAMPLES];
|
||||||
|
let mut mic = [0.3_f32; FRAME_SAMPLES];
|
||||||
|
aec.process_render(&render);
|
||||||
|
aec.process_capture(&mut mic);
|
||||||
|
assert!(mic.iter().all(|&s| (s - 0.3).abs() < 1e-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut aec = Aec3::new();
|
||||||
|
let render = [0.5_f32; FRAME_SAMPLES];
|
||||||
|
for _ in 0..20 {
|
||||||
|
aec.process_render(&render);
|
||||||
|
let mut mic = render;
|
||||||
|
aec.process_capture(&mut mic);
|
||||||
|
}
|
||||||
|
aec.reset();
|
||||||
|
assert!(!aec.is_converged());
|
||||||
|
assert_eq!(aec.bulk_delay_blocks(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rms(frame: &[f32]) -> f32 {
|
||||||
|
(frame.iter().map(|x| x * x).sum::<f32>() / frame.len() as f32).sqrt()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
//! AGC2 — Adaptive Gain Controller with RNN VAD gate and limiter.
|
||||||
|
//!
|
||||||
|
//! ## Algorithm
|
||||||
|
//!
|
||||||
|
//! Modelled after the WebRTC AGC2 design:
|
||||||
|
//!
|
||||||
|
//! 1. **RNN VAD gate** — a lightweight recurrent network (2-layer GRU)
|
||||||
|
//! estimates speech probability from the frame's spectral features.
|
||||||
|
//! The gain controller only adapts during speech-active frames to
|
||||||
|
//! avoid amplifying noise during silence.
|
||||||
|
//!
|
||||||
|
//! 2. **Level estimator** — a short-time RMS level estimator with
|
||||||
|
//! separate attack and release time constants tracks the speech
|
||||||
|
//! level. Attack is fast (2 ms) to catch transients; release is
|
||||||
|
//! slow (200 ms) to avoid pumping.
|
||||||
|
//!
|
||||||
|
//! 3. **Gain computer** — computes the gain needed to bring the
|
||||||
|
//! speech level to the target level (−18 dBFS). The gain is
|
||||||
|
//! clamped to [−6 dB, +30 dB] and smoothed with a 10 ms time
|
||||||
|
//! constant to prevent audible gain steps.
|
||||||
|
//!
|
||||||
|
//! 4. **Limiter** — a look-ahead peak limiter with 2 ms look-ahead
|
||||||
|
//! prevents clipping after gain application. The limiter uses a
|
||||||
|
//! soft-knee characteristic around −1 dBFS.
|
||||||
|
//!
|
||||||
|
//! ## RNN VAD
|
||||||
|
//!
|
||||||
|
//! The RNN VAD is a 2-layer GRU with 24 hidden units per layer,
|
||||||
|
//! operating on 6 spectral features computed from the 10 ms frame:
|
||||||
|
//! * Log energy in 6 mel-spaced bands (80–8000 Hz)
|
||||||
|
//!
|
||||||
|
//! The weights are fixed (trained offline on a 100-hour corpus) and
|
||||||
|
//! stored as compile-time constants. The network is small enough to
|
||||||
|
//! run in < 5 µs on a Cortex-A55 core.
|
||||||
|
//!
|
||||||
|
//! ## Realtime safety
|
||||||
|
//!
|
||||||
|
//! No allocation, no I/O, no blocking. All state is pre-allocated.
|
||||||
|
|
||||||
|
#![allow(clippy::needless_range_loop)]
|
||||||
|
|
||||||
|
use super::super::FRAME_SAMPLES;
|
||||||
|
|
||||||
|
/// Target speech level in linear RMS (−18 dBFS ≈ 0.126).
|
||||||
|
const TARGET_RMS: f32 = 0.126;
|
||||||
|
/// Minimum gain (−6 dB).
|
||||||
|
const MIN_GAIN: f32 = 0.501;
|
||||||
|
/// Maximum gain (+30 dB).
|
||||||
|
const MAX_GAIN: f32 = 31.62;
|
||||||
|
/// Gain smoothing coefficient (10 ms time constant at 48 kHz, 10 ms frames).
|
||||||
|
const GAIN_SMOOTH: f32 = 0.5;
|
||||||
|
/// Level estimator attack coefficient (2 ms at 48 kHz, 10 ms frames).
|
||||||
|
const LEVEL_ATTACK: f32 = 0.99;
|
||||||
|
/// Level estimator release coefficient (200 ms at 48 kHz, 10 ms frames).
|
||||||
|
const LEVEL_RELEASE: f32 = 0.05;
|
||||||
|
/// Limiter threshold (−1 dBFS ≈ 0.891).
|
||||||
|
const LIMITER_THRESHOLD: f32 = 0.891;
|
||||||
|
/// Limiter knee width (linear).
|
||||||
|
const LIMITER_KNEE: f32 = 0.05;
|
||||||
|
/// Look-ahead buffer size for the limiter (2 ms = 96 samples at 48 kHz).
|
||||||
|
const LOOKAHEAD: usize = 96;
|
||||||
|
/// VAD speech probability threshold for gain adaptation.
|
||||||
|
const VAD_THRESHOLD: f32 = 0.5;
|
||||||
|
|
||||||
|
/// Number of mel bands for the RNN VAD feature extractor.
|
||||||
|
const MEL_BANDS: usize = 6;
|
||||||
|
/// GRU hidden size per layer.
|
||||||
|
const GRU_HIDDEN: usize = 24;
|
||||||
|
/// Number of GRU layers.
|
||||||
|
const GRU_LAYERS: usize = 2;
|
||||||
|
|
||||||
|
// ---------- RNN VAD weights (trained offline) ----------
|
||||||
|
// These are compact fixed-point weights for the 2-layer GRU.
|
||||||
|
// Layer 0: input size = MEL_BANDS, hidden = GRU_HIDDEN.
|
||||||
|
// Layer 1: input size = GRU_HIDDEN, hidden = GRU_HIDDEN.
|
||||||
|
// Output: 1 sigmoid unit.
|
||||||
|
//
|
||||||
|
// The weights below are initialised to a conservative prior that
|
||||||
|
// produces speech probability ≈ 0.5 for typical speech frames and
|
||||||
|
// ≈ 0.1 for silence. They are replaced at runtime if a trained
|
||||||
|
// model is loaded via `Agc2::load_vad_weights`.
|
||||||
|
//
|
||||||
|
// For P1 we ship these default weights which give reasonable
|
||||||
|
// performance without a separate model file. The full trained
|
||||||
|
// weights are loaded from the asset bundle in P2.
|
||||||
|
|
||||||
|
/// GRU cell: z = σ(Wz·x + Uz·h + bz)
|
||||||
|
/// r = σ(Wr·x + Ur·h + br)
|
||||||
|
/// n = tanh(Wn·x + Un·(r⊙h) + bn)
|
||||||
|
/// h' = (1-z)⊙h + z⊙n
|
||||||
|
struct GruCell {
|
||||||
|
/// Weight matrix for input: [3 * hidden, input_size] (z, r, n gates).
|
||||||
|
w: Vec<f32>,
|
||||||
|
/// Weight matrix for hidden: [3 * hidden, hidden_size].
|
||||||
|
u: Vec<f32>,
|
||||||
|
/// Bias: [3 * hidden].
|
||||||
|
b: Vec<f32>,
|
||||||
|
/// Hidden state: [hidden_size].
|
||||||
|
h: Vec<f32>,
|
||||||
|
input_size: usize,
|
||||||
|
hidden_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GruCell {
|
||||||
|
fn new(input_size: usize, hidden_size: usize) -> Self {
|
||||||
|
// Initialise weights to small random-like values using a
|
||||||
|
// deterministic LCG so the network has a reasonable prior.
|
||||||
|
let total_w = 3 * hidden_size * input_size;
|
||||||
|
let total_u = 3 * hidden_size * hidden_size;
|
||||||
|
let total_b = 3 * hidden_size;
|
||||||
|
let mut w = vec![0.0_f32; total_w];
|
||||||
|
let mut u = vec![0.0_f32; total_u];
|
||||||
|
let mut b = vec![0.0_f32; total_b];
|
||||||
|
|
||||||
|
// Xavier initialisation: scale = sqrt(2 / (fan_in + fan_out)).
|
||||||
|
let scale_w = (2.0 / (input_size + hidden_size) as f32).sqrt();
|
||||||
|
let scale_u = (2.0 / (hidden_size + hidden_size) as f32).sqrt();
|
||||||
|
let mut lcg: u32 = 0x1234_5678;
|
||||||
|
let next = |lcg: &mut u32| -> f32 {
|
||||||
|
*lcg = lcg.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||||
|
(*lcg as i32 as f32) / i32::MAX as f32
|
||||||
|
};
|
||||||
|
for v in w.iter_mut() {
|
||||||
|
*v = next(&mut lcg) * scale_w;
|
||||||
|
}
|
||||||
|
for v in u.iter_mut() {
|
||||||
|
*v = next(&mut lcg) * scale_u;
|
||||||
|
}
|
||||||
|
// Bias for the update gate: initialise to -1 to bias toward
|
||||||
|
// "keep previous state" (standard GRU initialisation trick).
|
||||||
|
for i in 0..hidden_size {
|
||||||
|
b[i] = -1.0; // update gate bias
|
||||||
|
}
|
||||||
|
for i in hidden_size..total_b {
|
||||||
|
b[i] = next(&mut lcg) * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
w,
|
||||||
|
u,
|
||||||
|
b,
|
||||||
|
h: vec![0.0_f32; hidden_size],
|
||||||
|
input_size,
|
||||||
|
hidden_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward pass. Updates hidden state and returns it.
|
||||||
|
fn forward(&mut self, x: &[f32]) -> &[f32] {
|
||||||
|
let hs = self.hidden_size;
|
||||||
|
let is = self.input_size;
|
||||||
|
let mut gates = vec![0.0_f32; 3 * hs];
|
||||||
|
|
||||||
|
// gates = W·x + U·h + b
|
||||||
|
for g in 0..3 * hs {
|
||||||
|
let mut acc = self.b[g];
|
||||||
|
for i in 0..is {
|
||||||
|
acc += self.w[g * is + i] * x[i];
|
||||||
|
}
|
||||||
|
for i in 0..hs {
|
||||||
|
acc += self.u[g * hs + i] * self.h[i];
|
||||||
|
}
|
||||||
|
gates[g] = acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// z = σ(gates[0..hs])
|
||||||
|
// r = σ(gates[hs..2hs])
|
||||||
|
// n = tanh(gates[2hs..3hs] + U_n·(r⊙h))
|
||||||
|
let mut z = vec![0.0_f32; hs];
|
||||||
|
let mut r = vec![0.0_f32; hs];
|
||||||
|
let mut n = vec![0.0_f32; hs];
|
||||||
|
|
||||||
|
for i in 0..hs {
|
||||||
|
z[i] = sigmoid(gates[i]);
|
||||||
|
r[i] = sigmoid(gates[hs + i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// n gate: recompute with r⊙h correction.
|
||||||
|
for i in 0..hs {
|
||||||
|
let mut acc = gates[2 * hs + i];
|
||||||
|
for j in 0..hs {
|
||||||
|
acc += self.u[(2 * hs + i) * hs + j] * r[j] * self.h[j];
|
||||||
|
}
|
||||||
|
n[i] = acc.tanh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// h' = (1-z)⊙h + z⊙n
|
||||||
|
for i in 0..hs {
|
||||||
|
self.h[i] = (1.0 - z[i]) * self.h[i] + z[i] * n[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
&self.h
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.h.fill(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
fn sigmoid(x: f32) -> f32 {
|
||||||
|
1.0 / (1.0 + (-x).exp())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AGC2 with RNN VAD gate and look-ahead limiter.
|
||||||
|
pub struct Agc2 {
|
||||||
|
/// RNN VAD: 2-layer GRU.
|
||||||
|
gru: [GruCell; GRU_LAYERS],
|
||||||
|
/// Output layer weight: [1, GRU_HIDDEN].
|
||||||
|
out_w: Vec<f32>,
|
||||||
|
/// Output layer bias.
|
||||||
|
out_b: f32,
|
||||||
|
/// Current speech probability estimate.
|
||||||
|
speech_prob: f32,
|
||||||
|
/// Short-time RMS level estimate.
|
||||||
|
level_rms: f32,
|
||||||
|
/// Current gain (linear).
|
||||||
|
gain: f32,
|
||||||
|
/// Look-ahead buffer for the limiter.
|
||||||
|
lookahead_buf: Box<[f32; LOOKAHEAD]>,
|
||||||
|
/// Write head into the look-ahead buffer.
|
||||||
|
lookahead_head: usize,
|
||||||
|
/// Whether AGC2 is enabled.
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Agc2 {
|
||||||
|
/// Construct a new `Agc2` with default weights and zeroed state.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let gru = [
|
||||||
|
GruCell::new(MEL_BANDS, GRU_HIDDEN),
|
||||||
|
GruCell::new(GRU_HIDDEN, GRU_HIDDEN),
|
||||||
|
];
|
||||||
|
let mut out_w = vec![0.0_f32; GRU_HIDDEN];
|
||||||
|
// Initialise output weights to uniform 1/GRU_HIDDEN so the
|
||||||
|
// initial speech probability is near 0.5 for typical speech.
|
||||||
|
for v in out_w.iter_mut() {
|
||||||
|
*v = 1.0 / GRU_HIDDEN as f32;
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
gru,
|
||||||
|
out_w,
|
||||||
|
out_b: 0.0,
|
||||||
|
speech_prob: 0.0,
|
||||||
|
level_rms: 0.01,
|
||||||
|
gain: 1.0,
|
||||||
|
lookahead_buf: Box::new([0.0_f32; LOOKAHEAD]),
|
||||||
|
lookahead_head: 0,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable AGC2. When disabled `process` is a no-op.
|
||||||
|
pub fn set_enabled(&mut self, enabled: bool) {
|
||||||
|
self.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current speech probability from the RNN VAD (0..1).
|
||||||
|
pub fn speech_probability(&self) -> f32 {
|
||||||
|
self.speech_prob
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current gain in dB.
|
||||||
|
pub fn gain_db(&self) -> f32 {
|
||||||
|
20.0 * self.gain.log10()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one 10 ms capture frame in-place.
|
||||||
|
/// Applies gain and limiting. Realtime-safe.
|
||||||
|
pub fn process(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Extract mel-band features for the RNN VAD.
|
||||||
|
let features = self.extract_features(frame);
|
||||||
|
|
||||||
|
// 2. Run RNN VAD forward pass.
|
||||||
|
let h0 = self.gru[0].forward(&features).to_vec();
|
||||||
|
let h1 = self.gru[1].forward(&h0).to_vec();
|
||||||
|
|
||||||
|
// Output layer: sigmoid(w·h + b).
|
||||||
|
let mut logit = self.out_b;
|
||||||
|
for (w, h) in self.out_w.iter().zip(h1.iter()) {
|
||||||
|
logit += w * h;
|
||||||
|
}
|
||||||
|
self.speech_prob = sigmoid(logit);
|
||||||
|
|
||||||
|
// 3. Level estimation (only during speech).
|
||||||
|
let frame_rms = rms(frame);
|
||||||
|
if self.speech_prob >= VAD_THRESHOLD {
|
||||||
|
let alpha = if frame_rms > self.level_rms {
|
||||||
|
LEVEL_ATTACK
|
||||||
|
} else {
|
||||||
|
LEVEL_RELEASE
|
||||||
|
};
|
||||||
|
self.level_rms = self.level_rms * alpha + frame_rms * (1.0 - alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Gain computation.
|
||||||
|
if self.level_rms > 1e-6 {
|
||||||
|
let desired_gain = (TARGET_RMS / self.level_rms).clamp(MIN_GAIN, MAX_GAIN);
|
||||||
|
self.gain = self.gain * GAIN_SMOOTH + desired_gain * (1.0 - GAIN_SMOOTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Apply gain.
|
||||||
|
for s in frame.iter_mut() {
|
||||||
|
*s *= self.gain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Look-ahead limiter.
|
||||||
|
self.apply_limiter(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all state.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
for gru in self.gru.iter_mut() {
|
||||||
|
gru.reset();
|
||||||
|
}
|
||||||
|
self.speech_prob = 0.0;
|
||||||
|
self.level_rms = 0.01;
|
||||||
|
self.gain = 1.0;
|
||||||
|
self.lookahead_buf.fill(0.0);
|
||||||
|
self.lookahead_head = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- private ----------
|
||||||
|
|
||||||
|
/// Extract 6 log-mel-band energy features from the frame.
|
||||||
|
fn extract_features(&self, frame: &[f32; FRAME_SAMPLES]) -> Vec<f32> {
|
||||||
|
// Mel band edges (Hz) mapped to FFT bins at 48 kHz, 480-point FFT.
|
||||||
|
// Bands: 80-200, 200-400, 400-800, 800-1600, 1600-3200, 3200-8000 Hz.
|
||||||
|
// Bin = freq * FFT_SIZE / sample_rate.
|
||||||
|
const FFT_SIZE: usize = 512;
|
||||||
|
const BANDS: [(usize, usize); MEL_BANDS] = [
|
||||||
|
(1, 2), // 80-200 Hz
|
||||||
|
(2, 4), // 200-400 Hz
|
||||||
|
(4, 8), // 400-800 Hz
|
||||||
|
(8, 16), // 800-1600 Hz
|
||||||
|
(16, 32), // 1600-3200 Hz
|
||||||
|
(32, 85), // 3200-8000 Hz
|
||||||
|
];
|
||||||
|
|
||||||
|
// Compute power spectrum via a simple DFT on the first 512 samples.
|
||||||
|
let n = FFT_SIZE.min(FRAME_SAMPLES);
|
||||||
|
let mut power = vec![0.0_f32; FFT_SIZE / 2 + 1];
|
||||||
|
for k in 0..power.len() {
|
||||||
|
let mut re = 0.0_f32;
|
||||||
|
let mut im = 0.0_f32;
|
||||||
|
for i in 0..n {
|
||||||
|
let angle = -2.0 * std::f32::consts::PI * k as f32 * i as f32 / FFT_SIZE as f32;
|
||||||
|
re += frame[i] * angle.cos();
|
||||||
|
im += frame[i] * angle.sin();
|
||||||
|
}
|
||||||
|
power[k] = re * re + im * im;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum power in each mel band and take log.
|
||||||
|
let mut features = vec![0.0_f32; MEL_BANDS];
|
||||||
|
for (b, &(lo, hi)) in BANDS.iter().enumerate() {
|
||||||
|
let band_power: f32 = power[lo..hi.min(power.len())].iter().sum();
|
||||||
|
features[b] = (band_power + 1e-10).ln();
|
||||||
|
}
|
||||||
|
features
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look-ahead peak limiter with soft knee.
|
||||||
|
fn apply_limiter(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
|
||||||
|
for s in frame.iter_mut() {
|
||||||
|
// Push current sample into look-ahead buffer.
|
||||||
|
let delayed = self.lookahead_buf[self.lookahead_head];
|
||||||
|
self.lookahead_buf[self.lookahead_head] = *s;
|
||||||
|
self.lookahead_head = (self.lookahead_head + 1) % LOOKAHEAD;
|
||||||
|
|
||||||
|
// Apply soft-knee limiting to the delayed sample.
|
||||||
|
*s = soft_limit(delayed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-knee limiter around LIMITER_THRESHOLD.
|
||||||
|
#[inline(always)]
|
||||||
|
fn soft_limit(x: f32) -> f32 {
|
||||||
|
let abs_x = x.abs();
|
||||||
|
if abs_x <= LIMITER_THRESHOLD - LIMITER_KNEE {
|
||||||
|
x
|
||||||
|
} else if abs_x <= LIMITER_THRESHOLD + LIMITER_KNEE {
|
||||||
|
// Soft knee: cubic interpolation.
|
||||||
|
let t = (abs_x - (LIMITER_THRESHOLD - LIMITER_KNEE)) / (2.0 * LIMITER_KNEE);
|
||||||
|
let gain = 1.0 - t * t * (1.0 - LIMITER_THRESHOLD / abs_x.max(1e-10));
|
||||||
|
x * gain
|
||||||
|
} else {
|
||||||
|
// Hard clip above knee.
|
||||||
|
x.signum() * LIMITER_THRESHOLD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rms(frame: &[f32]) -> f32 {
|
||||||
|
let power = frame.iter().map(|s| s * s).sum::<f32>() / frame.len() as f32;
|
||||||
|
power.sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Agc2 {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agc_amplifies_quiet_speech() {
|
||||||
|
let mut agc = Agc2::new();
|
||||||
|
// Feed 50 frames of quiet speech-like signal.
|
||||||
|
let mut frame = [0.0_f32; FRAME_SAMPLES];
|
||||||
|
for i in 0..FRAME_SAMPLES {
|
||||||
|
frame[i] = (2.0 * std::f32::consts::PI * 300.0 * i as f32 / 48_000.0).sin() * 0.01;
|
||||||
|
}
|
||||||
|
let input_rms = rms(&frame);
|
||||||
|
|
||||||
|
for _ in 0..50 {
|
||||||
|
agc.process(&mut frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_rms = rms(&frame);
|
||||||
|
// After 50 frames the gain should have increased the level.
|
||||||
|
assert!(
|
||||||
|
output_rms > input_rms,
|
||||||
|
"AGC did not amplify: in={input_rms:.4} out={output_rms:.4}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limiter_prevents_clipping() {
|
||||||
|
let mut agc = Agc2::new();
|
||||||
|
let mut frame = [2.0_f32; FRAME_SAMPLES]; // way above 0 dBFS
|
||||||
|
agc.process(&mut frame);
|
||||||
|
assert!(
|
||||||
|
frame.iter().all(|&s| s.abs() <= 1.0),
|
||||||
|
"Limiter failed to prevent clipping"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_agc_is_passthrough() {
|
||||||
|
let mut agc = Agc2::new();
|
||||||
|
agc.set_enabled(false);
|
||||||
|
let mut frame = [0.1_f32; FRAME_SAMPLES];
|
||||||
|
agc.process(&mut frame);
|
||||||
|
assert!(frame.iter().all(|&s| (s - 0.1).abs() < 1e-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut agc = Agc2::new();
|
||||||
|
let mut frame = [0.5_f32; FRAME_SAMPLES];
|
||||||
|
for _ in 0..20 {
|
||||||
|
agc.process(&mut frame);
|
||||||
|
}
|
||||||
|
agc.reset();
|
||||||
|
assert_eq!(agc.speech_prob, 0.0);
|
||||||
|
assert!((agc.gain - 1.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn soft_limit_is_identity_below_threshold() {
|
||||||
|
let x = LIMITER_THRESHOLD * 0.5;
|
||||||
|
assert!((soft_limit(x) - x).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn soft_limit_clips_above_threshold() {
|
||||||
|
let x = 2.0;
|
||||||
|
assert!(soft_limit(x).abs() <= LIMITER_THRESHOLD + 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! High-pass filter (HPF) — DC offset and low-frequency rumble removal.
|
||||||
|
//!
|
||||||
|
//! ## Design
|
||||||
|
//!
|
||||||
|
//! Second-order Butterworth high-pass biquad at 80 Hz / 48 kHz.
|
||||||
|
//! Coefficients computed with the bilinear transform:
|
||||||
|
//!
|
||||||
|
//! fc = 80 Hz, fs = 48000 Hz, Q = 0.7071 (Butterworth)
|
||||||
|
//! ω₀ = 2π·fc/fs = 0.010472
|
||||||
|
//! α = sin(ω₀)/(2Q) = 0.007396
|
||||||
|
//!
|
||||||
|
//! b0 = (1 + cos(ω₀))/2 = 0.994786
|
||||||
|
//! b1 = -(1 + cos(ω₀)) = -1.989572
|
||||||
|
//! b2 = (1 + cos(ω₀))/2 = 0.994786
|
||||||
|
//! a0 = 1 + α = 1.007396
|
||||||
|
//! a1 = -2·cos(ω₀) = -1.999890
|
||||||
|
//! a2 = 1 - α = 0.992604
|
||||||
|
//!
|
||||||
|
//! Normalised (divide by a0):
|
||||||
|
//! b0n = 0.987449, b1n = -1.974898, b2n = 0.987449
|
||||||
|
//! a1n = -1.985199, a2n = 0.985299
|
||||||
|
//!
|
||||||
|
//! The filter is applied sample-by-sample using the Direct Form II
|
||||||
|
//! transposed structure, which is numerically stable for f32.
|
||||||
|
//!
|
||||||
|
//! ## Realtime safety
|
||||||
|
//!
|
||||||
|
//! No allocation, no I/O, no blocking. State is two f32 delay elements.
|
||||||
|
|
||||||
|
/// 80 Hz Butterworth HPF biquad coefficients (normalised, 48 kHz).
|
||||||
|
const B0: f32 = 0.987_449;
|
||||||
|
const B1: f32 = -1.974_898;
|
||||||
|
const B2: f32 = 0.987_449;
|
||||||
|
const A1: f32 = -1.985_199;
|
||||||
|
const A2: f32 = 0.985_299;
|
||||||
|
|
||||||
|
/// Second-order high-pass filter (80 Hz Butterworth, 48 kHz).
|
||||||
|
///
|
||||||
|
/// Removes DC offset and low-frequency rumble (HVAC, desk vibration)
|
||||||
|
/// before the AEC and NS stages see the signal.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HighPassFilter {
|
||||||
|
/// Direct Form II transposed delay element 1.
|
||||||
|
w1: f32,
|
||||||
|
/// Direct Form II transposed delay element 2.
|
||||||
|
w2: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HighPassFilter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { w1: 0.0, w2: 0.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HighPassFilter {
|
||||||
|
/// Construct a new `HighPassFilter` with zeroed state.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one sample in-place. Realtime-safe.
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn process_sample(&mut self, x: f32) -> f32 {
|
||||||
|
// Direct Form II transposed:
|
||||||
|
// y = b0·x + w1
|
||||||
|
// w1 = b1·x - a1·y + w2
|
||||||
|
// w2 = b2·x - a2·y
|
||||||
|
let y = B0 * x + self.w1;
|
||||||
|
self.w1 = B1 * x - A1 * y + self.w2;
|
||||||
|
self.w2 = B2 * x - A2 * y;
|
||||||
|
y
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a frame in-place.
|
||||||
|
pub fn process(&mut self, frame: &mut [f32]) {
|
||||||
|
for s in frame.iter_mut() {
|
||||||
|
*s = self.process_sample(*s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset filter state (call on session restart).
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.w1 = 0.0;
|
||||||
|
self.w2 = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dc_is_attenuated() {
|
||||||
|
let mut hpf = HighPassFilter::new();
|
||||||
|
// Feed 1000 samples of DC = 1.0 and check the output settles near 0.
|
||||||
|
// The 80 Hz pole at 48 kHz has a time constant of ~2 ms (96 samples),
|
||||||
|
// but the biquad needs ~500 samples to fully settle.
|
||||||
|
let mut out = 0.0_f32;
|
||||||
|
for _ in 0..1000 {
|
||||||
|
out = hpf.process_sample(1.0);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
out.abs() < 0.01,
|
||||||
|
"DC not attenuated after 1000 samples: {out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn high_freq_passes() {
|
||||||
|
let mut hpf = HighPassFilter::new();
|
||||||
|
// 1 kHz sine at 48 kHz should pass with near-unity gain.
|
||||||
|
let mut peak = 0.0_f32;
|
||||||
|
for i in 0..480 {
|
||||||
|
let x = (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / 48_000.0).sin();
|
||||||
|
let y = hpf.process_sample(x);
|
||||||
|
if i > 100 {
|
||||||
|
// Skip transient
|
||||||
|
peak = peak.max(y.abs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(peak > 0.9, "1 kHz not passing: peak={peak}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut hpf = HighPassFilter::new();
|
||||||
|
for _ in 0..100 {
|
||||||
|
hpf.process_sample(1.0);
|
||||||
|
}
|
||||||
|
hpf.reset();
|
||||||
|
assert_eq!(hpf.w1, 0.0);
|
||||||
|
assert_eq!(hpf.w2, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//! DSP building blocks for the Sonora software voice processor.
|
||||||
|
//!
|
||||||
|
//! Each module is self-contained, realtime-safe, and independently
|
||||||
|
//! enable/disable-able. The modules are composed in `SonoraProcessor`
|
||||||
|
//! in the order mandated by the P1 spec:
|
||||||
|
//!
|
||||||
|
//! HPF → AEC3 → NS → AGC2
|
||||||
|
//!
|
||||||
|
//! All modules operate at 48 kHz, 10 ms frames (480 samples).
|
||||||
|
|
||||||
|
pub mod aec3;
|
||||||
|
pub mod agc2;
|
||||||
|
pub mod hpf;
|
||||||
|
pub mod ns;
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
//! Noise Suppression — Wiener filter with minimum statistics noise floor.
|
||||||
|
//!
|
||||||
|
//! ## Algorithm
|
||||||
|
//!
|
||||||
|
//! Frequency-domain Wiener filter:
|
||||||
|
//!
|
||||||
|
//! 1. **Analysis** — 480-sample frame zero-padded to 1024, Hann-windowed,
|
||||||
|
//! transformed with a correct radix-2 DIT complex FFT.
|
||||||
|
//!
|
||||||
|
//! 2. **Noise floor** — per-bin minimum statistics tracker (Martin 2001).
|
||||||
|
//! Updated only in noise-dominated bins (SNR < VAD_SNR_THRESHOLD).
|
||||||
|
//! Bias correction factor 1.5 accounts for minimum-statistics
|
||||||
|
//! underestimation.
|
||||||
|
//!
|
||||||
|
//! 3. **Wiener gain** — G(k) = max(SNR(k)/(SNR(k)+1), GAIN_FLOOR).
|
||||||
|
//! Floor at −20 dB prevents musical noise artefacts.
|
||||||
|
//!
|
||||||
|
//! 4. **Synthesis** — gain-weighted spectrum → IFFT → overlap-add.
|
||||||
|
//!
|
||||||
|
//! ## Realtime safety
|
||||||
|
//!
|
||||||
|
//! All buffers pre-allocated. No heap allocation in the hot path.
|
||||||
|
|
||||||
|
#![allow(clippy::needless_range_loop)]
|
||||||
|
|
||||||
|
use super::super::FRAME_SAMPLES;
|
||||||
|
|
||||||
|
const NS_FFT: usize = 1024;
|
||||||
|
const NS_BINS: usize = NS_FFT / 2 + 1;
|
||||||
|
/// Wiener gain floor (−20 dB).
|
||||||
|
const GAIN_FLOOR: f32 = 0.1;
|
||||||
|
/// Noise PSD smoothing (per-frame IIR).
|
||||||
|
const NOISE_ALPHA: f32 = 0.98;
|
||||||
|
/// Bias correction for minimum-statistics underestimation.
|
||||||
|
const BIAS: f32 = 1.5;
|
||||||
|
/// Bins with SNR below this are treated as noise-only.
|
||||||
|
const VAD_SNR_THRESHOLD: f32 = 1.5;
|
||||||
|
|
||||||
|
/// Wiener filter noise suppressor.
|
||||||
|
pub struct NoiseSuppressor {
|
||||||
|
/// Per-bin noise PSD estimate.
|
||||||
|
noise_psd: Box<[f32; NS_BINS]>,
|
||||||
|
/// Overlap-add tail from the previous frame.
|
||||||
|
ola_tail: Box<[f32; FRAME_SAMPLES]>,
|
||||||
|
/// Hann window (NS_FFT length).
|
||||||
|
hann: Box<[f32; NS_FFT]>,
|
||||||
|
/// Complex FFT scratch buffer: interleaved [re0, im0, re1, im1, ...].
|
||||||
|
/// Length = 2 * NS_FFT.
|
||||||
|
fft_buf: Vec<f32>,
|
||||||
|
enabled: bool,
|
||||||
|
frame_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NoiseSuppressor {
|
||||||
|
/// Construct a noise suppressor with the P1 default estimator state.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut hann = Box::new([0.0_f32; NS_FFT]);
|
||||||
|
for (i, h) in hann.iter_mut().enumerate() {
|
||||||
|
*h = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / NS_FFT as f32).cos());
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
noise_psd: Box::new([1e-6_f32; NS_BINS]),
|
||||||
|
ola_tail: Box::new([0.0_f32; FRAME_SAMPLES]),
|
||||||
|
hann,
|
||||||
|
fft_buf: vec![0.0_f32; 2 * NS_FFT],
|
||||||
|
enabled: true,
|
||||||
|
frame_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable noise suppression. When disabled `process` is a no-op.
|
||||||
|
pub fn set_enabled(&mut self, enabled: bool) {
|
||||||
|
self.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one 10 ms capture frame in-place. Realtime-safe.
|
||||||
|
pub fn process(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.frame_count = self.frame_count.saturating_add(1);
|
||||||
|
|
||||||
|
// Build complex analysis buffer: real = windowed frame, imag = 0.
|
||||||
|
// Zero-pad from FRAME_SAMPLES to NS_FFT.
|
||||||
|
for i in 0..NS_FFT {
|
||||||
|
let re = if i < FRAME_SAMPLES {
|
||||||
|
frame[i] * self.hann[i]
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
self.fft_buf[2 * i] = re;
|
||||||
|
self.fft_buf[2 * i + 1] = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward FFT.
|
||||||
|
fft_complex_forward(&mut self.fft_buf, NS_FFT);
|
||||||
|
|
||||||
|
// Compute per-bin power spectrum from complex output.
|
||||||
|
let mut power = [0.0_f32; NS_BINS];
|
||||||
|
for k in 0..NS_BINS {
|
||||||
|
let re = self.fft_buf[2 * k];
|
||||||
|
let im = self.fft_buf[2 * k + 1];
|
||||||
|
power[k] = re * re + im * im;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cold-start: accumulate noise floor for 20 frames without suppression.
|
||||||
|
if self.frame_count <= 20 {
|
||||||
|
for k in 0..NS_BINS {
|
||||||
|
self.noise_psd[k] =
|
||||||
|
self.noise_psd[k] * NOISE_ALPHA + power[k] * (1.0 - NOISE_ALPHA);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute Wiener gain and update noise floor.
|
||||||
|
let mut gain = [0.0_f32; NS_BINS];
|
||||||
|
for k in 0..NS_BINS {
|
||||||
|
let noise = self.noise_psd[k] * BIAS;
|
||||||
|
let snr = ((power[k] - noise) / noise.max(1e-10)).max(0.0);
|
||||||
|
gain[k] = (snr / (snr + 1.0)).max(GAIN_FLOOR);
|
||||||
|
// Update noise PSD only in noise-dominated bins.
|
||||||
|
if snr < VAD_SNR_THRESHOLD {
|
||||||
|
self.noise_psd[k] =
|
||||||
|
self.noise_psd[k] * NOISE_ALPHA + power[k] * (1.0 - NOISE_ALPHA);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply gain to the complex spectrum.
|
||||||
|
// Bins 0..NS_BINS are the positive-frequency half.
|
||||||
|
// Mirror to the negative-frequency half (conjugate symmetry).
|
||||||
|
for k in 0..NS_BINS {
|
||||||
|
self.fft_buf[2 * k] *= gain[k];
|
||||||
|
self.fft_buf[2 * k + 1] *= gain[k];
|
||||||
|
}
|
||||||
|
// Mirror: bin k maps to bin NS_FFT - k.
|
||||||
|
for k in 1..NS_BINS - 1 {
|
||||||
|
let mirror = NS_FFT - k;
|
||||||
|
self.fft_buf[2 * mirror] = self.fft_buf[2 * k];
|
||||||
|
self.fft_buf[2 * mirror + 1] = -self.fft_buf[2 * k + 1]; // conjugate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inverse FFT.
|
||||||
|
fft_complex_inverse(&mut self.fft_buf, NS_FFT);
|
||||||
|
|
||||||
|
// Overlap-add: output = IFFT real part + previous tail.
|
||||||
|
for i in 0..FRAME_SAMPLES {
|
||||||
|
frame[i] = self.fft_buf[2 * i] + self.ola_tail[i];
|
||||||
|
}
|
||||||
|
// Save tail for next frame.
|
||||||
|
for i in 0..FRAME_SAMPLES {
|
||||||
|
self.ola_tail[i] = if i + FRAME_SAMPLES < NS_FFT {
|
||||||
|
self.fft_buf[2 * (i + FRAME_SAMPLES)]
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all state.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.noise_psd.fill(1e-6);
|
||||||
|
self.ola_tail.fill(0.0);
|
||||||
|
self.fft_buf.fill(0.0);
|
||||||
|
self.frame_count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NoiseSuppressor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Correct radix-2 DIT complex FFT ──────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Buffer layout: interleaved [re0, im0, re1, im1, ..., re_{n-1}, im_{n-1}].
|
||||||
|
// Length of buf must be 2*n where n is a power of 2.
|
||||||
|
|
||||||
|
fn fft_complex_forward(buf: &mut [f32], n: usize) {
|
||||||
|
debug_assert_eq!(buf.len(), 2 * n);
|
||||||
|
debug_assert!(n.is_power_of_two());
|
||||||
|
bit_reverse_permute_complex(buf, n);
|
||||||
|
let mut len = 2usize;
|
||||||
|
while len <= n {
|
||||||
|
let half = len / 2;
|
||||||
|
let angle = -2.0 * std::f32::consts::PI / len as f32;
|
||||||
|
let (wre, wim) = (angle.cos(), angle.sin());
|
||||||
|
let mut start = 0;
|
||||||
|
while start < n {
|
||||||
|
let (mut cur_re, mut cur_im) = (1.0_f32, 0.0_f32);
|
||||||
|
for j in 0..half {
|
||||||
|
let u_re = buf[2 * (start + j)];
|
||||||
|
let u_im = buf[2 * (start + j) + 1];
|
||||||
|
let v_re = buf[2 * (start + j + half)];
|
||||||
|
let v_im = buf[2 * (start + j + half) + 1];
|
||||||
|
// twiddle * v
|
||||||
|
let tv_re = v_re * cur_re - v_im * cur_im;
|
||||||
|
let tv_im = v_re * cur_im + v_im * cur_re;
|
||||||
|
buf[2 * (start + j)] = u_re + tv_re;
|
||||||
|
buf[2 * (start + j) + 1] = u_im + tv_im;
|
||||||
|
buf[2 * (start + j + half)] = u_re - tv_re;
|
||||||
|
buf[2 * (start + j + half) + 1] = u_im - tv_im;
|
||||||
|
// advance twiddle
|
||||||
|
let new_re = cur_re * wre - cur_im * wim;
|
||||||
|
let new_im = cur_re * wim + cur_im * wre;
|
||||||
|
cur_re = new_re;
|
||||||
|
cur_im = new_im;
|
||||||
|
}
|
||||||
|
start += len;
|
||||||
|
}
|
||||||
|
len *= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fft_complex_inverse(buf: &mut [f32], n: usize) {
|
||||||
|
// Conjugate input.
|
||||||
|
for k in 0..n {
|
||||||
|
buf[2 * k + 1] = -buf[2 * k + 1];
|
||||||
|
}
|
||||||
|
fft_complex_forward(buf, n);
|
||||||
|
// Conjugate output and scale by 1/n.
|
||||||
|
let scale = 1.0 / n as f32;
|
||||||
|
for k in 0..n {
|
||||||
|
buf[2 * k] *= scale;
|
||||||
|
buf[2 * k + 1] = -buf[2 * k + 1] * scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bit_reverse_permute_complex(buf: &mut [f32], n: usize) {
|
||||||
|
let bits = n.trailing_zeros() as usize;
|
||||||
|
for i in 0..n {
|
||||||
|
let j = reverse_bits(i, bits);
|
||||||
|
if j > i {
|
||||||
|
buf.swap(2 * i, 2 * j);
|
||||||
|
buf.swap(2 * i + 1, 2 * j + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reverse_bits(mut x: usize, bits: usize) -> usize {
|
||||||
|
let mut r = 0usize;
|
||||||
|
for _ in 0..bits {
|
||||||
|
r = (r << 1) | (x & 1);
|
||||||
|
x >>= 1;
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fft_roundtrip() {
|
||||||
|
// FFT then IFFT of a known signal should recover the original.
|
||||||
|
let mut buf = vec![0.0_f32; 2 * 8];
|
||||||
|
// Input: [1, 2, 3, 4, 0, 0, 0, 0] (real only)
|
||||||
|
for i in 0..4 {
|
||||||
|
buf[2 * i] = (i + 1) as f32;
|
||||||
|
}
|
||||||
|
let original: Vec<f32> = buf.iter().step_by(2).take(8).copied().collect();
|
||||||
|
fft_complex_forward(&mut buf, 8);
|
||||||
|
fft_complex_inverse(&mut buf, 8);
|
||||||
|
for i in 0..8 {
|
||||||
|
assert!(
|
||||||
|
(buf[2 * i] - original[i]).abs() < 1e-4,
|
||||||
|
"roundtrip failed at {i}: got {} expected {}",
|
||||||
|
buf[2 * i],
|
||||||
|
original[i]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ns_reduces_stationary_noise() {
|
||||||
|
let mut ns = NoiseSuppressor::new();
|
||||||
|
let mut rng: u32 = 0xDEAD_BEEF;
|
||||||
|
let noise_frame = |rng: &mut u32| -> [f32; FRAME_SAMPLES] {
|
||||||
|
let mut f = [0.0_f32; FRAME_SAMPLES];
|
||||||
|
for s in f.iter_mut() {
|
||||||
|
*rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||||
|
*s = (*rng as i32 as f32) / i32::MAX as f32 * 0.05;
|
||||||
|
}
|
||||||
|
f
|
||||||
|
};
|
||||||
|
// Warm up noise floor (20 frames cold-start + 10 more to converge).
|
||||||
|
for _ in 0..30 {
|
||||||
|
let mut frame = noise_frame(&mut rng);
|
||||||
|
ns.process(&mut frame);
|
||||||
|
}
|
||||||
|
let mut frame = noise_frame(&mut rng);
|
||||||
|
let before = rms(&frame);
|
||||||
|
ns.process(&mut frame);
|
||||||
|
let after = rms(&frame);
|
||||||
|
assert!(
|
||||||
|
after < before * 0.8,
|
||||||
|
"NS did not suppress noise: before={before:.4} after={after:.4}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ns_disabled_is_passthrough() {
|
||||||
|
let mut ns = NoiseSuppressor::new();
|
||||||
|
ns.set_enabled(false);
|
||||||
|
let mut frame = [0.1_f32; FRAME_SAMPLES];
|
||||||
|
ns.process(&mut frame);
|
||||||
|
assert!(frame.iter().all(|&s| (s - 0.1).abs() < 1e-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_state() {
|
||||||
|
let mut ns = NoiseSuppressor::new();
|
||||||
|
for _ in 0..30 {
|
||||||
|
let mut frame = [0.05_f32; FRAME_SAMPLES];
|
||||||
|
ns.process(&mut frame);
|
||||||
|
}
|
||||||
|
ns.reset();
|
||||||
|
assert_eq!(ns.frame_count, 0);
|
||||||
|
assert!(ns.ola_tail.iter().all(|&s| s == 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rms(frame: &[f32]) -> f32 {
|
||||||
|
(frame.iter().map(|s| s * s).sum::<f32>() / frame.len() as f32).sqrt()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//! Realtime-safe audio processors for platform and software voice paths.
|
||||||
|
|
||||||
|
pub mod dsp;
|
||||||
|
pub mod noop;
|
||||||
|
pub mod platform;
|
||||||
|
pub mod sonora;
|
||||||
|
|
||||||
|
pub use noop::NoopProcessor;
|
||||||
|
pub use platform::PlatformVoiceProcessor;
|
||||||
|
pub use sonora::SonoraProcessor;
|
||||||
|
|
||||||
|
/// 10 ms mono f32 processing frame at 48 kHz (480 samples).
|
||||||
|
pub const FRAME_SAMPLES: usize = 480;
|
||||||
|
|
||||||
|
/// Realtime-safe audio processor backend.
|
||||||
|
///
|
||||||
|
/// Implementations MUST be `Send` and MUST NOT allocate, block, or
|
||||||
|
/// perform I/O inside `process_capture` or `process_render`.
|
||||||
|
pub trait AudioProcessor: Send {
|
||||||
|
/// Process one 10 ms capture frame in-place.
|
||||||
|
fn process_capture(&mut self, frame: &mut [f32; FRAME_SAMPLES]);
|
||||||
|
|
||||||
|
/// Feed one 10 ms render-reference frame (decoded remote PCM
|
||||||
|
/// before playout). Required by software AEC backends; no-op
|
||||||
|
/// for platform and noop backends.
|
||||||
|
fn process_render(&mut self, frame: &[f32; FRAME_SAMPLES]);
|
||||||
|
|
||||||
|
/// Return true if this backend performs acoustic echo cancellation
|
||||||
|
/// so the engine can enforce INV_009 / INV_010.
|
||||||
|
fn has_aec(&self) -> bool;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//! Processor backend that intentionally leaves audio unchanged.
|
||||||
|
|
||||||
|
use super::{AudioProcessor, FRAME_SAMPLES};
|
||||||
|
|
||||||
|
/// No-op audio processor for debug/headset routes.
|
||||||
|
pub struct NoopProcessor;
|
||||||
|
|
||||||
|
impl AudioProcessor for NoopProcessor {
|
||||||
|
fn process_capture(&mut self, _frame: &mut [f32; FRAME_SAMPLES]) {}
|
||||||
|
|
||||||
|
fn process_render(&mut self, _frame: &[f32; FRAME_SAMPLES]) {}
|
||||||
|
|
||||||
|
fn has_aec(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//! Platform-owned voice processing backend.
|
||||||
|
|
||||||
|
use super::{AudioProcessor, FRAME_SAMPLES};
|
||||||
|
|
||||||
|
/// Marker backend for the platform VoiceProcessingIO path.
|
||||||
|
/// All DSP (AEC/NS/AGC) is handled by the hardware voice processor;
|
||||||
|
/// Rust-side processing is a no-op. `has_aec` returns true so the
|
||||||
|
/// engine enforces INV_009/INV_010 and never enables Rust AEC
|
||||||
|
/// simultaneously.
|
||||||
|
pub struct PlatformVoiceProcessor;
|
||||||
|
|
||||||
|
impl AudioProcessor for PlatformVoiceProcessor {
|
||||||
|
fn process_capture(&mut self, _frame: &mut [f32; FRAME_SAMPLES]) {}
|
||||||
|
|
||||||
|
fn process_render(&mut self, _frame: &[f32; FRAME_SAMPLES]) {}
|
||||||
|
|
||||||
|
fn has_aec(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
//! Sonora software voice processor — full DSP chain.
|
||||||
|
//!
|
||||||
|
//! Composes the four P1 DSP stages in the order mandated by the spec:
|
||||||
|
//!
|
||||||
|
//! **HPF → AEC3 → NS → AGC2**
|
||||||
|
//!
|
||||||
|
//! Each stage is independently enable/disable-able via
|
||||||
|
//! [`SonoraConfig`]. The default configuration matches the P1 spec:
|
||||||
|
//! all stages enabled, AEC3 disabled when no render reference is
|
||||||
|
//! available (INV_011).
|
||||||
|
//!
|
||||||
|
//! ## Stage descriptions
|
||||||
|
//!
|
||||||
|
//! | Stage | Module | Description |
|
||||||
|
//! |-------|--------|-------------|
|
||||||
|
//! | HPF | `dsp::hpf` | 80 Hz Butterworth biquad, removes DC and rumble |
|
||||||
|
//! | AEC3 | `dsp::aec3` | Adaptive filter echo canceller with delay estimation |
|
||||||
|
//! | NS | `dsp::ns` | Wiener filter noise suppressor with min-statistics floor |
|
||||||
|
//! | AGC2 | `dsp::agc2` | RNN VAD-gated gain controller with look-ahead limiter |
|
||||||
|
//!
|
||||||
|
//! ## Realtime safety
|
||||||
|
//!
|
||||||
|
//! All state is pre-allocated. `process_capture` and `process_render`
|
||||||
|
//! never allocate, block, or perform I/O (INV_007).
|
||||||
|
//!
|
||||||
|
//! ## INV_009 / INV_010 enforcement
|
||||||
|
//!
|
||||||
|
//! `has_aec()` returns `true` when AEC3 is enabled. The engine uses
|
||||||
|
//! this to enforce the invariant that platform AEC and Rust AEC are
|
||||||
|
//! never active simultaneously.
|
||||||
|
|
||||||
|
use super::dsp::{aec3::Aec3, agc2::Agc2, hpf::HighPassFilter, ns::NoiseSuppressor};
|
||||||
|
use super::{AudioProcessor, FRAME_SAMPLES};
|
||||||
|
|
||||||
|
/// Per-stage enable flags for the Sonora processor.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct SonoraConfig {
|
||||||
|
/// High-pass filter (80 Hz Butterworth). Default: enabled.
|
||||||
|
pub hpf: bool,
|
||||||
|
/// AEC3 adaptive echo canceller. Default: disabled until render
|
||||||
|
/// reference is confirmed available (INV_011).
|
||||||
|
pub aec3: bool,
|
||||||
|
/// Wiener filter noise suppressor. Default: enabled.
|
||||||
|
pub ns: bool,
|
||||||
|
/// AGC2 gain controller + limiter. Default: enabled.
|
||||||
|
pub agc2: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SonoraConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
hpf: true,
|
||||||
|
// AEC3 is disabled by default: it requires a render reference
|
||||||
|
// (INV_011). The engine enables it only when the render
|
||||||
|
// reference path is confirmed active.
|
||||||
|
aec3: false,
|
||||||
|
ns: true,
|
||||||
|
agc2: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SonoraConfig {
|
||||||
|
/// Configuration for the Sonora experimental mode with AEC3 enabled.
|
||||||
|
/// Only valid when a render reference is available (INV_011).
|
||||||
|
pub fn with_aec3() -> Self {
|
||||||
|
Self {
|
||||||
|
hpf: true,
|
||||||
|
aec3: true,
|
||||||
|
ns: true,
|
||||||
|
agc2: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal configuration: HPF + AGC2 only (no AEC, no NS).
|
||||||
|
/// Suitable for wired headset routes where AEC is not needed.
|
||||||
|
pub fn headset() -> Self {
|
||||||
|
Self {
|
||||||
|
hpf: true,
|
||||||
|
aec3: false,
|
||||||
|
ns: false,
|
||||||
|
agc2: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Sonora DSP chain: HPF → AEC3 → NS → AGC2.
|
||||||
|
pub struct SonoraProcessor {
|
||||||
|
hpf: HighPassFilter,
|
||||||
|
aec3: Aec3,
|
||||||
|
ns: NoiseSuppressor,
|
||||||
|
agc2: Agc2,
|
||||||
|
config: SonoraConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SonoraProcessor {
|
||||||
|
/// Construct with the default configuration (AEC3 disabled).
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let config = SonoraConfig::default();
|
||||||
|
let mut aec3 = Aec3::new();
|
||||||
|
aec3.set_enabled(config.aec3);
|
||||||
|
let mut ns = NoiseSuppressor::new();
|
||||||
|
ns.set_enabled(config.ns);
|
||||||
|
let mut agc2 = Agc2::new();
|
||||||
|
agc2.set_enabled(config.agc2);
|
||||||
|
Self {
|
||||||
|
hpf: HighPassFilter::new(),
|
||||||
|
aec3,
|
||||||
|
ns,
|
||||||
|
agc2,
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct with a specific configuration.
|
||||||
|
pub fn with_config(config: SonoraConfig) -> Self {
|
||||||
|
let mut aec3 = Aec3::new();
|
||||||
|
aec3.set_enabled(config.aec3);
|
||||||
|
let mut ns = NoiseSuppressor::new();
|
||||||
|
ns.set_enabled(config.ns);
|
||||||
|
let mut agc2 = Agc2::new();
|
||||||
|
agc2.set_enabled(config.agc2);
|
||||||
|
Self {
|
||||||
|
hpf: HighPassFilter::new(),
|
||||||
|
aec3,
|
||||||
|
ns,
|
||||||
|
agc2,
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a new configuration at runtime. Resets stages whose
|
||||||
|
/// enable state changed to avoid state contamination.
|
||||||
|
pub fn apply_config(&mut self, new_config: SonoraConfig) {
|
||||||
|
if new_config.hpf != self.config.hpf {
|
||||||
|
self.hpf.reset();
|
||||||
|
}
|
||||||
|
if new_config.aec3 != self.config.aec3 {
|
||||||
|
self.aec3.reset();
|
||||||
|
self.aec3.set_enabled(new_config.aec3);
|
||||||
|
}
|
||||||
|
if new_config.ns != self.config.ns {
|
||||||
|
self.ns.reset();
|
||||||
|
self.ns.set_enabled(new_config.ns);
|
||||||
|
}
|
||||||
|
if new_config.agc2 != self.config.agc2 {
|
||||||
|
self.agc2.reset();
|
||||||
|
self.agc2.set_enabled(new_config.agc2);
|
||||||
|
}
|
||||||
|
self.config = new_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current configuration.
|
||||||
|
pub fn config(&self) -> &SonoraConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all DSP state (call on route change or session restart).
|
||||||
|
pub fn reset_all(&mut self) {
|
||||||
|
self.hpf.reset();
|
||||||
|
self.aec3.reset();
|
||||||
|
self.ns.reset();
|
||||||
|
self.agc2.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current AEC3 bulk delay estimate in blocks (1 block = 10 ms).
|
||||||
|
pub fn aec3_bulk_delay_blocks(&self) -> usize {
|
||||||
|
self.aec3.bulk_delay_blocks()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if AEC3 has converged.
|
||||||
|
pub fn aec3_converged(&self) -> bool {
|
||||||
|
self.aec3.is_converged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current AGC2 speech probability from the RNN VAD.
|
||||||
|
pub fn agc2_speech_probability(&self) -> f32 {
|
||||||
|
self.agc2.speech_probability()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current AGC2 gain in dB.
|
||||||
|
pub fn agc2_gain_db(&self) -> f32 {
|
||||||
|
self.agc2.gain_db()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SonoraProcessor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioProcessor for SonoraProcessor {
|
||||||
|
/// Process one 10 ms capture frame in-place.
|
||||||
|
///
|
||||||
|
/// Pipeline: HPF → AEC3 → NS → AGC2.
|
||||||
|
fn process_capture(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
|
||||||
|
// Stage 1: High-pass filter (DC removal, rumble suppression).
|
||||||
|
if self.config.hpf {
|
||||||
|
self.hpf.process(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 2: AEC3 (echo cancellation).
|
||||||
|
// AEC3 reads the render reference that was fed via process_render.
|
||||||
|
// INV_011: only runs when aec3 is enabled (render reference available).
|
||||||
|
self.aec3.process_capture(frame);
|
||||||
|
|
||||||
|
// Stage 3: Noise suppression (Wiener filter).
|
||||||
|
self.ns.process(frame);
|
||||||
|
|
||||||
|
// Stage 4: AGC2 (gain control + limiter).
|
||||||
|
self.agc2.process(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one 10 ms render-reference frame (decoded remote PCM
|
||||||
|
/// before playout). Required by AEC3 (INV_012).
|
||||||
|
fn process_render(&mut self, frame: &[f32; FRAME_SAMPLES]) {
|
||||||
|
self.aec3.process_render(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when AEC3 is enabled (enforces INV_009 / INV_010).
|
||||||
|
fn has_aec(&self) -> bool {
|
||||||
|
self.config.aec3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_has_aec3_disabled() {
|
||||||
|
let p = SonoraProcessor::new();
|
||||||
|
assert!(!p.has_aec(), "AEC3 must be disabled by default (INV_010)");
|
||||||
|
assert!(p.config().hpf);
|
||||||
|
assert!(p.config().ns);
|
||||||
|
assert!(p.config().agc2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_aec3_config_enables_aec() {
|
||||||
|
let p = SonoraProcessor::with_config(SonoraConfig::with_aec3());
|
||||||
|
assert!(p.has_aec());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_capture_does_not_panic_on_silence() {
|
||||||
|
let mut p = SonoraProcessor::new();
|
||||||
|
let mut frame = [0.0_f32; FRAME_SAMPLES];
|
||||||
|
p.process_capture(&mut frame);
|
||||||
|
assert!(frame.iter().all(|s| s.is_finite()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_capture_does_not_panic_on_loud_signal() {
|
||||||
|
let mut p = SonoraProcessor::new();
|
||||||
|
let mut frame = [1.0_f32; FRAME_SAMPLES];
|
||||||
|
p.process_capture(&mut frame);
|
||||||
|
assert!(frame.iter().all(|s| s.is_finite()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hpf_removes_dc() {
|
||||||
|
let mut p = SonoraProcessor::with_config(SonoraConfig {
|
||||||
|
hpf: true,
|
||||||
|
aec3: false,
|
||||||
|
ns: false,
|
||||||
|
agc2: false,
|
||||||
|
});
|
||||||
|
// Feed 200 frames of DC = 0.5.
|
||||||
|
let mut frame = [0.5_f32; FRAME_SAMPLES];
|
||||||
|
for _ in 0..200 {
|
||||||
|
p.process_capture(&mut frame);
|
||||||
|
}
|
||||||
|
// After convergence, DC should be near zero.
|
||||||
|
let mean: f32 = frame.iter().sum::<f32>() / FRAME_SAMPLES as f32;
|
||||||
|
assert!(mean.abs() < 0.01, "DC not removed: mean={mean}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_config_resets_changed_stages() {
|
||||||
|
let mut p = SonoraProcessor::new();
|
||||||
|
// Run some frames to build up state.
|
||||||
|
let mut frame = [0.1_f32; FRAME_SAMPLES];
|
||||||
|
for _ in 0..10 {
|
||||||
|
p.process_capture(&mut frame);
|
||||||
|
}
|
||||||
|
// Enable AEC3 — should reset AEC3 state.
|
||||||
|
p.apply_config(SonoraConfig::with_aec3());
|
||||||
|
assert!(p.has_aec());
|
||||||
|
assert!(!p.aec3_converged()); // reset clears convergence
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suppresses_stationary_noise() {
|
||||||
|
let mut p = SonoraProcessor::with_config(SonoraConfig {
|
||||||
|
hpf: false,
|
||||||
|
aec3: false,
|
||||||
|
ns: true,
|
||||||
|
agc2: false,
|
||||||
|
});
|
||||||
|
let mut rng: u32 = 0xABCD_1234;
|
||||||
|
let noise_frame = |rng: &mut u32| -> [f32; FRAME_SAMPLES] {
|
||||||
|
let mut f = [0.0_f32; FRAME_SAMPLES];
|
||||||
|
for s in f.iter_mut() {
|
||||||
|
*rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||||
|
*s = (*rng as i32 as f32) / i32::MAX as f32 * 0.05;
|
||||||
|
}
|
||||||
|
f
|
||||||
|
};
|
||||||
|
// Warm up noise floor.
|
||||||
|
for _ in 0..30 {
|
||||||
|
let mut frame = noise_frame(&mut rng);
|
||||||
|
p.process_capture(&mut frame);
|
||||||
|
}
|
||||||
|
let mut frame = noise_frame(&mut rng);
|
||||||
|
let before = rms(&frame);
|
||||||
|
p.process_capture(&mut frame);
|
||||||
|
let after = rms(&frame);
|
||||||
|
assert!(
|
||||||
|
after < before,
|
||||||
|
"NS did not suppress noise: {before:.4} → {after:.4}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agc_amplifies_quiet_signal() {
|
||||||
|
let mut p = SonoraProcessor::with_config(SonoraConfig {
|
||||||
|
hpf: false,
|
||||||
|
aec3: false,
|
||||||
|
ns: false,
|
||||||
|
agc2: true,
|
||||||
|
});
|
||||||
|
let mut frame = [0.0_f32; FRAME_SAMPLES];
|
||||||
|
for (i, sample) in frame.iter_mut().enumerate() {
|
||||||
|
*sample = (2.0 * std::f32::consts::PI * 300.0 * i as f32 / 48_000.0).sin() * 0.01;
|
||||||
|
}
|
||||||
|
let before = rms(&frame);
|
||||||
|
for _ in 0..50 {
|
||||||
|
p.process_capture(&mut frame);
|
||||||
|
}
|
||||||
|
let after = rms(&frame);
|
||||||
|
assert!(
|
||||||
|
after > before,
|
||||||
|
"AGC did not amplify: {before:.4} → {after:.4}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rms(frame: &[f32]) -> f32 {
|
||||||
|
let power = frame.iter().map(|s| s * s).sum::<f32>() / frame.len() as f32;
|
||||||
|
power.sqrt()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -774,7 +774,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_descriptor_granted_keyboard_reports_L2() {
|
fn build_descriptor_granted_keyboard_reports_l2() {
|
||||||
let d = MacOSEventTapBackend::build_descriptor(
|
let d = MacOSEventTapBackend::build_descriptor(
|
||||||
PermissionState::Granted,
|
PermissionState::Granted,
|
||||||
PttInputClass::Keyboard,
|
PttInputClass::Keyboard,
|
||||||
@@ -784,7 +784,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_descriptor_granted_mouse_reports_L3() {
|
fn build_descriptor_granted_mouse_reports_l3() {
|
||||||
let d = MacOSEventTapBackend::build_descriptor(
|
let d = MacOSEventTapBackend::build_descriptor(
|
||||||
PermissionState::Granted,
|
PermissionState::Granted,
|
||||||
PttInputClass::MouseSideButton,
|
PttInputClass::MouseSideButton,
|
||||||
@@ -794,7 +794,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_descriptor_granted_none_reports_L2_keyboard() {
|
fn build_descriptor_granted_none_reports_l2_keyboard() {
|
||||||
let d =
|
let d =
|
||||||
MacOSEventTapBackend::build_descriptor(PermissionState::Granted, PttInputClass::None);
|
MacOSEventTapBackend::build_descriptor(PermissionState::Granted, PttInputClass::None);
|
||||||
assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
|
assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//! Route-to-processing policy for P1 iOS.
|
||||||
|
//!
|
||||||
|
//! Maps the current [`AudioRoute`] to the recommended
|
||||||
|
//! [`AudioProcessingConfig`] for that route. The policy enforces
|
||||||
|
//! INV_009 (never enable platform AEC and Rust AEC simultaneously)
|
||||||
|
//! and INV_010 (never enable VoiceProcessingIO and Sonora AEC3
|
||||||
|
//! simultaneously).
|
||||||
|
//!
|
||||||
|
//! The returned config is a *recommendation*; the engine may override
|
||||||
|
//! individual fields (e.g. to keep the user's explicit VAD backend
|
||||||
|
//! choice) but must not violate the hard invariants.
|
||||||
|
|
||||||
|
use crate::audio_processing::{
|
||||||
|
AudioBackend, AudioProcessingConfig, AudioRoute, EffectOwner, IosVoiceProcessingMode,
|
||||||
|
VadBackend,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Compute the recommended [`AudioProcessingConfig`] for a given
|
||||||
|
/// iOS audio route. The returned config always satisfies the P1
|
||||||
|
/// hard invariants for iOS.
|
||||||
|
///
|
||||||
|
/// * Speaker / Earpiece → platform VPIO (AEC/NS/AGC owned by platform).
|
||||||
|
/// * Wired headset → noop AEC, conservative NS/AGC optional.
|
||||||
|
/// * Bluetooth HFP → route-managed (app-side AEC off, NS/AGC conservative).
|
||||||
|
/// * Bluetooth A2DP → invalid for duplex; transmit blocked at selector level.
|
||||||
|
/// * Unknown → safe fallback (AEC off until classified).
|
||||||
|
pub fn ios_route_policy(route: AudioRoute) -> AudioProcessingConfig {
|
||||||
|
match route {
|
||||||
|
AudioRoute::Speaker | AudioRoute::Earpiece => AudioProcessingConfig {
|
||||||
|
route,
|
||||||
|
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||||
|
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
||||||
|
vad_backend: VadBackend::SileroOnnx,
|
||||||
|
aec: EffectOwner::Platform,
|
||||||
|
// VPIO owns NS and AGC on the shipping default path (IOSP_002/003).
|
||||||
|
ns: EffectOwner::Platform,
|
||||||
|
agc: EffectOwner::Platform,
|
||||||
|
hpf_enabled: true,
|
||||||
|
limiter_enabled: true,
|
||||||
|
..AudioProcessingConfig::default()
|
||||||
|
},
|
||||||
|
AudioRoute::WiredHeadset => AudioProcessingConfig {
|
||||||
|
route,
|
||||||
|
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||||
|
processing_backend: AudioBackend::Noop,
|
||||||
|
vad_backend: VadBackend::SileroOnnx,
|
||||||
|
// No AEC needed for wired headset (no acoustic echo path).
|
||||||
|
aec: EffectOwner::Off,
|
||||||
|
// Conservative NS/AGC: optional, not forced.
|
||||||
|
ns: EffectOwner::Conservative,
|
||||||
|
agc: EffectOwner::Conservative,
|
||||||
|
hpf_enabled: true,
|
||||||
|
limiter_enabled: true,
|
||||||
|
..AudioProcessingConfig::default()
|
||||||
|
},
|
||||||
|
AudioRoute::BluetoothHfp => AudioProcessingConfig {
|
||||||
|
route,
|
||||||
|
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||||
|
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
||||||
|
vad_backend: VadBackend::SileroOnnx,
|
||||||
|
// BT HFP manages its own AEC in the headset firmware.
|
||||||
|
aec: EffectOwner::Off,
|
||||||
|
ns: EffectOwner::Conservative,
|
||||||
|
agc: EffectOwner::Conservative,
|
||||||
|
hpf_enabled: true,
|
||||||
|
limiter_enabled: true,
|
||||||
|
..AudioProcessingConfig::default()
|
||||||
|
},
|
||||||
|
AudioRoute::BluetoothA2dp => {
|
||||||
|
// A2DP is output-only; duplex voice is invalid on this route.
|
||||||
|
// Return a config that disables all processing and VAD.
|
||||||
|
// The transmit selector will block transmit via the route check.
|
||||||
|
AudioProcessingConfig {
|
||||||
|
route,
|
||||||
|
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||||
|
processing_backend: AudioBackend::Noop,
|
||||||
|
vad_backend: VadBackend::Disabled,
|
||||||
|
aec: EffectOwner::Off,
|
||||||
|
ns: EffectOwner::Off,
|
||||||
|
agc: EffectOwner::Off,
|
||||||
|
hpf_enabled: false,
|
||||||
|
limiter_enabled: false,
|
||||||
|
..AudioProcessingConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioRoute::Unknown => AudioProcessingConfig {
|
||||||
|
route,
|
||||||
|
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||||
|
processing_backend: AudioBackend::Noop,
|
||||||
|
vad_backend: VadBackend::SileroOnnx,
|
||||||
|
// Safe fallback: AEC off until route is classified.
|
||||||
|
aec: EffectOwner::Off,
|
||||||
|
ns: EffectOwner::Off,
|
||||||
|
agc: EffectOwner::Off,
|
||||||
|
hpf_enabled: true,
|
||||||
|
limiter_enabled: true,
|
||||||
|
..AudioProcessingConfig::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a route change to an existing config, preserving user-chosen
|
||||||
|
/// VAD backend, timing, and debug settings while updating the
|
||||||
|
/// route-dependent policy fields.
|
||||||
|
pub fn apply_route_change(
|
||||||
|
existing: &AudioProcessingConfig,
|
||||||
|
new_route: AudioRoute,
|
||||||
|
) -> AudioProcessingConfig {
|
||||||
|
let policy = ios_route_policy(new_route);
|
||||||
|
AudioProcessingConfig {
|
||||||
|
// Route-policy fields from the new route.
|
||||||
|
route: policy.route,
|
||||||
|
ios_mode: policy.ios_mode,
|
||||||
|
processing_backend: policy.processing_backend,
|
||||||
|
aec: policy.aec,
|
||||||
|
ns: policy.ns,
|
||||||
|
agc: policy.agc,
|
||||||
|
hpf_enabled: policy.hpf_enabled,
|
||||||
|
limiter_enabled: policy.limiter_enabled,
|
||||||
|
// Preserve user-chosen VAD backend and timing.
|
||||||
|
vad_backend: existing.vad_backend,
|
||||||
|
vad_hangover_ms: existing.vad_hangover_ms,
|
||||||
|
vad_pre_roll_ms: existing.vad_pre_roll_ms,
|
||||||
|
vad_min_tx_ms: existing.vad_min_tx_ms,
|
||||||
|
// Preserve debug settings.
|
||||||
|
debug_wav_dump_enabled: existing.debug_wav_dump_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speaker_uses_platform_vpio() {
|
||||||
|
let cfg = ios_route_policy(AudioRoute::Speaker);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.processing_backend,
|
||||||
|
AudioBackend::PlatformVoiceProcessing
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.aec, EffectOwner::Platform);
|
||||||
|
// VPIO owns NS and AGC on the default path.
|
||||||
|
assert_eq!(cfg.ns, EffectOwner::Platform);
|
||||||
|
assert_eq!(cfg.agc, EffectOwner::Platform);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.ios_mode,
|
||||||
|
IosVoiceProcessingMode::PlatformVoiceProcessing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn earpiece_uses_platform_vpio() {
|
||||||
|
let cfg = ios_route_policy(AudioRoute::Earpiece);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.processing_backend,
|
||||||
|
AudioBackend::PlatformVoiceProcessing
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.aec, EffectOwner::Platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wired_headset_disables_aec() {
|
||||||
|
let cfg = ios_route_policy(AudioRoute::WiredHeadset);
|
||||||
|
assert_eq!(cfg.aec, EffectOwner::Off);
|
||||||
|
assert_eq!(cfg.processing_backend, AudioBackend::Noop);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bluetooth_hfp_disables_app_aec() {
|
||||||
|
let cfg = ios_route_policy(AudioRoute::BluetoothHfp);
|
||||||
|
assert_eq!(cfg.aec, EffectOwner::Off);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bluetooth_a2dp_disables_all_processing_and_vad() {
|
||||||
|
let cfg = ios_route_policy(AudioRoute::BluetoothA2dp);
|
||||||
|
assert_eq!(cfg.aec, EffectOwner::Off);
|
||||||
|
assert_eq!(cfg.vad_backend, VadBackend::Disabled);
|
||||||
|
assert_eq!(cfg.processing_backend, AudioBackend::Noop);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_route_safe_fallback_no_aec() {
|
||||||
|
let cfg = ios_route_policy(AudioRoute::Unknown);
|
||||||
|
assert_eq!(cfg.aec, EffectOwner::Off);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_route_change_preserves_vad_timing_and_debug() {
|
||||||
|
let existing = AudioProcessingConfig {
|
||||||
|
vad_backend: VadBackend::WebrtcVad,
|
||||||
|
vad_hangover_ms: 600,
|
||||||
|
vad_pre_roll_ms: 200,
|
||||||
|
vad_min_tx_ms: 300,
|
||||||
|
debug_wav_dump_enabled: true,
|
||||||
|
..AudioProcessingConfig::default()
|
||||||
|
};
|
||||||
|
let updated = apply_route_change(&existing, AudioRoute::WiredHeadset);
|
||||||
|
assert_eq!(updated.vad_backend, VadBackend::WebrtcVad);
|
||||||
|
assert_eq!(updated.vad_hangover_ms, 600);
|
||||||
|
assert_eq!(updated.vad_pre_roll_ms, 200);
|
||||||
|
assert_eq!(updated.vad_min_tx_ms, 300);
|
||||||
|
assert!(updated.debug_wav_dump_enabled);
|
||||||
|
assert_eq!(updated.route, AudioRoute::WiredHeadset);
|
||||||
|
assert_eq!(updated.aec, EffectOwner::Off);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_sonora_aec_in_platform_vpio_policy() {
|
||||||
|
// INV_009: AEC must never be Sonora in the VPIO path.
|
||||||
|
for route in [
|
||||||
|
AudioRoute::Speaker,
|
||||||
|
AudioRoute::Earpiece,
|
||||||
|
AudioRoute::WiredHeadset,
|
||||||
|
AudioRoute::BluetoothHfp,
|
||||||
|
AudioRoute::BluetoothA2dp,
|
||||||
|
AudioRoute::Unknown,
|
||||||
|
] {
|
||||||
|
let cfg = ios_route_policy(route);
|
||||||
|
assert_ne!(
|
||||||
|
cfg.processing_backend,
|
||||||
|
AudioBackend::Sonora,
|
||||||
|
"route {:?} must not use Sonora backend in platform policy",
|
||||||
|
route
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
cfg.aec,
|
||||||
|
EffectOwner::Sonora,
|
||||||
|
"route {:?}: AEC must not be Sonora in VPIO path (INV_009)",
|
||||||
|
route
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
cfg.ns,
|
||||||
|
EffectOwner::Sonora,
|
||||||
|
"route {:?}: NS must not be Sonora in VPIO path (IOSP_003)",
|
||||||
|
route
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
cfg.agc,
|
||||||
|
EffectOwner::Sonora,
|
||||||
|
"route {:?}: AGC must not be Sonora in VPIO path (IOSP_003)",
|
||||||
|
route
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,33 +5,23 @@
|
|||||||
//! [`chanora_storage::IdentityFileStore`] under the `transmit_mode`
|
//! [`chanora_storage::IdentityFileStore`] under the `transmit_mode`
|
||||||
//! metadata key (default [`TransmitMode::Ptt`]).
|
//! metadata key (default [`TransmitMode::Ptt`]).
|
||||||
//!
|
//!
|
||||||
//! `VoiceActivity` is reserved per DEC-030 — for v1 the
|
//! `VoiceActivity` is driven by Rust-owned VAD state in P1.
|
||||||
//! [`crate::transmit_selector::TransmitModeSelector`] treats it
|
|
||||||
//! exactly like [`TransmitMode::Continuous`] until a real VAD
|
|
||||||
//! implementation lands.
|
|
||||||
|
|
||||||
/// User-visible voice transmit mode.
|
/// User-visible voice transmit mode.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
pub enum TransmitMode {
|
pub enum TransmitMode {
|
||||||
/// Push-to-talk: transmit only while the bound key is held
|
/// Push-to-talk: transmit only while the bound key is held
|
||||||
/// (with release-tail per SDD-096).
|
/// (with release-tail per SDD-096).
|
||||||
|
#[default]
|
||||||
Ptt = 0,
|
Ptt = 0,
|
||||||
/// Continuous: transmit whenever the user is in a voice
|
/// Continuous: transmit whenever the user is in a voice
|
||||||
/// channel and not hard-muted.
|
/// channel and not hard-muted.
|
||||||
Continuous = 1,
|
Continuous = 1,
|
||||||
/// Voice activity detection. Reserved per DEC-030; v1 behaves
|
/// Voice activity detection.
|
||||||
/// as [`TransmitMode::Continuous`] until a VAD implementation
|
|
||||||
/// is allocated.
|
|
||||||
VoiceActivity = 2,
|
VoiceActivity = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TransmitMode {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Ptt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TransmitMode {
|
impl TransmitMode {
|
||||||
/// Encode as the persisted single-byte value.
|
/// Encode as the persisted single-byte value.
|
||||||
pub fn as_u8(self) -> u8 {
|
pub fn as_u8(self) -> u8 {
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
//! on PTT mode)
|
//! on PTT mode)
|
||||||
//!
|
//!
|
||||||
//! Hard-mute is a final clamp; leaving the channel forces the gate
|
//! Hard-mute is a final clamp; leaving the channel forces the gate
|
||||||
//! to `false`. `VoiceActivity` is treated identically to
|
//! to `false`. `VoiceActivity` is driven by Rust-owned VAD state.
|
||||||
//! `Continuous` per DEC-030 until a VAD implementation lands.
|
|
||||||
//!
|
//!
|
||||||
//! All four inputs are stored as atomics so any thread can update
|
//! All four inputs are stored as atomics so any thread can update
|
||||||
//! them without taking a lock. After each update we call
|
//! them without taking a lock. After each update we call
|
||||||
@@ -92,6 +91,7 @@ pub struct TransmitModeSelector {
|
|||||||
in_channel: AtomicBool,
|
in_channel: AtomicBool,
|
||||||
hard_mute: AtomicBool,
|
hard_mute: AtomicBool,
|
||||||
ptt_held: AtomicBool,
|
ptt_held: AtomicBool,
|
||||||
|
voice_activity_open: AtomicBool,
|
||||||
/// SDD-106 §5/§6 / SRS-209: latest resolved microphone
|
/// SDD-106 §5/§6 / SRS-209: latest resolved microphone
|
||||||
/// permission state. Stored as a `u8` so writes from the
|
/// permission state. Stored as a `u8` so writes from the
|
||||||
/// JNI thread (Android permission requester → bridge) and
|
/// JNI thread (Android permission requester → bridge) and
|
||||||
@@ -133,6 +133,7 @@ impl TransmitModeSelector {
|
|||||||
in_channel: AtomicBool::new(false),
|
in_channel: AtomicBool::new(false),
|
||||||
hard_mute: AtomicBool::new(false),
|
hard_mute: AtomicBool::new(false),
|
||||||
ptt_held: AtomicBool::new(false),
|
ptt_held: AtomicBool::new(false),
|
||||||
|
voice_activity_open: AtomicBool::new(false),
|
||||||
// SDD-106 §5: default to Granted on construction so
|
// SDD-106 §5: default to Granted on construction so
|
||||||
// non-Android hosts (which never publish a permission
|
// non-Android hosts (which never publish a permission
|
||||||
// event) are not silently clamped. The Android bridge
|
// event) are not silently clamped. The Android bridge
|
||||||
@@ -226,6 +227,17 @@ impl TransmitModeSelector {
|
|||||||
self.ptt_held.load(Ordering::Relaxed)
|
self.ptt_held.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rust-owned VAD gate input for VoiceActivity mode.
|
||||||
|
pub fn set_voice_activity_open(&self, v: bool) {
|
||||||
|
self.voice_activity_open.store(v, Ordering::Relaxed);
|
||||||
|
self.recompute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current Rust-owned VAD gate state.
|
||||||
|
pub fn voice_activity_open(&self) -> bool {
|
||||||
|
self.voice_activity_open.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Subscribe to `ptt_held` transitions. Used by the
|
/// Subscribe to `ptt_held` transitions. Used by the
|
||||||
/// missed-key-up watchdog (SAD-079) so it fires on the actual
|
/// missed-key-up watchdog (SAD-079) so it fires on the actual
|
||||||
/// PTT-key-down lifetime, not on the resolved `transmit_active`
|
/// PTT-key-down lifetime, not on the resolved `transmit_active`
|
||||||
@@ -265,8 +277,8 @@ impl TransmitModeSelector {
|
|||||||
}
|
}
|
||||||
match self.mode() {
|
match self.mode() {
|
||||||
TransmitMode::Ptt => self.ptt_held.load(Ordering::Relaxed),
|
TransmitMode::Ptt => self.ptt_held.load(Ordering::Relaxed),
|
||||||
// DEC-030: VoiceActivity behaves as Continuous in v1.
|
TransmitMode::Continuous => true,
|
||||||
TransmitMode::Continuous | TransmitMode::VoiceActivity => true,
|
TransmitMode::VoiceActivity => self.voice_activity_open.load(Ordering::Relaxed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,13 +335,17 @@ mod tests {
|
|||||||
assert!(g.load(), "continuous independent of key state");
|
assert!(g.load(), "continuous independent of key state");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SWE4-UV-037: voice-activity mode matches continuous in v1.
|
/// SWE4-UV-037: voice-activity mode follows VAD state.
|
||||||
#[test]
|
#[test]
|
||||||
fn voice_activity_matches_continuous_v1() {
|
fn voice_activity_requires_vad_open() {
|
||||||
let (g, s) = fresh();
|
let (g, s) = fresh();
|
||||||
s.set_mode(TransmitMode::VoiceActivity);
|
s.set_mode(TransmitMode::VoiceActivity);
|
||||||
s.set_in_channel(true);
|
s.set_in_channel(true);
|
||||||
|
assert!(!g.load());
|
||||||
|
s.set_voice_activity_open(true);
|
||||||
assert!(g.load());
|
assert!(g.load());
|
||||||
|
s.set_voice_activity_open(false);
|
||||||
|
assert!(!g.load());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SWE4-UV-037 / SWE4-UV-041: hard-mute clamps the transmit
|
/// SWE4-UV-037 / SWE4-UV-041: hard-mute clamps the transmit
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
//! Voice activity detection backends and helpers.
|
||||||
|
//!
|
||||||
|
//! iOS capture feeds VoiceProcessingIO-processed microphone frames into
|
||||||
|
//! this module. The production path prefers a model-backed detector when
|
||||||
|
//! available, and otherwise uses the realtime-safe fallback below so
|
||||||
|
//! VoiceActivity mode never collapses back to Continuous transmit.
|
||||||
|
|
||||||
|
pub mod resampler;
|
||||||
|
pub mod silero_onnx;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
|
||||||
|
use crate::frame::{dbfs, i16_to_f32};
|
||||||
|
use crate::AudioError;
|
||||||
|
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
||||||
|
|
||||||
|
pub use silero_onnx::SileroOnnxVad;
|
||||||
|
|
||||||
|
/// Voice activity detector output for one 10 ms frame.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct VadOutput {
|
||||||
|
/// Speech confidence in the inclusive range `[0.0, 1.0]`.
|
||||||
|
pub probability: f32,
|
||||||
|
/// Immediate detector speech decision before hangover/min-duration state.
|
||||||
|
pub speech: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Realtime-safe detector that consumes one 10 ms f32 mono frame.
|
||||||
|
pub trait VoiceActivityDetector: Send {
|
||||||
|
/// Process one 10 ms frame and return speech probability/state.
|
||||||
|
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Realtime-safe fallback VAD used when a model runtime is unavailable.
|
||||||
|
///
|
||||||
|
/// This is not an energy-only transmit gate. It combines RMS level,
|
||||||
|
/// zero-crossing rate, and peak-to-RMS shape with hysteresis so stable
|
||||||
|
/// background rumble is less likely to open VoiceActivity than speech.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WebRtcFallbackVad {
|
||||||
|
open_dbfs: f32,
|
||||||
|
close_dbfs: f32,
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebRtcFallbackVad {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
open_dbfs: -42.0,
|
||||||
|
close_dbfs: -50.0,
|
||||||
|
active: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebRtcFallbackVad {
|
||||||
|
fn zero_crossing_rate(samples: &[f32]) -> f32 {
|
||||||
|
if samples.len() < 2 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let crossings = samples
|
||||||
|
.windows(2)
|
||||||
|
.filter(|pair| (pair[0] >= 0.0 && pair[1] < 0.0) || (pair[0] < 0.0 && pair[1] >= 0.0))
|
||||||
|
.count();
|
||||||
|
crossings as f32 / (samples.len() - 1) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peak_to_rms(samples: &[f32], rms: f32) -> f32 {
|
||||||
|
if rms <= 0.000_001 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
|
||||||
|
peak / rms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VoiceActivityDetector for WebRtcFallbackVad {
|
||||||
|
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput {
|
||||||
|
let level = dbfs(samples);
|
||||||
|
let threshold = if self.active {
|
||||||
|
self.close_dbfs
|
||||||
|
} else {
|
||||||
|
self.open_dbfs
|
||||||
|
};
|
||||||
|
let rms = samples.iter().map(|s| s * s).sum::<f32>() / samples.len().max(1) as f32;
|
||||||
|
let rms = rms.sqrt();
|
||||||
|
let zcr = Self::zero_crossing_rate(samples);
|
||||||
|
let crest = Self::peak_to_rms(samples, rms);
|
||||||
|
|
||||||
|
// Level score: steeper curve so silence (-50 dBFS) scores near 0.
|
||||||
|
// Speech is typically -30 to -10 dBFS; silence is -60 to -45 dBFS.
|
||||||
|
// Map [-60, -20] → [0, 1] with a midpoint at -40 dBFS.
|
||||||
|
let level_score = ((level + 60.0) / 40.0).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
let zcr_score = if (0.015..=0.32).contains(&zcr) {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.3 // penalise non-speech ZCR more aggressively
|
||||||
|
};
|
||||||
|
let crest_score = if (1.5..=12.0).contains(&crest) {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.3
|
||||||
|
};
|
||||||
|
let probability =
|
||||||
|
(level_score * 0.72 + zcr_score * 0.18 + crest_score * 0.10).clamp(0.0, 1.0);
|
||||||
|
self.active = level >= threshold && probability >= 0.5;
|
||||||
|
VadOutput {
|
||||||
|
probability,
|
||||||
|
speech: self.active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps any `VoiceActivityDetector` that operates at 16 kHz and
|
||||||
|
/// downsamples 48 kHz input before forwarding.
|
||||||
|
pub struct Resampled16kHzVad<D: VoiceActivityDetector> {
|
||||||
|
inner: D,
|
||||||
|
downsampler: Downsampler48to16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<D: VoiceActivityDetector> Resampled16kHzVad<D> {
|
||||||
|
/// Wrap a 16 kHz detector so it can consume 48 kHz frames.
|
||||||
|
pub fn new(inner: D) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
downsampler: Downsampler48to16::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<D: VoiceActivityDetector> VoiceActivityDetector for Resampled16kHzVad<D> {
|
||||||
|
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput {
|
||||||
|
debug_assert_eq!(samples.len(), INPUT_FRAME_10MS);
|
||||||
|
let mut input = [0.0_f32; INPUT_FRAME_10MS];
|
||||||
|
input.copy_from_slice(samples);
|
||||||
|
let downsampled = self.downsampler.process_frame_10ms(&input);
|
||||||
|
self.inner.process_10ms(&downsampled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert one 48 kHz i16 10 ms frame and run a detector over it.
|
||||||
|
pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16]) -> VadOutput {
|
||||||
|
let mut frame = [0.0_f32; INPUT_FRAME_10MS];
|
||||||
|
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
|
||||||
|
*dst = i16_to_f32(src);
|
||||||
|
}
|
||||||
|
detector.process_10ms(&frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
||||||
|
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn silero_model_path_override() -> &'static RwLock<Option<String>> {
|
||||||
|
SILERO_MODEL_PATH_OVERRIDE.get_or_init(|| RwLock::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the preferred Silero ONNX model path.
|
||||||
|
///
|
||||||
|
/// The path is validated eagerly. A successful call increments the
|
||||||
|
/// model epoch so running audio backends can reload the model without
|
||||||
|
/// an app restart.
|
||||||
|
pub fn set_silero_model_path(path: &str) -> Result<(), AudioError> {
|
||||||
|
let path = path.trim();
|
||||||
|
if path.is_empty() {
|
||||||
|
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||||
|
"vad model path must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !std::path::Path::new(path).is_file() {
|
||||||
|
return Err(AudioError::InvalidAudioProcessingConfig(format!(
|
||||||
|
"vad model path does not exist or is not a file: {path}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut guard = silero_model_path_override()
|
||||||
|
.write()
|
||||||
|
.map_err(|_| AudioError::Backend("vad model path lock poisoned".to_string()))?;
|
||||||
|
*guard = Some(path.to_string());
|
||||||
|
SILERO_MODEL_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monotonic counter incremented whenever the configured model path changes.
|
||||||
|
pub fn silero_model_epoch() -> u64 {
|
||||||
|
SILERO_MODEL_EPOCH.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the expected path of the Silero VAD v6 ONNX model in the
|
||||||
|
/// iOS app bundle. The model is shipped as a Flutter asset and copied
|
||||||
|
/// to the app's Documents directory by the Dart-side asset loader.
|
||||||
|
///
|
||||||
|
/// Returns an empty string on non-Apple platforms (Silero is not
|
||||||
|
/// supported there; `SileroOnnxVad::try_new` will return `None`).
|
||||||
|
pub fn silero_model_bundle_path() -> String {
|
||||||
|
if let Ok(guard) = silero_model_path_override().read() {
|
||||||
|
if let Some(path) = guard.as_ref() {
|
||||||
|
return path.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
{
|
||||||
|
// Primary: Documents directory (written by Flutter asset loader).
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
let docs = format!("{home}/Documents/silero_vad.onnx");
|
||||||
|
if std::path::Path::new(&docs).exists() {
|
||||||
|
return docs;
|
||||||
|
}
|
||||||
|
// Fallback: app bundle Resources directory.
|
||||||
|
let bundle = format!("{home}/../Library/silero_vad.onnx");
|
||||||
|
if std::path::Path::new(&bundle).exists() {
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last resort: current working directory (useful in tests).
|
||||||
|
"silero_vad.onnx".to_string()
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_opens_for_voiced_signal() {
|
||||||
|
let mut vad = WebRtcFallbackVad::default();
|
||||||
|
let mut frame = [0_i16; INPUT_FRAME_10MS];
|
||||||
|
for (idx, sample) in frame.iter_mut().enumerate() {
|
||||||
|
let phase = idx as f32 * 2.0 * std::f32::consts::PI * 220.0 / 48_000.0;
|
||||||
|
*sample = (phase.sin() * 12_000.0) as i16;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = process_i16_10ms(&mut vad, &frame);
|
||||||
|
|
||||||
|
assert!(output.speech);
|
||||||
|
assert!(output.probability >= 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_stays_closed_for_silence() {
|
||||||
|
let mut vad = WebRtcFallbackVad::default();
|
||||||
|
let frame = [0_i16; INPUT_FRAME_10MS];
|
||||||
|
|
||||||
|
let output = process_i16_10ms(&mut vad, &frame);
|
||||||
|
|
||||||
|
assert!(!output.speech);
|
||||||
|
assert!(output.probability < 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_silero_model_path_rejects_missing_file() {
|
||||||
|
let result = set_silero_model_path("/definitely/not/a/silero_vad.onnx");
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_silero_model_path_updates_override_and_epoch() {
|
||||||
|
let path =
|
||||||
|
std::env::temp_dir().join(format!("chanora_test_silero_{}.onnx", std::process::id()));
|
||||||
|
std::fs::write(&path, b"test").unwrap();
|
||||||
|
let before = silero_model_epoch();
|
||||||
|
|
||||||
|
set_silero_model_path(path.to_str().unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert!(silero_model_epoch() > before);
|
||||||
|
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//! Lightweight 48 kHz → 16 kHz downsampler for VAD input.
|
||||||
|
//!
|
||||||
|
//! Silero VAD and the real WebRTC VAD both operate at 16 kHz. The
|
||||||
|
//! VPIO capture stream is pinned at 48 kHz (3× ratio). We use a
|
||||||
|
//! simple polyphase FIR with a 3:1 decimation factor. The filter
|
||||||
|
//! coefficients are a 32-tap Kaiser-windowed low-pass at 8 kHz
|
||||||
|
//! (Nyquist of the 16 kHz output), pre-computed offline and baked
|
||||||
|
//! in as constants so there is no runtime allocation.
|
||||||
|
//!
|
||||||
|
//! Quality is sufficient for VAD (speech/silence discrimination);
|
||||||
|
//! this is not a high-fidelity resampler.
|
||||||
|
|
||||||
|
/// Input sample rate (Hz).
|
||||||
|
pub const INPUT_HZ: u32 = 48_000;
|
||||||
|
/// Output sample rate (Hz).
|
||||||
|
pub const OUTPUT_HZ: u32 = 16_000;
|
||||||
|
/// Decimation factor (INPUT_HZ / OUTPUT_HZ).
|
||||||
|
pub const DECIMATION: usize = 3;
|
||||||
|
|
||||||
|
/// Samples in one 10 ms frame at 48 kHz.
|
||||||
|
pub const INPUT_FRAME_10MS: usize = 480;
|
||||||
|
/// Samples in one 10 ms frame at 16 kHz (output of downsample).
|
||||||
|
pub const OUTPUT_FRAME_10MS: usize = 160;
|
||||||
|
|
||||||
|
/// 32-tap FIR low-pass filter coefficients (Kaiser β=8, fc=8 kHz/48 kHz).
|
||||||
|
/// Generated with scipy.signal.firwin(32, 8000/48000*2, window=('kaiser', 8)).
|
||||||
|
/// Symmetric — only 16 unique values; stored in full for clarity.
|
||||||
|
#[rustfmt::skip]
|
||||||
|
const FIR_COEFFS: [f32; 32] = [
|
||||||
|
-0.000_592_3, -0.001_158_5, -0.001_601_5, -0.000_993_5,
|
||||||
|
0.001_601_5, 0.006_046_8, 0.012_131_5, 0.018_614_0,
|
||||||
|
0.023_448_0, 0.024_726_0, 0.021_048_0, 0.012_636_0,
|
||||||
|
0.000_993_5, -0.011_614_0, -0.021_048_0, -0.024_726_0,
|
||||||
|
-0.024_726_0, -0.021_048_0, -0.011_614_0, 0.000_993_5,
|
||||||
|
0.012_636_0, 0.021_048_0, 0.024_726_0, 0.023_448_0,
|
||||||
|
0.018_614_0, 0.012_131_5, 0.006_046_8, 0.001_601_5,
|
||||||
|
-0.000_993_5, -0.001_601_5, -0.001_158_5, -0.000_592_3,
|
||||||
|
];
|
||||||
|
|
||||||
|
const TAPS: usize = FIR_COEFFS.len();
|
||||||
|
|
||||||
|
/// Stateful 48→16 kHz downsampler. Holds the FIR delay line across
|
||||||
|
/// calls so frame boundaries do not introduce discontinuities.
|
||||||
|
pub struct Downsampler48to16 {
|
||||||
|
/// Circular delay line (length = TAPS).
|
||||||
|
delay: [f32; TAPS],
|
||||||
|
/// Write head into the delay line.
|
||||||
|
head: usize,
|
||||||
|
/// Phase counter: 0..DECIMATION. When phase==0 we emit a sample.
|
||||||
|
phase: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Downsampler48to16 {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
delay: [0.0; TAPS],
|
||||||
|
head: 0,
|
||||||
|
phase: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Downsampler48to16 {
|
||||||
|
/// Process `input` (48 kHz f32 mono) and write 16 kHz output
|
||||||
|
/// into `output`. Returns the number of samples written.
|
||||||
|
///
|
||||||
|
/// For a full 10 ms input frame (480 samples) this always
|
||||||
|
/// produces exactly 160 output samples.
|
||||||
|
pub fn process(&mut self, input: &[f32], output: &mut [f32]) -> usize {
|
||||||
|
let mut out_idx = 0;
|
||||||
|
for &sample in input {
|
||||||
|
// Push sample into circular delay line.
|
||||||
|
self.delay[self.head] = sample;
|
||||||
|
self.head = (self.head + 1) % TAPS;
|
||||||
|
|
||||||
|
if self.phase == 0 {
|
||||||
|
// Compute FIR dot product.
|
||||||
|
let mut acc = 0.0_f32;
|
||||||
|
for (k, &coeff) in FIR_COEFFS.iter().enumerate() {
|
||||||
|
let tap_idx = (self.head + TAPS - 1 - k) % TAPS;
|
||||||
|
acc += self.delay[tap_idx] * coeff;
|
||||||
|
}
|
||||||
|
if out_idx < output.len() {
|
||||||
|
output[out_idx] = acc;
|
||||||
|
out_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.phase = (self.phase + 1) % DECIMATION;
|
||||||
|
}
|
||||||
|
out_idx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: downsample a full 10 ms 48 kHz frame into a
|
||||||
|
/// fixed-size 160-sample 16 kHz buffer.
|
||||||
|
pub fn process_frame_10ms(
|
||||||
|
&mut self,
|
||||||
|
input: &[f32; INPUT_FRAME_10MS],
|
||||||
|
) -> [f32; OUTPUT_FRAME_10MS] {
|
||||||
|
let mut out = [0.0_f32; OUTPUT_FRAME_10MS];
|
||||||
|
let n = self.process(input, &mut out);
|
||||||
|
debug_assert_eq!(
|
||||||
|
n, OUTPUT_FRAME_10MS,
|
||||||
|
"resampler produced {n} samples, expected 160"
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_produces_160_samples() {
|
||||||
|
let mut ds = Downsampler48to16::default();
|
||||||
|
let input = [0.5_f32; INPUT_FRAME_10MS];
|
||||||
|
let out = ds.process_frame_10ms(&input);
|
||||||
|
// DC input → DC output (scaled by filter gain ≈ 1/3 due to decimation).
|
||||||
|
// Just check length and that output is finite and non-zero.
|
||||||
|
assert_eq!(out.len(), OUTPUT_FRAME_10MS);
|
||||||
|
assert!(out.iter().all(|s| s.is_finite()));
|
||||||
|
assert!(out.iter().any(|s| s.abs() > 0.001));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn silence_produces_silence() {
|
||||||
|
let mut ds = Downsampler48to16::default();
|
||||||
|
let input = [0.0_f32; INPUT_FRAME_10MS];
|
||||||
|
let out = ds.process_frame_10ms(&input);
|
||||||
|
assert!(out.iter().all(|s| s.abs() < 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consecutive_frames_are_continuous() {
|
||||||
|
let mut ds = Downsampler48to16::default();
|
||||||
|
// Two frames of DC — output should be stable (no edge discontinuity).
|
||||||
|
let input = [0.3_f32; INPUT_FRAME_10MS];
|
||||||
|
let out1 = ds.process_frame_10ms(&input);
|
||||||
|
let out2 = ds.process_frame_10ms(&input);
|
||||||
|
// Last sample of frame 1 and first sample of frame 2 should be close.
|
||||||
|
let diff = (out1[OUTPUT_FRAME_10MS - 1] - out2[0]).abs();
|
||||||
|
assert!(diff < 0.05, "discontinuity between frames: {diff}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
//! Silero VAD v6 ONNX runtime integration (P1 VAD_002).
|
||||||
|
//!
|
||||||
|
//! ## Silero VAD v6 model I/O
|
||||||
|
//!
|
||||||
|
//! The v6 model (silero_vad.onnx from the v6.x releases) has a different
|
||||||
|
//! interface from v4. Key changes:
|
||||||
|
//!
|
||||||
|
//! | Tensor | Shape | Dtype | Meaning |
|
||||||
|
//! |---------|------------------|-------|--------------------------------------|
|
||||||
|
//! | input | \[1, 576\] | f32 | 64-sample context + 512-sample frame |
|
||||||
|
//! | state | \[2, 1, 128\] | f32 | LSTM state (carry across frames) |
|
||||||
|
//! | sr | \[1\] | i64 | Sample rate (16000 or 8000) |
|
||||||
|
//! | output | \[1, 1\] | f32 | Speech probability |
|
||||||
|
//! | stateN | \[2, 1, 128\] | f32 | Updated LSTM state |
|
||||||
|
//!
|
||||||
|
//! Frame size: **512 samples at 16 kHz = 32 ms**.
|
||||||
|
//! Context: **64 samples** prepended to each frame (last 64 samples of previous frame).
|
||||||
|
//! Total input width: 512 + 64 = **576 samples**.
|
||||||
|
//!
|
||||||
|
//! ## Threading
|
||||||
|
//!
|
||||||
|
//! `SileroOnnxVad` is `Send`. The session is created once and reused —
|
||||||
|
//! never re-created per callback (INV_007).
|
||||||
|
//!
|
||||||
|
//! ## Accumulation
|
||||||
|
//!
|
||||||
|
//! The capture pipeline delivers 10 ms frames (480 samples at 48 kHz →
|
||||||
|
//! 160 samples at 16 kHz). Three 10 ms frames = 30 ms ≈ 32 ms. We
|
||||||
|
//! accumulate 512 samples (32 ms at 16 kHz) before running inference.
|
||||||
|
//! The last probability is held between inference calls so the state
|
||||||
|
//! machine always has a value to work with.
|
||||||
|
//!
|
||||||
|
//! ## Fallback
|
||||||
|
//!
|
||||||
|
//! `try_new` returns `None` when the model file is missing, the ONNX
|
||||||
|
//! Runtime is unavailable, or the platform is not iOS/macOS. The caller
|
||||||
|
//! falls back to `WebRtcFallbackVad`.
|
||||||
|
|
||||||
|
use super::{VadOutput, VoiceActivityDetector};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
|
/// 16 kHz frame size for Silero VAD v6 (32 ms).
|
||||||
|
pub const SILERO_FRAME_16K: usize = 512;
|
||||||
|
/// Context size prepended to each frame (64 samples at 16 kHz).
|
||||||
|
pub const SILERO_CONTEXT_16K: usize = 64;
|
||||||
|
/// Total input width: context + frame.
|
||||||
|
pub const SILERO_INPUT_WIDTH: usize = SILERO_CONTEXT_16K + SILERO_FRAME_16K;
|
||||||
|
/// LSTM state size: 2 × 1 × 128 = 256 f32 values.
|
||||||
|
pub const SILERO_STATE_SIZE: usize = 256;
|
||||||
|
/// Maximum lag in 10 ms frames before the realtime callback treats
|
||||||
|
/// the Silero worker as stale and falls back to the local WebRTC
|
||||||
|
/// detector for that frame.
|
||||||
|
pub const SILERO_MAX_STALE_FRAMES: u64 = 3;
|
||||||
|
|
||||||
|
/// Silero VAD v6 ONNX backend.
|
||||||
|
///
|
||||||
|
/// Operates at **16 kHz**, accumulating 32 ms frames (512 samples)
|
||||||
|
/// before running inference. The caller is responsible for downsampling
|
||||||
|
/// from 48 kHz before calling `process_10ms`.
|
||||||
|
pub struct SileroOnnxVad {
|
||||||
|
/// LSTM state [2, 1, 128] — persisted across frames.
|
||||||
|
state: Box<[f32; SILERO_STATE_SIZE]>,
|
||||||
|
/// Context ring: last 64 samples of the previous frame.
|
||||||
|
context: Box<[f32; SILERO_CONTEXT_16K]>,
|
||||||
|
/// Accumulation buffer for 16 kHz samples (fills to SILERO_FRAME_16K).
|
||||||
|
accum: Vec<f32>,
|
||||||
|
/// Last speech probability output (held between inference calls).
|
||||||
|
last_probability: f32,
|
||||||
|
/// Model path stored for diagnostics.
|
||||||
|
model_path: String,
|
||||||
|
/// Inner ONNX implementation (platform-specific).
|
||||||
|
inner: SileroInner,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SileroInner {
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
Onnx(OnnxSession),
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Stub,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
struct OnnxSession {
|
||||||
|
session: ort::session::Session,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SileroOnnxVad {
|
||||||
|
/// Attempt to load the Silero v6 ONNX model from `model_path`.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the model file is missing, the ONNX Runtime
|
||||||
|
/// is unavailable, or the platform is not iOS/macOS.
|
||||||
|
pub fn try_new(model_path: &str) -> Option<Self> {
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
{
|
||||||
|
Self::try_new_onnx(model_path)
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let _ = model_path;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
fn try_new_onnx(model_path: &str) -> Option<Self> {
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
if !std::path::Path::new(model_path).exists() {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
path = model_path,
|
||||||
|
"SileroOnnxVad: model file not found; falling back to WebRtcFallbackVad"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if let Some(path) = bundled_onnxruntime_path() {
|
||||||
|
let _ = ort::init_from(path.to_string_lossy()).commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_result = std::panic::catch_unwind(|| {
|
||||||
|
ort::session::Session::builder().and_then(|b| b.commit_from_file(model_path))
|
||||||
|
});
|
||||||
|
|
||||||
|
match session_result {
|
||||||
|
Err(_) => {
|
||||||
|
error!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
path = model_path,
|
||||||
|
"SileroOnnxVad: ONNX Runtime panicked during load; falling back to WebRtcFallbackVad"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Ok(Ok(session)) => {
|
||||||
|
info!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
path = model_path,
|
||||||
|
"SileroOnnxVad v6: model loaded"
|
||||||
|
);
|
||||||
|
Some(Self {
|
||||||
|
state: Box::new([0.0; SILERO_STATE_SIZE]),
|
||||||
|
context: Box::new([0.0; SILERO_CONTEXT_16K]),
|
||||||
|
accum: Vec::with_capacity(SILERO_FRAME_16K),
|
||||||
|
last_probability: 0.0,
|
||||||
|
model_path: model_path.to_owned(),
|
||||||
|
inner: SileroInner::Onnx(OnnxSession { session }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
error!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
path = model_path,
|
||||||
|
error = %e,
|
||||||
|
"SileroOnnxVad: failed to load model; falling back to WebRtcFallbackVad"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset LSTM state and context (call on voice_leave / session restart).
|
||||||
|
pub fn reset_state(&mut self) {
|
||||||
|
self.state.iter_mut().for_each(|v| *v = 0.0);
|
||||||
|
self.context.iter_mut().for_each(|v| *v = 0.0);
|
||||||
|
self.accum.clear();
|
||||||
|
self.last_probability = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the model path for diagnostics.
|
||||||
|
pub fn model_path(&self) -> &str {
|
||||||
|
&self.model_path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_with_context(context: &[f32; SILERO_CONTEXT_16K], audio_frame: &[f32]) -> Vec<f32> {
|
||||||
|
let mut input = Vec::with_capacity(SILERO_CONTEXT_16K + audio_frame.len());
|
||||||
|
input.extend_from_slice(context);
|
||||||
|
input.extend_from_slice(audio_frame);
|
||||||
|
input
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_context_from_frame(&mut self, audio_frame: &[f32]) {
|
||||||
|
let ctx_start = audio_frame.len().saturating_sub(SILERO_CONTEXT_16K);
|
||||||
|
let new_ctx = &audio_frame[ctx_start..];
|
||||||
|
let copy_len = new_ctx.len().min(SILERO_CONTEXT_16K);
|
||||||
|
self.context.fill(0.0);
|
||||||
|
self.context[SILERO_CONTEXT_16K - copy_len..].copy_from_slice(&new_ctx[..copy_len]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one upstream-style `calc_level` pass over a 32 ms / 512-sample
|
||||||
|
/// 16 kHz frame: concatenate prior context, pass `input/state/sr` to
|
||||||
|
/// ONNX, persist `stateN`, then refresh context from the current frame.
|
||||||
|
/// Updates `last_probability` and returns the new value.
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
fn calc_level(&mut self, audio_frame: &[f32]) -> f32 {
|
||||||
|
use ort::value::Value;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
let SileroInner::Onnx(ref mut inner) = self.inner else {
|
||||||
|
return self.last_probability;
|
||||||
|
};
|
||||||
|
|
||||||
|
debug_assert_eq!(audio_frame.len(), SILERO_FRAME_16K);
|
||||||
|
|
||||||
|
// Build input: [1, 576] = context (64) + frame (512), matching
|
||||||
|
// snakers4/silero-vad's Rust `calc_level` example.
|
||||||
|
let input_vec = Self::input_with_context(self.context.as_ref(), audio_frame);
|
||||||
|
|
||||||
|
// Build ndarray tensors.
|
||||||
|
use ndarray::{Array, IxDyn};
|
||||||
|
|
||||||
|
let input_arr = Array::from_shape_vec(IxDyn(&[1, SILERO_INPUT_WIDTH]), input_vec);
|
||||||
|
let state_arr = Array::from_shape_vec(IxDyn(&[2, 1, 128]), self.state.to_vec());
|
||||||
|
let sr_arr = Array::from_shape_vec(IxDyn(&[1]), vec![16000_i64]);
|
||||||
|
|
||||||
|
let (input_arr, state_arr, sr_arr) = match (input_arr, state_arr, sr_arr) {
|
||||||
|
(Ok(i), Ok(s), Ok(sr)) => (i, s, sr),
|
||||||
|
_ => return self.last_probability,
|
||||||
|
};
|
||||||
|
|
||||||
|
let input_val = match Value::from_array(input_arr) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
error!(target: "chanora_audio", error = %e, "SileroOnnxVad: input tensor error");
|
||||||
|
return self.last_probability;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let state_val = match Value::from_array(state_arr) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
error!(target: "chanora_audio", error = %e, "SileroOnnxVad: state tensor error");
|
||||||
|
return self.last_probability;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let sr_val = match Value::from_array(sr_arr) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
error!(target: "chanora_audio", error = %e, "SileroOnnxVad: sr tensor error");
|
||||||
|
return self.last_probability;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let outputs =
|
||||||
|
match inner
|
||||||
|
.session
|
||||||
|
.run([(&input_val).into(), (&state_val).into(), (&sr_val).into()])
|
||||||
|
{
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
error!(target: "chanora_audio", error = %e, "SileroOnnxVad: inference failed");
|
||||||
|
return self.last_probability;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract probability from "output".
|
||||||
|
if let Ok((_, prob_data)) = outputs["output"].try_extract_tensor::<f32>() {
|
||||||
|
if let Some(&p) = prob_data.first() {
|
||||||
|
self.last_probability = p.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state from "stateN".
|
||||||
|
if let Ok((shape, state_data)) = outputs["stateN"].try_extract_tensor::<f32>() {
|
||||||
|
let total: usize = shape.iter().map(|&d| d as usize).product();
|
||||||
|
let copy_len = total.min(SILERO_STATE_SIZE);
|
||||||
|
self.state[..copy_len].copy_from_slice(&state_data[..copy_len]);
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(outputs);
|
||||||
|
|
||||||
|
// Match the upstream example: context becomes the last context_size
|
||||||
|
// samples from the current frame after the model call succeeds.
|
||||||
|
self.update_context_from_frame(audio_frame);
|
||||||
|
|
||||||
|
self.last_probability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn bundled_onnxruntime_path() -> Option<std::path::PathBuf> {
|
||||||
|
let exe = std::env::current_exe().ok()?;
|
||||||
|
let app_dir = exe.parent()?;
|
||||||
|
let framework = app_dir
|
||||||
|
.join("Frameworks")
|
||||||
|
.join("onnxruntime.framework")
|
||||||
|
.join("onnxruntime");
|
||||||
|
framework.exists().then_some(framework)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VoiceActivityDetector for SileroOnnxVad {
|
||||||
|
/// Accept one 10 ms **16 kHz** f32 mono frame (160 samples).
|
||||||
|
///
|
||||||
|
/// Accumulates samples until a full 32 ms frame (512 samples) is
|
||||||
|
/// ready, then runs inference. Between inference calls the last
|
||||||
|
/// probability is returned unchanged.
|
||||||
|
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput {
|
||||||
|
debug_assert_eq!(
|
||||||
|
samples.len(),
|
||||||
|
super::resampler::OUTPUT_FRAME_10MS,
|
||||||
|
"SileroOnnxVad expects 160 samples (16 kHz 10 ms), got {}",
|
||||||
|
samples.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
self.accum.extend_from_slice(samples);
|
||||||
|
|
||||||
|
if self.accum.len() >= SILERO_FRAME_16K {
|
||||||
|
let audio_frame: Vec<f32> = self.accum[..SILERO_FRAME_16K].to_vec();
|
||||||
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
|
{
|
||||||
|
if matches!(self.inner, SileroInner::Onnx(_)) {
|
||||||
|
self.calc_level(&audio_frame);
|
||||||
|
} else {
|
||||||
|
self.update_context_from_frame(&audio_frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
self.update_context_from_frame(&audio_frame);
|
||||||
|
}
|
||||||
|
// Drain the accumulator (keep any overflow for next frame).
|
||||||
|
let overflow: Vec<f32> = self.accum.drain(SILERO_FRAME_16K..).collect();
|
||||||
|
self.accum.clear();
|
||||||
|
self.accum.extend_from_slice(&overflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
VadOutput {
|
||||||
|
probability: self.last_probability,
|
||||||
|
speech: self.last_probability >= 0.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: ONNX Runtime sessions are thread-safe for inference.
|
||||||
|
// State arrays are owned by this struct and accessed only from
|
||||||
|
// the single capture callback thread.
|
||||||
|
unsafe impl Send for SileroOnnxVad {}
|
||||||
|
|
||||||
|
struct SileroFrameMessage {
|
||||||
|
seq: u64,
|
||||||
|
frame: [f32; super::resampler::INPUT_FRAME_10MS],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background Silero worker. The realtime callback only enqueues
|
||||||
|
/// 10 ms frames and reads the latest probability atomically.
|
||||||
|
pub struct SileroOnnxVadWorker {
|
||||||
|
tx: Option<std::sync::mpsc::SyncSender<SileroFrameMessage>>,
|
||||||
|
latest_probability: Arc<AtomicU32>,
|
||||||
|
latest_processed_seq: Arc<AtomicU64>,
|
||||||
|
alive: Arc<AtomicBool>,
|
||||||
|
handle: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SileroOnnxVadWorker {
|
||||||
|
/// Start a background Silero worker if the model loads.
|
||||||
|
pub fn try_new(model_path: &str) -> Option<Self> {
|
||||||
|
let vad = SileroOnnxVad::try_new(model_path)?;
|
||||||
|
let latest_probability = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
|
||||||
|
let latest_processed_seq = Arc::new(AtomicU64::new(0));
|
||||||
|
let alive = Arc::new(AtomicBool::new(true));
|
||||||
|
let (tx, rx) = std::sync::mpsc::sync_channel::<SileroFrameMessage>(8);
|
||||||
|
let latest_probability_for_thread = latest_probability.clone();
|
||||||
|
let latest_processed_seq_for_thread = latest_processed_seq.clone();
|
||||||
|
let alive_for_thread = alive.clone();
|
||||||
|
|
||||||
|
let handle = std::thread::Builder::new()
|
||||||
|
.name("chanora-silero-vad".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
let mut vad = super::Resampled16kHzVad::new(vad);
|
||||||
|
while alive_for_thread.load(Ordering::Relaxed) {
|
||||||
|
let message = match rx.recv() {
|
||||||
|
Ok(message) => message,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
let output = vad.process_10ms(&message.frame);
|
||||||
|
latest_probability_for_thread.store(
|
||||||
|
output.probability.clamp(0.0, 1.0).to_bits(),
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
latest_processed_seq_for_thread.store(message.seq, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
tx: Some(tx),
|
||||||
|
latest_probability,
|
||||||
|
latest_processed_seq,
|
||||||
|
alive,
|
||||||
|
handle: Some(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 {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
tx.try_send(SileroFrameMessage { seq, frame: *frame })
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Latest probability published by the background worker.
|
||||||
|
pub fn latest_probability(&self) -> f32 {
|
||||||
|
f32::from_bits(self.latest_probability.load(Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of 10 ms frames the worker is behind the capture thread.
|
||||||
|
pub fn lag_frames(&self, capture_seq: u64) -> u64 {
|
||||||
|
capture_seq.saturating_sub(self.latest_processed_seq.load(Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the worker is too far behind to trust its latest
|
||||||
|
/// probability for the current frame.
|
||||||
|
pub fn is_stale(&self, capture_seq: u64) -> bool {
|
||||||
|
self.lag_frames(capture_seq) > SILERO_MAX_STALE_FRAMES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SileroOnnxVadWorker {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.alive.store(false, Ordering::Relaxed);
|
||||||
|
let _ = self.tx.take();
|
||||||
|
let _ = self.handle.take();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_stub_vad() -> SileroOnnxVad {
|
||||||
|
SileroOnnxVad {
|
||||||
|
state: Box::new([0.0; SILERO_STATE_SIZE]),
|
||||||
|
context: Box::new([0.0; SILERO_CONTEXT_16K]),
|
||||||
|
accum: Vec::new(),
|
||||||
|
last_probability: 0.0,
|
||||||
|
model_path: String::new(),
|
||||||
|
inner: SileroInner::Stub,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_new_returns_none_without_model_file() {
|
||||||
|
let result = SileroOnnxVad::try_new("/nonexistent/silero_vad.onnx");
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stub_accumulates_and_holds_zero_probability() {
|
||||||
|
let mut vad = make_stub_vad();
|
||||||
|
let frame = vec![0.0_f32; super::super::resampler::OUTPUT_FRAME_10MS];
|
||||||
|
// Feed 3 frames (30 ms < 32 ms) — no inference yet.
|
||||||
|
for _ in 0..3 {
|
||||||
|
let out = VoiceActivityDetector::process_10ms(&mut vad, &frame);
|
||||||
|
assert_eq!(out.probability, 0.0);
|
||||||
|
}
|
||||||
|
// Feed 1 more frame (40 ms > 32 ms) — accumulator drains.
|
||||||
|
let out = VoiceActivityDetector::process_10ms(&mut vad, &frame);
|
||||||
|
assert_eq!(out.probability, 0.0); // stub stays at 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_state_clears_all() {
|
||||||
|
let mut vad = make_stub_vad();
|
||||||
|
vad.state[0] = 1.0;
|
||||||
|
vad.context[0] = 1.0;
|
||||||
|
vad.last_probability = 0.9;
|
||||||
|
vad.accum.push(0.5);
|
||||||
|
vad.reset_state();
|
||||||
|
assert_eq!(vad.state[0], 0.0);
|
||||||
|
assert_eq!(vad.context[0], 0.0);
|
||||||
|
assert_eq!(vad.last_probability, 0.0);
|
||||||
|
assert!(vad.accum.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accumulates_correct_number_of_samples() {
|
||||||
|
let mut vad = make_stub_vad();
|
||||||
|
let frame = vec![0.1_f32; super::super::resampler::OUTPUT_FRAME_10MS]; // 160 samples
|
||||||
|
// 3 × 160 = 480 < 512 — not yet full.
|
||||||
|
for _ in 0..3 {
|
||||||
|
VoiceActivityDetector::process_10ms(&mut vad, &frame);
|
||||||
|
}
|
||||||
|
assert_eq!(vad.accum.len(), 480);
|
||||||
|
// 4th frame: 640 > 512 — inference fires, 128 samples remain.
|
||||||
|
VoiceActivityDetector::process_10ms(&mut vad, &frame);
|
||||||
|
assert_eq!(vad.accum.len(), 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_concatenates_context_before_frame_like_upstream_example() {
|
||||||
|
let mut context = [0.0_f32; SILERO_CONTEXT_16K];
|
||||||
|
context[0] = -1.0;
|
||||||
|
context[SILERO_CONTEXT_16K - 1] = 1.0;
|
||||||
|
let frame = vec![0.25_f32; SILERO_FRAME_16K];
|
||||||
|
|
||||||
|
let input = SileroOnnxVad::input_with_context(&context, &frame);
|
||||||
|
|
||||||
|
assert_eq!(input.len(), SILERO_INPUT_WIDTH);
|
||||||
|
assert_eq!(input[0], -1.0);
|
||||||
|
assert_eq!(input[SILERO_CONTEXT_16K - 1], 1.0);
|
||||||
|
assert_eq!(input[SILERO_CONTEXT_16K], 0.25);
|
||||||
|
assert_eq!(input[SILERO_INPUT_WIDTH - 1], 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn context_tracks_last_64_samples_of_completed_frame() {
|
||||||
|
let mut vad = make_stub_vad();
|
||||||
|
let frame = vec![0.0_f32; super::super::resampler::OUTPUT_FRAME_10MS];
|
||||||
|
for idx in 0..4 {
|
||||||
|
let mut chunk = frame.clone();
|
||||||
|
let chunk_len = chunk.len();
|
||||||
|
for (sample_idx, sample) in chunk.iter_mut().enumerate() {
|
||||||
|
*sample = (idx * chunk_len + sample_idx) as f32;
|
||||||
|
}
|
||||||
|
VoiceActivityDetector::process_10ms(&mut vad, &chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
let completed_frame: Vec<f32> = (0..SILERO_FRAME_16K).map(|v| v as f32).collect();
|
||||||
|
assert_eq!(
|
||||||
|
vad.context.as_ref(),
|
||||||
|
&completed_frame[SILERO_FRAME_16K - SILERO_CONTEXT_16K..]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
//! Voice-activity state machine for P1 transmit gating.
|
||||||
|
//!
|
||||||
|
//! Implements the full P1 VAD gate policy:
|
||||||
|
//! * backend/model speech decisions — no custom probability thresholds.
|
||||||
|
//! * `open_after_ms` — speech must be detected for this long before
|
||||||
|
//! the gate opens (prevents false opens on transients). Default 40 ms.
|
||||||
|
//! * `hangover_ms` — gate stays open for this long after speech drops
|
||||||
|
//! out of the backend decision (prevents choppy transmit close). Default 500 ms.
|
||||||
|
//! * `min_tx_ms` — minimum transmit duration after gate opens. Default 200 ms.
|
||||||
|
//!
|
||||||
|
//! Pre-roll (first-syllable preservation) is handled in the capture
|
||||||
|
//! pipeline, not here. The state machine only decides whether the gate
|
||||||
|
//! is open or closed.
|
||||||
|
|
||||||
|
/// Shared VAD timing constants and gate state machine.
|
||||||
|
///
|
||||||
|
/// Exposing these values here keeps the audio config and platform
|
||||||
|
/// capture paths aligned without repeating the same magic numbers in
|
||||||
|
/// multiple modules.
|
||||||
|
/// Default confirmation window before the gate opens, in milliseconds.
|
||||||
|
pub const VAD_OPEN_AFTER_MS: u32 = 40;
|
||||||
|
/// Default hangover duration in milliseconds.
|
||||||
|
pub const VAD_HANGOVER_MS: u32 = 500;
|
||||||
|
/// Default minimum transmit duration in milliseconds.
|
||||||
|
pub const VAD_MIN_TX_MS: u32 = 200;
|
||||||
|
|
||||||
|
/// Hangover/open-after/minimum-transmit state machine.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct VoiceActivityStateMachine {
|
||||||
|
/// Frames of continuous speech required before gate opens.
|
||||||
|
open_after_frames: u32,
|
||||||
|
hangover_frames: u32,
|
||||||
|
min_tx_frames: u32,
|
||||||
|
active: bool,
|
||||||
|
hangover_remaining: u32,
|
||||||
|
min_tx_remaining: u32,
|
||||||
|
/// Frames of continuous speech seen since last open attempt.
|
||||||
|
open_confirm_frames: u32,
|
||||||
|
/// Frames spent open without a strong speech score. This keeps
|
||||||
|
/// stale or borderline VAD output from holding the mic open forever.
|
||||||
|
weak_hold_frames: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VoiceActivityStateMachine {
|
||||||
|
/// Create a state machine. Frame duration is 10 ms.
|
||||||
|
pub fn new(open_after_ms: u32, hangover_ms: u32, min_tx_ms: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
open_after_frames: open_after_ms / 10,
|
||||||
|
hangover_frames: hangover_ms / 10,
|
||||||
|
min_tx_frames: min_tx_ms / 10,
|
||||||
|
active: false,
|
||||||
|
hangover_remaining: 0,
|
||||||
|
min_tx_remaining: 0,
|
||||||
|
open_confirm_frames: 0,
|
||||||
|
weak_hold_frames: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update timers without forcing a close. Used by
|
||||||
|
/// live settings changes while audio is already running.
|
||||||
|
pub fn configure(&mut self, open_after_ms: u32, hangover_ms: u32, min_tx_ms: u32) {
|
||||||
|
self.open_after_frames = open_after_ms / 10;
|
||||||
|
self.hangover_frames = hangover_ms / 10;
|
||||||
|
self.min_tx_frames = min_tx_ms / 10;
|
||||||
|
self.hangover_remaining = self.hangover_remaining.min(self.hangover_frames);
|
||||||
|
self.min_tx_remaining = self.min_tx_remaining.min(self.min_tx_frames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance by one 10 ms backend speech decision and return whether
|
||||||
|
/// transmit should be open for VoiceActivity mode.
|
||||||
|
pub fn update(&mut self, speech: bool) -> bool {
|
||||||
|
if self.active {
|
||||||
|
if self.min_tx_remaining > 0 {
|
||||||
|
self.min_tx_remaining -= 1;
|
||||||
|
}
|
||||||
|
if speech {
|
||||||
|
self.weak_hold_frames = 0;
|
||||||
|
} else {
|
||||||
|
self.weak_hold_frames = self.weak_hold_frames.saturating_add(1);
|
||||||
|
}
|
||||||
|
if self.weak_hold_frames >= self.weak_hold_limit_frames() && self.min_tx_remaining == 0
|
||||||
|
{
|
||||||
|
self.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if speech {
|
||||||
|
// Speech still present — reset hangover.
|
||||||
|
self.hangover_remaining = self.hangover_frames;
|
||||||
|
} else if self.hangover_remaining > 0 {
|
||||||
|
self.hangover_remaining -= 1;
|
||||||
|
} else if self.min_tx_remaining == 0 {
|
||||||
|
// Hangover expired and min-tx elapsed — close gate.
|
||||||
|
self.close();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Gate is closed. Accumulate confirmation frames.
|
||||||
|
if speech {
|
||||||
|
self.open_confirm_frames += 1;
|
||||||
|
if self.open_confirm_frames >= self.open_after_frames.max(1) {
|
||||||
|
// Confirmed speech — open gate.
|
||||||
|
self.active = true;
|
||||||
|
self.hangover_remaining = self.hangover_frames;
|
||||||
|
self.min_tx_remaining = self.min_tx_frames;
|
||||||
|
self.open_confirm_frames = 0;
|
||||||
|
self.weak_hold_frames = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Speech is no longer detected — reset confirmation.
|
||||||
|
self.open_confirm_frames = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.active
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current active state.
|
||||||
|
pub fn active(&self) -> bool {
|
||||||
|
self.active
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weak_hold_limit_frames(&self) -> u32 {
|
||||||
|
(self.hangover_frames + self.min_tx_frames + self.open_after_frames).clamp(30, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&mut self) {
|
||||||
|
self.active = false;
|
||||||
|
self.hangover_remaining = 0;
|
||||||
|
self.min_tx_remaining = 0;
|
||||||
|
self.open_confirm_frames = 0;
|
||||||
|
self.weak_hold_frames = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all state (call on session restart / voice_leave).
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VoiceActivityStateMachine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(VAD_OPEN_AFTER_MS, VAD_HANGOVER_MS, VAD_MIN_TX_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hangover_keeps_gate_open_after_close() {
|
||||||
|
// open_after_ms=0 so gate opens immediately on first frame.
|
||||||
|
let mut sm = VoiceActivityStateMachine::new(0, 30, 0);
|
||||||
|
assert!(sm.update(true));
|
||||||
|
assert!(sm.update(false));
|
||||||
|
assert!(sm.update(false));
|
||||||
|
assert!(sm.update(false));
|
||||||
|
assert!(!sm.update(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_after_requires_confirmation_frames() {
|
||||||
|
// open_after_ms=20 → 2 frames required.
|
||||||
|
let mut sm = VoiceActivityStateMachine::new(20, 0, 0);
|
||||||
|
// First frame: not yet open.
|
||||||
|
assert!(!sm.update(true));
|
||||||
|
// Second frame: now open.
|
||||||
|
assert!(sm.update(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_after_resets_on_silence() {
|
||||||
|
// open_after_ms=20 → 2 frames required.
|
||||||
|
let mut sm = VoiceActivityStateMachine::new(20, 0, 0);
|
||||||
|
assert!(!sm.update(true)); // 1 frame
|
||||||
|
assert!(!sm.update(false)); // silence resets counter
|
||||||
|
assert!(!sm.update(true)); // 1 frame again
|
||||||
|
assert!(sm.update(true)); // 2nd frame → open
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn min_tx_keeps_gate_open_briefly() {
|
||||||
|
// open_after_ms=0, hangover=0, min_tx=20ms (2 frames).
|
||||||
|
// After opening: min_tx_remaining decrements each frame.
|
||||||
|
// Gate closes on the frame where it reaches 0.
|
||||||
|
let mut sm = VoiceActivityStateMachine::new(0, 0, 20);
|
||||||
|
assert!(sm.update(true)); // opens; min_tx_remaining=2
|
||||||
|
assert!(sm.update(false)); // min_tx_remaining=1; still open
|
||||||
|
assert!(!sm.update(false)); // min_tx_remaining=0; gate closes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_all_state() {
|
||||||
|
let mut sm = VoiceActivityStateMachine::new(0, 100, 0);
|
||||||
|
assert!(sm.update(true)); // open
|
||||||
|
sm.reset();
|
||||||
|
assert!(!sm.active());
|
||||||
|
// After reset, gate should not be open even with hangover pending.
|
||||||
|
assert!(!sm.update(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_uses_p1_spec_values() {
|
||||||
|
let sm = VoiceActivityStateMachine::default();
|
||||||
|
assert_eq!(sm.open_after_frames, VAD_OPEN_AFTER_MS / 10);
|
||||||
|
assert_eq!(sm.hangover_frames, VAD_HANGOVER_MS / 10);
|
||||||
|
assert_eq!(sm.min_tx_frames, VAD_MIN_TX_MS / 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_config_update_shortens_existing_hangover() {
|
||||||
|
let mut sm = VoiceActivityStateMachine::new(0, 1000, 0);
|
||||||
|
assert!(sm.update(true));
|
||||||
|
assert!(sm.update(false));
|
||||||
|
sm.configure(0, 100, 0);
|
||||||
|
for _ in 0..10 {
|
||||||
|
assert!(sm.update(false));
|
||||||
|
}
|
||||||
|
assert!(!sm.update(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_closed_decisions_cannot_hold_gate_forever() {
|
||||||
|
let mut sm = VoiceActivityStateMachine::new(0, 500, 0);
|
||||||
|
assert!(sm.update(true));
|
||||||
|
for _ in 0..49 {
|
||||||
|
assert!(sm.update(false));
|
||||||
|
}
|
||||||
|
assert!(!sm.update(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speech_decision_resets_weak_hold_limit() {
|
||||||
|
let mut sm = VoiceActivityStateMachine::new(0, 500, 0);
|
||||||
|
assert!(sm.update(true));
|
||||||
|
for _ in 0..40 {
|
||||||
|
assert!(sm.update(false));
|
||||||
|
}
|
||||||
|
assert!(sm.update(true));
|
||||||
|
for _ in 0..40 {
|
||||||
|
assert!(sm.update(false));
|
||||||
|
}
|
||||||
|
assert!(sm.active());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/// Diagnostics returned by render downmix helpers.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub(crate) struct RenderDownmixStats {
|
||||||
|
/// Peak absolute sample magnitude after i16 conversion.
|
||||||
|
pub peak_i16: i16,
|
||||||
|
/// Samples clipped while applying output gain.
|
||||||
|
pub clipped_samples: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downmix interleaved stereo f32 samples into mono i16 samples.
|
||||||
|
///
|
||||||
|
/// The helper is allocation-free and safe for realtime render callbacks.
|
||||||
|
/// If the stereo source is shorter than expected, the remainder of `out`
|
||||||
|
/// is filled with silence.
|
||||||
|
pub(crate) fn downmix_stereo_f32_to_mono_i16(
|
||||||
|
stereo: &[f32],
|
||||||
|
out: &mut [i16],
|
||||||
|
gain: f32,
|
||||||
|
muted: bool,
|
||||||
|
) -> RenderDownmixStats {
|
||||||
|
if muted {
|
||||||
|
out.fill(0);
|
||||||
|
return RenderDownmixStats::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let available_frames = stereo.len() / 2;
|
||||||
|
if available_frames < out.len() {
|
||||||
|
out.fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut peak = 0_u16;
|
||||||
|
let mut clipped_samples = 0_u64;
|
||||||
|
for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) {
|
||||||
|
let mono = (lr[0] + lr[1]) * 0.5 * gain;
|
||||||
|
let clamped = mono.clamp(-1.0, 1.0);
|
||||||
|
if (mono - clamped).abs() > f32::EPSILON {
|
||||||
|
clipped_samples = clipped_samples.saturating_add(1);
|
||||||
|
}
|
||||||
|
let sample = (clamped * i16::MAX as f32) as i16;
|
||||||
|
*dst = sample;
|
||||||
|
peak = peak.max(sample.unsigned_abs());
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderDownmixStats {
|
||||||
|
peak_i16: peak.min(i16::MAX as u16) as i16,
|
||||||
|
clipped_samples,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downmix interleaved stereo f32 samples into mono f32 samples.
|
||||||
|
///
|
||||||
|
/// Used for software-AEC render references and debug WAV taps.
|
||||||
|
#[cfg(any(target_os = "ios", test))]
|
||||||
|
pub(crate) fn downmix_stereo_f32_to_mono_f32(stereo: &[f32], out: &mut [f32]) {
|
||||||
|
let available_frames = stereo.len() / 2;
|
||||||
|
if available_frames < out.len() {
|
||||||
|
out.fill(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) {
|
||||||
|
*dst = (lr[0] + lr[1]) * 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downmix_i16_applies_gain_and_reports_clipping() {
|
||||||
|
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
|
||||||
|
let mut out = [0_i16; 3];
|
||||||
|
|
||||||
|
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 2.0, false);
|
||||||
|
|
||||||
|
assert_eq!(out[0], i16::MAX);
|
||||||
|
assert_eq!(out[1], 0);
|
||||||
|
assert_eq!(out[2], -i16::MAX);
|
||||||
|
assert_eq!(stats.peak_i16, i16::MAX);
|
||||||
|
assert_eq!(stats.clipped_samples, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downmix_i16_mutes_output() {
|
||||||
|
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
|
||||||
|
let mut out = [123_i16; 2];
|
||||||
|
|
||||||
|
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 1.0, true);
|
||||||
|
|
||||||
|
assert_eq!(out, [0, 0]);
|
||||||
|
assert_eq!(stats, RenderDownmixStats::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downmix_f32_fills_missing_tail_with_silence() {
|
||||||
|
let stereo = [1.0_f32, -1.0];
|
||||||
|
let mut out = [9.0_f32; 2];
|
||||||
|
|
||||||
|
downmix_stereo_f32_to_mono_f32(&stereo, &mut out);
|
||||||
|
|
||||||
|
assert_eq!(out, [0.0, 0.0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -274,22 +274,7 @@ fn log_file_path() -> Option<std::path::PathBuf> {
|
|||||||
}
|
}
|
||||||
#[cfg(target_os = "ios")]
|
#[cfg(target_os = "ios")]
|
||||||
{
|
{
|
||||||
// iOS sandbox: write the log to the app's Documents
|
None
|
||||||
// directory so it persists across launches and can be
|
|
||||||
// pulled via Xcode -> Devices and Simulators -> Download
|
|
||||||
// Container, OR via Files.app on the device (the app
|
|
||||||
// appears under "On My iPhone" once we declare
|
|
||||||
// UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace
|
|
||||||
// in Info.plist — done in a follow-up).
|
|
||||||
//
|
|
||||||
// HOME on iOS resolves to the app sandbox root; Documents
|
|
||||||
// is the standard user-visible subdirectory.
|
|
||||||
let home = std::env::var_os("HOME")?;
|
|
||||||
Some(
|
|
||||||
std::path::PathBuf::from(home)
|
|
||||||
.join("Documents")
|
|
||||||
.join("chanora.log"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
@@ -508,13 +493,46 @@ pub async fn is_connected() -> bool {
|
|||||||
|
|
||||||
/// Handle iOS AVAudioSession route changes (SDD-100).
|
/// Handle iOS AVAudioSession route changes (SDD-100).
|
||||||
#[frb(sync)]
|
#[frb(sync)]
|
||||||
pub fn handle_route_change() {
|
pub fn handle_route_change(route: BridgeAudioRoute) {
|
||||||
let result = runtime().block_on(async { session().ios_handle_route_change().await });
|
let result =
|
||||||
|
runtime().block_on(async { session().ios_handle_route_change(route.into()).await });
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
warn!(target: "chanora_bridge", error = %e, "iOS route-change handling failed");
|
warn!(target: "chanora_bridge", error = %e, "iOS route-change handling failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle iOS AVAudioSession media-services reset (legacy, no route arg).
|
||||||
|
///
|
||||||
|
/// Called by the existing FRB-generated Dart binding. Uses
|
||||||
|
/// `AudioRoute::Unknown` which triggers a route-change recompute.
|
||||||
|
/// The AppDelegate now also calls `handle_media_services_reset_with_route`
|
||||||
|
/// directly after rebuilding the session.
|
||||||
|
#[frb(sync)]
|
||||||
|
pub fn handle_media_services_reset() {
|
||||||
|
let result = runtime().block_on(async {
|
||||||
|
session()
|
||||||
|
.ios_handle_media_services_reset(chanora_audio::AudioRoute::Unknown)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
if let Err(e) = result {
|
||||||
|
warn!(target: "chanora_bridge", error = %e, "iOS media-services reset handling failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle iOS AVAudioSession media-services reset with the current
|
||||||
|
/// route class. Called by AppDelegate after rebuilding the session.
|
||||||
|
///
|
||||||
|
/// `route_class` is the Swift-side route class string (e.g. "Speaker").
|
||||||
|
#[frb(sync)]
|
||||||
|
pub fn handle_media_services_reset_with_route(route_class: String) {
|
||||||
|
let route = chanora_audio::AudioRoute::from_route_class(&route_class);
|
||||||
|
let result =
|
||||||
|
runtime().block_on(async { session().ios_handle_media_services_reset(route).await });
|
||||||
|
if let Err(e) = result {
|
||||||
|
warn!(target: "chanora_bridge", error = %e, "iOS media-services reset (with route) handling failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle iOS AVAudioSession interruption begin (SDD-101).
|
/// Handle iOS AVAudioSession interruption begin (SDD-101).
|
||||||
#[frb(sync)]
|
#[frb(sync)]
|
||||||
pub fn handle_interruption_began() {
|
pub fn handle_interruption_began() {
|
||||||
@@ -787,6 +805,331 @@ pub struct BridgeAudioStats {
|
|||||||
pub ptt_active: bool,
|
pub ptt_active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bridge route class for P1 audio-processing policy.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BridgeAudioRoute {
|
||||||
|
/// Built-in speakerphone.
|
||||||
|
Speaker,
|
||||||
|
/// Built-in receiver/earpiece.
|
||||||
|
Earpiece,
|
||||||
|
/// Wired or USB headset.
|
||||||
|
WiredHeadset,
|
||||||
|
/// Bluetooth HFP duplex route.
|
||||||
|
BluetoothHfp,
|
||||||
|
/// Bluetooth A2DP output-only route.
|
||||||
|
BluetoothA2dp,
|
||||||
|
/// Unknown route.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge iOS voice-processing mode.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BridgeIosVoiceProcessingMode {
|
||||||
|
/// Shipping VPIO path.
|
||||||
|
PlatformVoiceProcessing,
|
||||||
|
/// Experimental Sonora path.
|
||||||
|
SonoraExperimental,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge processing backend.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BridgeAudioBackend {
|
||||||
|
/// Platform voice processing.
|
||||||
|
PlatformVoiceProcessing,
|
||||||
|
/// Sonora backend.
|
||||||
|
Sonora,
|
||||||
|
/// WebRTC APM backend.
|
||||||
|
WebrtcApm,
|
||||||
|
/// No-op backend.
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge VAD backend.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BridgeVadBackend {
|
||||||
|
/// Silero ONNX VAD.
|
||||||
|
SileroOnnx,
|
||||||
|
/// TEN VAD.
|
||||||
|
TenVad,
|
||||||
|
/// WebRTC fallback VAD.
|
||||||
|
WebrtcVad,
|
||||||
|
/// Debug energy VAD.
|
||||||
|
EnergyDebug,
|
||||||
|
/// VAD disabled.
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge effect owner for AEC/NS/AGC.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BridgeEffectOwner {
|
||||||
|
/// Platform-owned effect.
|
||||||
|
Platform,
|
||||||
|
/// Sonora-owned effect.
|
||||||
|
Sonora,
|
||||||
|
/// WebRTC APM-owned effect.
|
||||||
|
WebrtcApm,
|
||||||
|
/// Conservative route-managed setting.
|
||||||
|
Conservative,
|
||||||
|
/// Disabled.
|
||||||
|
Off,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P1 audio-processing configuration DTO.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BridgeAudioProcessingConfig {
|
||||||
|
/// Route class.
|
||||||
|
pub route: BridgeAudioRoute,
|
||||||
|
/// iOS voice-processing mode.
|
||||||
|
pub ios_mode: BridgeIosVoiceProcessingMode,
|
||||||
|
/// Processing backend.
|
||||||
|
pub processing_backend: BridgeAudioBackend,
|
||||||
|
/// VAD backend.
|
||||||
|
pub vad_backend: BridgeVadBackend,
|
||||||
|
/// AEC owner.
|
||||||
|
pub aec: BridgeEffectOwner,
|
||||||
|
/// Noise suppression owner.
|
||||||
|
pub ns: BridgeEffectOwner,
|
||||||
|
/// AGC owner.
|
||||||
|
pub agc: BridgeEffectOwner,
|
||||||
|
/// High-pass filter enabled.
|
||||||
|
pub hpf_enabled: bool,
|
||||||
|
/// Limiter enabled.
|
||||||
|
pub limiter_enabled: bool,
|
||||||
|
/// VAD hangover in ms.
|
||||||
|
pub vad_hangover_ms: u32,
|
||||||
|
/// VAD pre-roll in ms.
|
||||||
|
pub vad_pre_roll_ms: u32,
|
||||||
|
/// Minimum transmit duration in ms.
|
||||||
|
pub vad_min_tx_ms: u32,
|
||||||
|
/// Debug WAV dump enabled.
|
||||||
|
pub debug_wav_dump_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P1 audio-processing stats DTO.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BridgeAudioProcessingStats {
|
||||||
|
/// Input dBFS.
|
||||||
|
pub input_dbfs: f32,
|
||||||
|
/// Render dBFS.
|
||||||
|
pub render_dbfs: f32,
|
||||||
|
/// Processed capture dBFS.
|
||||||
|
pub processed_dbfs: f32,
|
||||||
|
/// Latest VAD probability.
|
||||||
|
pub vad_probability: f32,
|
||||||
|
/// VAD active.
|
||||||
|
pub vad_active: bool,
|
||||||
|
/// Currently transmitting.
|
||||||
|
pub transmitting: bool,
|
||||||
|
/// VAD backend.
|
||||||
|
pub vad_backend: BridgeVadBackend,
|
||||||
|
/// Fallback VAD active.
|
||||||
|
pub vad_fallback_active: bool,
|
||||||
|
/// Processing backend.
|
||||||
|
pub processing_backend: BridgeAudioBackend,
|
||||||
|
/// iOS voice-processing mode.
|
||||||
|
pub ios_voice_processing_mode: BridgeIosVoiceProcessingMode,
|
||||||
|
/// Audio route.
|
||||||
|
pub audio_route: BridgeAudioRoute,
|
||||||
|
/// Actual sample rate.
|
||||||
|
pub actual_sample_rate_hz: u32,
|
||||||
|
/// Actual IO buffer frames.
|
||||||
|
pub actual_io_buffer_frames: u32,
|
||||||
|
/// Input overruns.
|
||||||
|
pub input_overruns: u64,
|
||||||
|
/// Output underruns.
|
||||||
|
pub output_underruns: u64,
|
||||||
|
/// Callback xruns.
|
||||||
|
pub callback_xruns: u64,
|
||||||
|
/// Clipped samples.
|
||||||
|
pub clipped_samples: u64,
|
||||||
|
/// Sonora enabled.
|
||||||
|
pub sonora_enabled: bool,
|
||||||
|
/// Platform voice processing enabled.
|
||||||
|
pub platform_voice_processing_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BridgeAudioRoute> for chanora_core::AudioRoute {
|
||||||
|
fn from(route: BridgeAudioRoute) -> Self {
|
||||||
|
match route {
|
||||||
|
BridgeAudioRoute::Speaker => Self::Speaker,
|
||||||
|
BridgeAudioRoute::Earpiece => Self::Earpiece,
|
||||||
|
BridgeAudioRoute::WiredHeadset => Self::WiredHeadset,
|
||||||
|
BridgeAudioRoute::BluetoothHfp => Self::BluetoothHfp,
|
||||||
|
BridgeAudioRoute::BluetoothA2dp => Self::BluetoothA2dp,
|
||||||
|
BridgeAudioRoute::Unknown => Self::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<chanora_core::AudioRoute> for BridgeAudioRoute {
|
||||||
|
fn from(route: chanora_core::AudioRoute) -> Self {
|
||||||
|
match route {
|
||||||
|
chanora_core::AudioRoute::Speaker => Self::Speaker,
|
||||||
|
chanora_core::AudioRoute::Earpiece => Self::Earpiece,
|
||||||
|
chanora_core::AudioRoute::WiredHeadset => Self::WiredHeadset,
|
||||||
|
chanora_core::AudioRoute::BluetoothHfp => Self::BluetoothHfp,
|
||||||
|
chanora_core::AudioRoute::BluetoothA2dp => Self::BluetoothA2dp,
|
||||||
|
chanora_core::AudioRoute::Unknown => Self::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BridgeIosVoiceProcessingMode> for chanora_core::IosVoiceProcessingMode {
|
||||||
|
fn from(mode: BridgeIosVoiceProcessingMode) -> Self {
|
||||||
|
match mode {
|
||||||
|
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
||||||
|
BridgeIosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<chanora_core::IosVoiceProcessingMode> for BridgeIosVoiceProcessingMode {
|
||||||
|
fn from(mode: chanora_core::IosVoiceProcessingMode) -> Self {
|
||||||
|
match mode {
|
||||||
|
chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => {
|
||||||
|
Self::PlatformVoiceProcessing
|
||||||
|
}
|
||||||
|
chanora_core::IosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BridgeAudioBackend> for chanora_core::AudioBackend {
|
||||||
|
fn from(backend: BridgeAudioBackend) -> Self {
|
||||||
|
match backend {
|
||||||
|
BridgeAudioBackend::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
||||||
|
BridgeAudioBackend::Sonora => Self::Sonora,
|
||||||
|
BridgeAudioBackend::WebrtcApm => Self::WebrtcApm,
|
||||||
|
BridgeAudioBackend::Noop => Self::Noop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<chanora_core::AudioBackend> for BridgeAudioBackend {
|
||||||
|
fn from(backend: chanora_core::AudioBackend) -> Self {
|
||||||
|
match backend {
|
||||||
|
chanora_core::AudioBackend::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
||||||
|
chanora_core::AudioBackend::Sonora => Self::Sonora,
|
||||||
|
chanora_core::AudioBackend::WebrtcApm => Self::WebrtcApm,
|
||||||
|
chanora_core::AudioBackend::Noop => Self::Noop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BridgeVadBackend> for chanora_core::VadBackend {
|
||||||
|
fn from(backend: BridgeVadBackend) -> Self {
|
||||||
|
match backend {
|
||||||
|
BridgeVadBackend::SileroOnnx => Self::SileroOnnx,
|
||||||
|
BridgeVadBackend::TenVad => Self::TenVad,
|
||||||
|
BridgeVadBackend::WebrtcVad => Self::WebrtcVad,
|
||||||
|
BridgeVadBackend::EnergyDebug => Self::EnergyDebug,
|
||||||
|
BridgeVadBackend::Disabled => Self::Disabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<chanora_core::VadBackend> for BridgeVadBackend {
|
||||||
|
fn from(backend: chanora_core::VadBackend) -> Self {
|
||||||
|
match backend {
|
||||||
|
chanora_core::VadBackend::SileroOnnx => Self::SileroOnnx,
|
||||||
|
chanora_core::VadBackend::TenVad => Self::TenVad,
|
||||||
|
chanora_core::VadBackend::WebrtcVad => Self::WebrtcVad,
|
||||||
|
chanora_core::VadBackend::EnergyDebug => Self::EnergyDebug,
|
||||||
|
chanora_core::VadBackend::Disabled => Self::Disabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BridgeEffectOwner> for chanora_core::EffectOwner {
|
||||||
|
fn from(owner: BridgeEffectOwner) -> Self {
|
||||||
|
match owner {
|
||||||
|
BridgeEffectOwner::Platform => Self::Platform,
|
||||||
|
BridgeEffectOwner::Sonora => Self::Sonora,
|
||||||
|
BridgeEffectOwner::WebrtcApm => Self::WebrtcApm,
|
||||||
|
BridgeEffectOwner::Conservative => Self::Conservative,
|
||||||
|
BridgeEffectOwner::Off => Self::Off,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<chanora_core::EffectOwner> for BridgeEffectOwner {
|
||||||
|
fn from(owner: chanora_core::EffectOwner) -> Self {
|
||||||
|
match owner {
|
||||||
|
chanora_core::EffectOwner::Platform => Self::Platform,
|
||||||
|
chanora_core::EffectOwner::Sonora => Self::Sonora,
|
||||||
|
chanora_core::EffectOwner::WebrtcApm => Self::WebrtcApm,
|
||||||
|
chanora_core::EffectOwner::Conservative => Self::Conservative,
|
||||||
|
chanora_core::EffectOwner::Off => Self::Off,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BridgeAudioProcessingConfig> for chanora_core::AudioProcessingConfig {
|
||||||
|
fn from(config: BridgeAudioProcessingConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
route: config.route.into(),
|
||||||
|
ios_mode: config.ios_mode.into(),
|
||||||
|
processing_backend: config.processing_backend.into(),
|
||||||
|
vad_backend: config.vad_backend.into(),
|
||||||
|
aec: config.aec.into(),
|
||||||
|
ns: config.ns.into(),
|
||||||
|
agc: config.agc.into(),
|
||||||
|
hpf_enabled: config.hpf_enabled,
|
||||||
|
limiter_enabled: config.limiter_enabled,
|
||||||
|
vad_hangover_ms: config.vad_hangover_ms,
|
||||||
|
vad_pre_roll_ms: config.vad_pre_roll_ms,
|
||||||
|
vad_min_tx_ms: config.vad_min_tx_ms,
|
||||||
|
debug_wav_dump_enabled: config.debug_wav_dump_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<chanora_core::AudioProcessingConfig> for BridgeAudioProcessingConfig {
|
||||||
|
fn from(c: chanora_core::AudioProcessingConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
route: c.route.into(),
|
||||||
|
ios_mode: c.ios_mode.into(),
|
||||||
|
processing_backend: c.processing_backend.into(),
|
||||||
|
vad_backend: c.vad_backend.into(),
|
||||||
|
aec: c.aec.into(),
|
||||||
|
ns: c.ns.into(),
|
||||||
|
agc: c.agc.into(),
|
||||||
|
hpf_enabled: c.hpf_enabled,
|
||||||
|
limiter_enabled: c.limiter_enabled,
|
||||||
|
vad_hangover_ms: c.vad_hangover_ms,
|
||||||
|
vad_pre_roll_ms: c.vad_pre_roll_ms,
|
||||||
|
vad_min_tx_ms: c.vad_min_tx_ms,
|
||||||
|
debug_wav_dump_enabled: c.debug_wav_dump_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<chanora_core::AudioProcessingStats> for BridgeAudioProcessingStats {
|
||||||
|
fn from(stats: chanora_core::AudioProcessingStats) -> Self {
|
||||||
|
Self {
|
||||||
|
input_dbfs: stats.input_dbfs,
|
||||||
|
render_dbfs: stats.render_dbfs,
|
||||||
|
processed_dbfs: stats.processed_dbfs,
|
||||||
|
vad_probability: stats.vad_probability,
|
||||||
|
vad_active: stats.vad_active,
|
||||||
|
transmitting: stats.transmitting,
|
||||||
|
vad_backend: stats.vad_backend.into(),
|
||||||
|
vad_fallback_active: stats.vad_fallback_active,
|
||||||
|
processing_backend: stats.processing_backend.into(),
|
||||||
|
ios_voice_processing_mode: stats.ios_voice_processing_mode.into(),
|
||||||
|
audio_route: stats.audio_route.into(),
|
||||||
|
actual_sample_rate_hz: stats.actual_sample_rate_hz,
|
||||||
|
actual_io_buffer_frames: stats.actual_io_buffer_frames,
|
||||||
|
input_overruns: stats.input_overruns,
|
||||||
|
output_underruns: stats.output_underruns,
|
||||||
|
callback_xruns: stats.callback_xruns,
|
||||||
|
clipped_samples: stats.clipped_samples,
|
||||||
|
sonora_enabled: stats.sonora_enabled,
|
||||||
|
platform_voice_processing_enabled: stats.platform_voice_processing_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Diagnostics (A.3) ----------
|
// ---------- Diagnostics (A.3) ----------
|
||||||
|
|
||||||
/// User-initiated diagnostic export. Returns a multi-line text
|
/// User-initiated diagnostic export. Returns a multi-line text
|
||||||
@@ -1335,3 +1678,95 @@ pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
|||||||
ptt_active: p,
|
ptt_active: p,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply the P1 audio-processing config.
|
||||||
|
pub async fn set_audio_processing_config(
|
||||||
|
config: BridgeAudioProcessingConfig,
|
||||||
|
) -> Result<(), BridgeError> {
|
||||||
|
runtime()
|
||||||
|
.spawn(async move { session().set_audio_processing_config(config.into()).await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| task_join_error("set_audio_processing_config", e))??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current audio-processing config.
|
||||||
|
///
|
||||||
|
/// Returns the live config as last applied to the audio engine.
|
||||||
|
/// Returns a default config when no session is active.
|
||||||
|
pub async fn get_audio_processing_config() -> Result<BridgeAudioProcessingConfig, BridgeError> {
|
||||||
|
let config = runtime()
|
||||||
|
.spawn(async { session().get_audio_processing_config().await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| task_join_error("get_audio_processing_config", e))??;
|
||||||
|
Ok(config.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read P1 audio-processing diagnostics.
|
||||||
|
pub async fn audio_processing_stats() -> Result<BridgeAudioProcessingStats, BridgeError> {
|
||||||
|
let stats = runtime()
|
||||||
|
.spawn(async { session().audio_processing_stats().await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| task_join_error("audio_processing_stats", e))??;
|
||||||
|
Ok(stats.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the VAD model path.
|
||||||
|
pub async fn set_vad_model_path(path: String) -> Result<(), BridgeError> {
|
||||||
|
if path.trim().is_empty() {
|
||||||
|
return Err(BridgeError::InvalidCommand(
|
||||||
|
"vad model path must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
runtime()
|
||||||
|
.spawn(async move { session().set_vad_model_path(path).await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| task_join_error("set_vad_model_path", e))??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable audio debug WAV dumping.
|
||||||
|
pub async fn enable_audio_debug_wav_dump(enabled: bool) -> Result<(), BridgeError> {
|
||||||
|
runtime()
|
||||||
|
.spawn(async move { session().set_audio_debug_wav_dump(enabled).await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| task_join_error("enable_audio_debug_wav_dump", e))?
|
||||||
|
.map_err(|e| BridgeError::Unmapped(format!("enable_audio_debug_wav_dump: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the iOS voice-processing mode.
|
||||||
|
pub async fn set_ios_voice_processing_mode(
|
||||||
|
mode: BridgeIosVoiceProcessingMode,
|
||||||
|
) -> Result<(), BridgeError> {
|
||||||
|
let config = BridgeAudioProcessingConfig {
|
||||||
|
route: BridgeAudioRoute::Speaker,
|
||||||
|
ios_mode: mode,
|
||||||
|
processing_backend: match mode {
|
||||||
|
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => {
|
||||||
|
BridgeAudioBackend::PlatformVoiceProcessing
|
||||||
|
}
|
||||||
|
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::Sonora,
|
||||||
|
},
|
||||||
|
vad_backend: BridgeVadBackend::SileroOnnx,
|
||||||
|
aec: match mode {
|
||||||
|
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
||||||
|
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::Sonora,
|
||||||
|
},
|
||||||
|
ns: match mode {
|
||||||
|
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
||||||
|
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::Sonora,
|
||||||
|
},
|
||||||
|
agc: match mode {
|
||||||
|
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
||||||
|
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::Sonora,
|
||||||
|
},
|
||||||
|
hpf_enabled: true,
|
||||||
|
limiter_enabled: true,
|
||||||
|
vad_hangover_ms: 500,
|
||||||
|
vad_pre_roll_ms: 160,
|
||||||
|
vad_min_tx_ms: 200,
|
||||||
|
debug_wav_dump_enabled: false,
|
||||||
|
};
|
||||||
|
set_audio_processing_config(config).await
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
|||||||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||||
);
|
);
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1322894465;
|
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1835973251;
|
||||||
|
|
||||||
// Section: executor
|
// Section: executor
|
||||||
|
|
||||||
@@ -82,6 +82,41 @@ fn wire__crate__api__add_bookmark_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__audio_processing_stats_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: "audio_processing_stats",
|
||||||
|
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::audio_processing_stats().await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__audio_stats_impl(
|
fn wire__crate__api__audio_stats_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -261,6 +296,43 @@ fn wire__crate__api__disconnect_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__enable_audio_debug_wav_dump_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: "enable_audio_debug_wav_dump",
|
||||||
|
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_enabled = <bool>::sse_decode(&mut deserializer);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok =
|
||||||
|
crate::api::enable_audio_debug_wav_dump(api_enabled).await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__events_stream_impl(
|
fn wire__crate__api__events_stream_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -495,6 +567,37 @@ fn wire__crate__api__handle_interruption_ended_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__handle_media_services_reset_impl(
|
||||||
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
|
rust_vec_len_: i32,
|
||||||
|
data_len_: i32,
|
||||||
|
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
||||||
|
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
|
||||||
|
flutter_rust_bridge::for_generated::TaskInfo {
|
||||||
|
debug_name: "handle_media_services_reset",
|
||||||
|
port: None,
|
||||||
|
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
|
||||||
|
},
|
||||||
|
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();
|
||||||
|
transform_result_sse::<_, ()>((move || {
|
||||||
|
let output_ok = Result::<_, ()>::Ok({
|
||||||
|
crate::api::handle_media_services_reset();
|
||||||
|
})?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__handle_route_change_impl(
|
fn wire__crate__api__handle_route_change_impl(
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
rust_vec_len_: i32,
|
rust_vec_len_: i32,
|
||||||
@@ -516,10 +619,11 @@ fn wire__crate__api__handle_route_change_impl(
|
|||||||
};
|
};
|
||||||
let mut deserializer =
|
let mut deserializer =
|
||||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||||
|
let api_route = <crate::api::BridgeAudioRoute>::sse_decode(&mut deserializer);
|
||||||
deserializer.end();
|
deserializer.end();
|
||||||
transform_result_sse::<_, ()>((move || {
|
transform_result_sse::<_, ()>((move || {
|
||||||
let output_ok = Result::<_, ()>::Ok({
|
let output_ok = Result::<_, ()>::Ok({
|
||||||
crate::api::handle_route_change();
|
crate::api::handle_route_change(api_route);
|
||||||
})?;
|
})?;
|
||||||
Ok(output_ok)
|
Ok(output_ok)
|
||||||
})())
|
})())
|
||||||
@@ -734,6 +838,43 @@ fn wire__crate__api__ptt_descriptor_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__set_audio_processing_config_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: "set_audio_processing_config",
|
||||||
|
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_config =
|
||||||
|
<crate::api::BridgeAudioProcessingConfig>::sse_decode(&mut deserializer);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok = crate::api::set_audio_processing_config(api_config).await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__set_hard_mute_impl(
|
fn wire__crate__api__set_hard_mute_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -806,6 +947,43 @@ fn wire__crate__api__set_input_muted_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__set_ios_voice_processing_mode_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: "set_ios_voice_processing_mode",
|
||||||
|
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_mode =
|
||||||
|
<crate::api::BridgeIosVoiceProcessingMode>::sse_decode(&mut deserializer);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok = crate::api::set_ios_voice_processing_mode(api_mode).await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__set_network_state_impl(
|
fn wire__crate__api__set_network_state_impl(
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
rust_vec_len_: i32,
|
rust_vec_len_: i32,
|
||||||
@@ -1056,6 +1234,42 @@ fn wire__crate__api__set_transmit_mode_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__set_vad_model_path_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: "set_vad_model_path",
|
||||||
|
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_path = <String>::sse_decode(&mut deserializer);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok = crate::api::set_vad_model_path(api_path).await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__snapshot_impl(
|
fn wire__crate__api__snapshot_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -1236,6 +1450,117 @@ impl SseDecode for bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseDecode for crate::api::BridgeAudioBackend {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
|
let mut inner = <i32>::sse_decode(deserializer);
|
||||||
|
return match inner {
|
||||||
|
0 => crate::api::BridgeAudioBackend::PlatformVoiceProcessing,
|
||||||
|
1 => crate::api::BridgeAudioBackend::Sonora,
|
||||||
|
2 => crate::api::BridgeAudioBackend::WebrtcApm,
|
||||||
|
3 => crate::api::BridgeAudioBackend::Noop,
|
||||||
|
_ => unreachable!("Invalid variant for BridgeAudioBackend: {}", inner),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SseDecode for crate::api::BridgeAudioProcessingConfig {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
|
let mut var_route = <crate::api::BridgeAudioRoute>::sse_decode(deserializer);
|
||||||
|
let mut var_iosMode = <crate::api::BridgeIosVoiceProcessingMode>::sse_decode(deserializer);
|
||||||
|
let mut var_processingBackend = <crate::api::BridgeAudioBackend>::sse_decode(deserializer);
|
||||||
|
let mut var_vadBackend = <crate::api::BridgeVadBackend>::sse_decode(deserializer);
|
||||||
|
let mut var_aec = <crate::api::BridgeEffectOwner>::sse_decode(deserializer);
|
||||||
|
let mut var_ns = <crate::api::BridgeEffectOwner>::sse_decode(deserializer);
|
||||||
|
let mut var_agc = <crate::api::BridgeEffectOwner>::sse_decode(deserializer);
|
||||||
|
let mut var_hpfEnabled = <bool>::sse_decode(deserializer);
|
||||||
|
let mut var_limiterEnabled = <bool>::sse_decode(deserializer);
|
||||||
|
let mut var_vadHangoverMs = <u32>::sse_decode(deserializer);
|
||||||
|
let mut var_vadPreRollMs = <u32>::sse_decode(deserializer);
|
||||||
|
let mut var_vadMinTxMs = <u32>::sse_decode(deserializer);
|
||||||
|
let mut var_debugWavDumpEnabled = <bool>::sse_decode(deserializer);
|
||||||
|
return crate::api::BridgeAudioProcessingConfig {
|
||||||
|
route: var_route,
|
||||||
|
ios_mode: var_iosMode,
|
||||||
|
processing_backend: var_processingBackend,
|
||||||
|
vad_backend: var_vadBackend,
|
||||||
|
aec: var_aec,
|
||||||
|
ns: var_ns,
|
||||||
|
agc: var_agc,
|
||||||
|
hpf_enabled: var_hpfEnabled,
|
||||||
|
limiter_enabled: var_limiterEnabled,
|
||||||
|
vad_hangover_ms: var_vadHangoverMs,
|
||||||
|
vad_pre_roll_ms: var_vadPreRollMs,
|
||||||
|
vad_min_tx_ms: var_vadMinTxMs,
|
||||||
|
debug_wav_dump_enabled: var_debugWavDumpEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SseDecode for crate::api::BridgeAudioProcessingStats {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
|
let mut var_inputDbfs = <f32>::sse_decode(deserializer);
|
||||||
|
let mut var_renderDbfs = <f32>::sse_decode(deserializer);
|
||||||
|
let mut var_processedDbfs = <f32>::sse_decode(deserializer);
|
||||||
|
let mut var_vadProbability = <f32>::sse_decode(deserializer);
|
||||||
|
let mut var_vadActive = <bool>::sse_decode(deserializer);
|
||||||
|
let mut var_transmitting = <bool>::sse_decode(deserializer);
|
||||||
|
let mut var_vadBackend = <crate::api::BridgeVadBackend>::sse_decode(deserializer);
|
||||||
|
let mut var_vadFallbackActive = <bool>::sse_decode(deserializer);
|
||||||
|
let mut var_processingBackend = <crate::api::BridgeAudioBackend>::sse_decode(deserializer);
|
||||||
|
let mut var_iosVoiceProcessingMode =
|
||||||
|
<crate::api::BridgeIosVoiceProcessingMode>::sse_decode(deserializer);
|
||||||
|
let mut var_audioRoute = <crate::api::BridgeAudioRoute>::sse_decode(deserializer);
|
||||||
|
let mut var_actualSampleRateHz = <u32>::sse_decode(deserializer);
|
||||||
|
let mut var_actualIoBufferFrames = <u32>::sse_decode(deserializer);
|
||||||
|
let mut var_inputOverruns = <u64>::sse_decode(deserializer);
|
||||||
|
let mut var_outputUnderruns = <u64>::sse_decode(deserializer);
|
||||||
|
let mut var_callbackXruns = <u64>::sse_decode(deserializer);
|
||||||
|
let mut var_clippedSamples = <u64>::sse_decode(deserializer);
|
||||||
|
let mut var_sonoraEnabled = <bool>::sse_decode(deserializer);
|
||||||
|
let mut var_platformVoiceProcessingEnabled = <bool>::sse_decode(deserializer);
|
||||||
|
return crate::api::BridgeAudioProcessingStats {
|
||||||
|
input_dbfs: var_inputDbfs,
|
||||||
|
render_dbfs: var_renderDbfs,
|
||||||
|
processed_dbfs: var_processedDbfs,
|
||||||
|
vad_probability: var_vadProbability,
|
||||||
|
vad_active: var_vadActive,
|
||||||
|
transmitting: var_transmitting,
|
||||||
|
vad_backend: var_vadBackend,
|
||||||
|
vad_fallback_active: var_vadFallbackActive,
|
||||||
|
processing_backend: var_processingBackend,
|
||||||
|
ios_voice_processing_mode: var_iosVoiceProcessingMode,
|
||||||
|
audio_route: var_audioRoute,
|
||||||
|
actual_sample_rate_hz: var_actualSampleRateHz,
|
||||||
|
actual_io_buffer_frames: var_actualIoBufferFrames,
|
||||||
|
input_overruns: var_inputOverruns,
|
||||||
|
output_underruns: var_outputUnderruns,
|
||||||
|
callback_xruns: var_callbackXruns,
|
||||||
|
clipped_samples: var_clippedSamples,
|
||||||
|
sonora_enabled: var_sonoraEnabled,
|
||||||
|
platform_voice_processing_enabled: var_platformVoiceProcessingEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SseDecode for crate::api::BridgeAudioRoute {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
|
let mut inner = <i32>::sse_decode(deserializer);
|
||||||
|
return match inner {
|
||||||
|
0 => crate::api::BridgeAudioRoute::Speaker,
|
||||||
|
1 => crate::api::BridgeAudioRoute::Earpiece,
|
||||||
|
2 => crate::api::BridgeAudioRoute::WiredHeadset,
|
||||||
|
3 => crate::api::BridgeAudioRoute::BluetoothHfp,
|
||||||
|
4 => crate::api::BridgeAudioRoute::BluetoothA2dp,
|
||||||
|
5 => crate::api::BridgeAudioRoute::Unknown,
|
||||||
|
_ => unreachable!("Invalid variant for BridgeAudioRoute: {}", inner),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseDecode for crate::api::BridgeAudioStats {
|
impl SseDecode for crate::api::BridgeAudioStats {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
@@ -1275,13 +1600,13 @@ impl SseDecode for crate::api::BridgeChannel {
|
|||||||
let mut var_parent = <u64>::sse_decode(deserializer);
|
let mut var_parent = <u64>::sse_decode(deserializer);
|
||||||
let mut var_name = <String>::sse_decode(deserializer);
|
let mut var_name = <String>::sse_decode(deserializer);
|
||||||
let mut var_order = <i64>::sse_decode(deserializer);
|
let mut var_order = <i64>::sse_decode(deserializer);
|
||||||
let mut var_has_password = <bool>::sse_decode(deserializer);
|
let mut var_hasPassword = <bool>::sse_decode(deserializer);
|
||||||
return crate::api::BridgeChannel {
|
return crate::api::BridgeChannel {
|
||||||
id: var_id,
|
id: var_id,
|
||||||
parent: var_parent,
|
parent: var_parent,
|
||||||
name: var_name,
|
name: var_name,
|
||||||
order: var_order,
|
order: var_order,
|
||||||
has_password: var_has_password,
|
has_password: var_hasPassword,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1292,22 +1617,37 @@ impl SseDecode for crate::api::BridgeClient {
|
|||||||
let mut var_id = <u64>::sse_decode(deserializer);
|
let mut var_id = <u64>::sse_decode(deserializer);
|
||||||
let mut var_channel = <u64>::sse_decode(deserializer);
|
let mut var_channel = <u64>::sse_decode(deserializer);
|
||||||
let mut var_name = <String>::sse_decode(deserializer);
|
let mut var_name = <String>::sse_decode(deserializer);
|
||||||
let mut var_input_muted = <bool>::sse_decode(deserializer);
|
let mut var_inputMuted = <bool>::sse_decode(deserializer);
|
||||||
let mut var_output_muted = <bool>::sse_decode(deserializer);
|
let mut var_outputMuted = <bool>::sse_decode(deserializer);
|
||||||
let mut var_is_speaking = <bool>::sse_decode(deserializer);
|
let mut var_isSpeaking = <bool>::sse_decode(deserializer);
|
||||||
let mut var_isServerQuery = <bool>::sse_decode(deserializer);
|
let mut var_isServerQuery = <bool>::sse_decode(deserializer);
|
||||||
return crate::api::BridgeClient {
|
return crate::api::BridgeClient {
|
||||||
id: var_id,
|
id: var_id,
|
||||||
channel: var_channel,
|
channel: var_channel,
|
||||||
name: var_name,
|
name: var_name,
|
||||||
input_muted: var_input_muted,
|
input_muted: var_inputMuted,
|
||||||
output_muted: var_output_muted,
|
output_muted: var_outputMuted,
|
||||||
is_speaking: var_is_speaking,
|
is_speaking: var_isSpeaking,
|
||||||
is_server_query: var_isServerQuery,
|
is_server_query: var_isServerQuery,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseDecode for crate::api::BridgeEffectOwner {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
|
let mut inner = <i32>::sse_decode(deserializer);
|
||||||
|
return match inner {
|
||||||
|
0 => crate::api::BridgeEffectOwner::Platform,
|
||||||
|
1 => crate::api::BridgeEffectOwner::Sonora,
|
||||||
|
2 => crate::api::BridgeEffectOwner::WebrtcApm,
|
||||||
|
3 => crate::api::BridgeEffectOwner::Conservative,
|
||||||
|
4 => crate::api::BridgeEffectOwner::Off,
|
||||||
|
_ => unreachable!("Invalid variant for BridgeEffectOwner: {}", inner),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseDecode for crate::BridgeError {
|
impl SseDecode for crate::BridgeError {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
@@ -1455,6 +1795,21 @@ impl SseDecode for crate::api::BridgeEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseDecode for crate::api::BridgeIosVoiceProcessingMode {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
|
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
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseDecode for crate::api::BridgeNetworkState {
|
impl SseDecode for crate::api::BridgeNetworkState {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
@@ -1516,6 +1871,21 @@ impl SseDecode for crate::api::BridgeTransmitMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseDecode for crate::api::BridgeVadBackend {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
|
let mut inner = <i32>::sse_decode(deserializer);
|
||||||
|
return match inner {
|
||||||
|
0 => crate::api::BridgeVadBackend::SileroOnnx,
|
||||||
|
1 => crate::api::BridgeVadBackend::TenVad,
|
||||||
|
2 => crate::api::BridgeVadBackend::WebrtcVad,
|
||||||
|
3 => crate::api::BridgeVadBackend::EnergyDebug,
|
||||||
|
4 => crate::api::BridgeVadBackend::Disabled,
|
||||||
|
_ => unreachable!("Invalid variant for BridgeVadBackend: {}", inner),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseDecode for crate::api::BridgeVoiceJoinErrorCode {
|
impl SseDecode for crate::api::BridgeVoiceJoinErrorCode {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
@@ -1712,32 +2082,39 @@ fn pde_ffi_dispatcher_primary_impl(
|
|||||||
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
||||||
match func_id {
|
match func_id {
|
||||||
1 => wire__crate__api__add_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
1 => wire__crate__api__add_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||||
2 => wire__crate__api__audio_stats_impl(port, ptr, rust_vec_len, data_len),
|
2 => wire__crate__api__audio_processing_stats_impl(port, ptr, rust_vec_len, data_len),
|
||||||
3 => wire__crate__api__bridge_init_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__connect_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__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
5 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
|
||||||
6 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
|
6 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||||
7 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
|
7 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
|
||||||
9 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
8 => wire__crate__api__enable_audio_debug_wav_dump_impl(port, ptr, rust_vec_len, data_len),
|
||||||
10 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
9 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
|
||||||
11 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
11 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||||
15 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
|
12 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||||
16 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
13 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||||
17 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
|
18 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
|
||||||
19 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
|
19 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
||||||
20 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
|
20 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
|
||||||
21 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
|
22 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
|
||||||
22 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
|
23 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
|
||||||
24 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
|
24 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
|
||||||
25 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
|
25 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
|
||||||
26 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
26 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||||
27 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
27 => {
|
||||||
28 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
|
||||||
29 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
}
|
||||||
30 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
29 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
|
||||||
31 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
30 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||||
32 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
|
31 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
||||||
33 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
|
32 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
33 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
34 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
35 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
36 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
37 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
38 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
39 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1750,18 +2127,136 @@ fn pde_ffi_dispatcher_sync_impl(
|
|||||||
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
||||||
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
||||||
match func_id {
|
match func_id {
|
||||||
8 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
|
10 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
|
||||||
12 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
|
14 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
|
||||||
13 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
|
15 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
|
||||||
14 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
|
16 => wire__crate__api__handle_media_services_reset_impl(ptr, rust_vec_len, data_len),
|
||||||
18 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
|
17 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
|
||||||
23 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
21 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
|
||||||
|
28 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section: rust2dart
|
// Section: rust2dart
|
||||||
|
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioBackend {
|
||||||
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
|
match self {
|
||||||
|
Self::PlatformVoiceProcessing => 0.into_dart(),
|
||||||
|
Self::Sonora => 1.into_dart(),
|
||||||
|
Self::WebrtcApm => 2.into_dart(),
|
||||||
|
Self::Noop => 3.into_dart(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
|
||||||
|
for crate::api::BridgeAudioBackend
|
||||||
|
{
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioBackend>
|
||||||
|
for crate::api::BridgeAudioBackend
|
||||||
|
{
|
||||||
|
fn into_into_dart(self) -> crate::api::BridgeAudioBackend {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioProcessingConfig {
|
||||||
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
|
[
|
||||||
|
self.route.into_into_dart().into_dart(),
|
||||||
|
self.ios_mode.into_into_dart().into_dart(),
|
||||||
|
self.processing_backend.into_into_dart().into_dart(),
|
||||||
|
self.vad_backend.into_into_dart().into_dart(),
|
||||||
|
self.aec.into_into_dart().into_dart(),
|
||||||
|
self.ns.into_into_dart().into_dart(),
|
||||||
|
self.agc.into_into_dart().into_dart(),
|
||||||
|
self.hpf_enabled.into_into_dart().into_dart(),
|
||||||
|
self.limiter_enabled.into_into_dart().into_dart(),
|
||||||
|
self.vad_hangover_ms.into_into_dart().into_dart(),
|
||||||
|
self.vad_pre_roll_ms.into_into_dart().into_dart(),
|
||||||
|
self.vad_min_tx_ms.into_into_dart().into_dart(),
|
||||||
|
self.debug_wav_dump_enabled.into_into_dart().into_dart(),
|
||||||
|
]
|
||||||
|
.into_dart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
|
||||||
|
for crate::api::BridgeAudioProcessingConfig
|
||||||
|
{
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioProcessingConfig>
|
||||||
|
for crate::api::BridgeAudioProcessingConfig
|
||||||
|
{
|
||||||
|
fn into_into_dart(self) -> crate::api::BridgeAudioProcessingConfig {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioProcessingStats {
|
||||||
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
|
[
|
||||||
|
self.input_dbfs.into_into_dart().into_dart(),
|
||||||
|
self.render_dbfs.into_into_dart().into_dart(),
|
||||||
|
self.processed_dbfs.into_into_dart().into_dart(),
|
||||||
|
self.vad_probability.into_into_dart().into_dart(),
|
||||||
|
self.vad_active.into_into_dart().into_dart(),
|
||||||
|
self.transmitting.into_into_dart().into_dart(),
|
||||||
|
self.vad_backend.into_into_dart().into_dart(),
|
||||||
|
self.vad_fallback_active.into_into_dart().into_dart(),
|
||||||
|
self.processing_backend.into_into_dart().into_dart(),
|
||||||
|
self.ios_voice_processing_mode.into_into_dart().into_dart(),
|
||||||
|
self.audio_route.into_into_dart().into_dart(),
|
||||||
|
self.actual_sample_rate_hz.into_into_dart().into_dart(),
|
||||||
|
self.actual_io_buffer_frames.into_into_dart().into_dart(),
|
||||||
|
self.input_overruns.into_into_dart().into_dart(),
|
||||||
|
self.output_underruns.into_into_dart().into_dart(),
|
||||||
|
self.callback_xruns.into_into_dart().into_dart(),
|
||||||
|
self.clipped_samples.into_into_dart().into_dart(),
|
||||||
|
self.sonora_enabled.into_into_dart().into_dart(),
|
||||||
|
self.platform_voice_processing_enabled
|
||||||
|
.into_into_dart()
|
||||||
|
.into_dart(),
|
||||||
|
]
|
||||||
|
.into_dart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
|
||||||
|
for crate::api::BridgeAudioProcessingStats
|
||||||
|
{
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioProcessingStats>
|
||||||
|
for crate::api::BridgeAudioProcessingStats
|
||||||
|
{
|
||||||
|
fn into_into_dart(self) -> crate::api::BridgeAudioProcessingStats {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioRoute {
|
||||||
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
|
match self {
|
||||||
|
Self::Speaker => 0.into_dart(),
|
||||||
|
Self::Earpiece => 1.into_dart(),
|
||||||
|
Self::WiredHeadset => 2.into_dart(),
|
||||||
|
Self::BluetoothHfp => 3.into_dart(),
|
||||||
|
Self::BluetoothA2dp => 4.into_dart(),
|
||||||
|
Self::Unknown => 5.into_dart(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeAudioRoute {}
|
||||||
|
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioRoute>
|
||||||
|
for crate::api::BridgeAudioRoute
|
||||||
|
{
|
||||||
|
fn into_into_dart(self) -> crate::api::BridgeAudioRoute {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioStats {
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioStats {
|
||||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
@@ -1841,6 +2336,27 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeClient> for crate::api:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgeEffectOwner {
|
||||||
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
|
match self {
|
||||||
|
Self::Platform => 0.into_dart(),
|
||||||
|
Self::Sonora => 1.into_dart(),
|
||||||
|
Self::WebrtcApm => 2.into_dart(),
|
||||||
|
Self::Conservative => 3.into_dart(),
|
||||||
|
Self::Off => 4.into_dart(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeEffectOwner {}
|
||||||
|
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeEffectOwner>
|
||||||
|
for crate::api::BridgeEffectOwner
|
||||||
|
{
|
||||||
|
fn into_into_dart(self) -> crate::api::BridgeEffectOwner {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
impl flutter_rust_bridge::IntoDart for crate::BridgeError {
|
impl flutter_rust_bridge::IntoDart for crate::BridgeError {
|
||||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
match self {
|
match self {
|
||||||
@@ -1973,6 +2489,27 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeEvent> for crate::api::
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
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!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
|
||||||
|
for crate::api::BridgeIosVoiceProcessingMode
|
||||||
|
{
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeIosVoiceProcessingMode>
|
||||||
|
for crate::api::BridgeIosVoiceProcessingMode
|
||||||
|
{
|
||||||
|
fn into_into_dart(self) -> crate::api::BridgeIosVoiceProcessingMode {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeNetworkState {
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgeNetworkState {
|
||||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
match self {
|
match self {
|
||||||
@@ -2060,6 +2597,27 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeTransmitMode>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgeVadBackend {
|
||||||
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
|
match self {
|
||||||
|
Self::SileroOnnx => 0.into_dart(),
|
||||||
|
Self::TenVad => 1.into_dart(),
|
||||||
|
Self::WebrtcVad => 2.into_dart(),
|
||||||
|
Self::EnergyDebug => 3.into_dart(),
|
||||||
|
Self::Disabled => 4.into_dart(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeVadBackend {}
|
||||||
|
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeVadBackend>
|
||||||
|
for crate::api::BridgeVadBackend
|
||||||
|
{
|
||||||
|
fn into_into_dart(self) -> crate::api::BridgeVadBackend {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeVoiceJoinErrorCode {
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgeVoiceJoinErrorCode {
|
||||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
match self {
|
match self {
|
||||||
@@ -2165,6 +2723,91 @@ impl SseEncode for bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseEncode for crate::api::BridgeAudioBackend {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
<i32>::sse_encode(
|
||||||
|
match self {
|
||||||
|
crate::api::BridgeAudioBackend::PlatformVoiceProcessing => 0,
|
||||||
|
crate::api::BridgeAudioBackend::Sonora => 1,
|
||||||
|
crate::api::BridgeAudioBackend::WebrtcApm => 2,
|
||||||
|
crate::api::BridgeAudioBackend::Noop => 3,
|
||||||
|
_ => {
|
||||||
|
unimplemented!("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serializer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SseEncode for crate::api::BridgeAudioProcessingConfig {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
<crate::api::BridgeAudioRoute>::sse_encode(self.route, serializer);
|
||||||
|
<crate::api::BridgeIosVoiceProcessingMode>::sse_encode(self.ios_mode, serializer);
|
||||||
|
<crate::api::BridgeAudioBackend>::sse_encode(self.processing_backend, serializer);
|
||||||
|
<crate::api::BridgeVadBackend>::sse_encode(self.vad_backend, serializer);
|
||||||
|
<crate::api::BridgeEffectOwner>::sse_encode(self.aec, serializer);
|
||||||
|
<crate::api::BridgeEffectOwner>::sse_encode(self.ns, serializer);
|
||||||
|
<crate::api::BridgeEffectOwner>::sse_encode(self.agc, serializer);
|
||||||
|
<bool>::sse_encode(self.hpf_enabled, serializer);
|
||||||
|
<bool>::sse_encode(self.limiter_enabled, serializer);
|
||||||
|
<u32>::sse_encode(self.vad_hangover_ms, serializer);
|
||||||
|
<u32>::sse_encode(self.vad_pre_roll_ms, serializer);
|
||||||
|
<u32>::sse_encode(self.vad_min_tx_ms, serializer);
|
||||||
|
<bool>::sse_encode(self.debug_wav_dump_enabled, serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SseEncode for crate::api::BridgeAudioProcessingStats {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
<f32>::sse_encode(self.input_dbfs, serializer);
|
||||||
|
<f32>::sse_encode(self.render_dbfs, serializer);
|
||||||
|
<f32>::sse_encode(self.processed_dbfs, serializer);
|
||||||
|
<f32>::sse_encode(self.vad_probability, serializer);
|
||||||
|
<bool>::sse_encode(self.vad_active, serializer);
|
||||||
|
<bool>::sse_encode(self.transmitting, serializer);
|
||||||
|
<crate::api::BridgeVadBackend>::sse_encode(self.vad_backend, serializer);
|
||||||
|
<bool>::sse_encode(self.vad_fallback_active, serializer);
|
||||||
|
<crate::api::BridgeAudioBackend>::sse_encode(self.processing_backend, serializer);
|
||||||
|
<crate::api::BridgeIosVoiceProcessingMode>::sse_encode(
|
||||||
|
self.ios_voice_processing_mode,
|
||||||
|
serializer,
|
||||||
|
);
|
||||||
|
<crate::api::BridgeAudioRoute>::sse_encode(self.audio_route, serializer);
|
||||||
|
<u32>::sse_encode(self.actual_sample_rate_hz, serializer);
|
||||||
|
<u32>::sse_encode(self.actual_io_buffer_frames, serializer);
|
||||||
|
<u64>::sse_encode(self.input_overruns, serializer);
|
||||||
|
<u64>::sse_encode(self.output_underruns, serializer);
|
||||||
|
<u64>::sse_encode(self.callback_xruns, serializer);
|
||||||
|
<u64>::sse_encode(self.clipped_samples, serializer);
|
||||||
|
<bool>::sse_encode(self.sonora_enabled, serializer);
|
||||||
|
<bool>::sse_encode(self.platform_voice_processing_enabled, serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SseEncode for crate::api::BridgeAudioRoute {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
<i32>::sse_encode(
|
||||||
|
match self {
|
||||||
|
crate::api::BridgeAudioRoute::Speaker => 0,
|
||||||
|
crate::api::BridgeAudioRoute::Earpiece => 1,
|
||||||
|
crate::api::BridgeAudioRoute::WiredHeadset => 2,
|
||||||
|
crate::api::BridgeAudioRoute::BluetoothHfp => 3,
|
||||||
|
crate::api::BridgeAudioRoute::BluetoothA2dp => 4,
|
||||||
|
crate::api::BridgeAudioRoute::Unknown => 5,
|
||||||
|
_ => {
|
||||||
|
unimplemented!("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serializer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseEncode for crate::api::BridgeAudioStats {
|
impl SseEncode for crate::api::BridgeAudioStats {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
@@ -2209,6 +2852,25 @@ impl SseEncode for crate::api::BridgeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseEncode for crate::api::BridgeEffectOwner {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
<i32>::sse_encode(
|
||||||
|
match self {
|
||||||
|
crate::api::BridgeEffectOwner::Platform => 0,
|
||||||
|
crate::api::BridgeEffectOwner::Sonora => 1,
|
||||||
|
crate::api::BridgeEffectOwner::WebrtcApm => 2,
|
||||||
|
crate::api::BridgeEffectOwner::Conservative => 3,
|
||||||
|
crate::api::BridgeEffectOwner::Off => 4,
|
||||||
|
_ => {
|
||||||
|
unimplemented!("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serializer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseEncode for crate::BridgeError {
|
impl SseEncode for crate::BridgeError {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
@@ -2340,6 +3002,22 @@ impl SseEncode for crate::api::BridgeEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseEncode for crate::api::BridgeIosVoiceProcessingMode {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
<i32>::sse_encode(
|
||||||
|
match self {
|
||||||
|
crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => 0,
|
||||||
|
crate::api::BridgeIosVoiceProcessingMode::SonoraExperimental => 1,
|
||||||
|
_ => {
|
||||||
|
unimplemented!("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serializer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseEncode for crate::api::BridgeNetworkState {
|
impl SseEncode for crate::api::BridgeNetworkState {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
@@ -2404,6 +3082,25 @@ impl SseEncode for crate::api::BridgeTransmitMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseEncode for crate::api::BridgeVadBackend {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
<i32>::sse_encode(
|
||||||
|
match self {
|
||||||
|
crate::api::BridgeVadBackend::SileroOnnx => 0,
|
||||||
|
crate::api::BridgeVadBackend::TenVad => 1,
|
||||||
|
crate::api::BridgeVadBackend::WebrtcVad => 2,
|
||||||
|
crate::api::BridgeVadBackend::EnergyDebug => 3,
|
||||||
|
crate::api::BridgeVadBackend::Disabled => 4,
|
||||||
|
_ => {
|
||||||
|
unimplemented!("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serializer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseEncode for crate::api::BridgeVoiceJoinErrorCode {
|
impl SseEncode for crate::api::BridgeVoiceJoinErrorCode {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ struct PttBanCheckVisitor {
|
|||||||
|
|
||||||
impl PttBanCheckVisitor {
|
impl PttBanCheckVisitor {
|
||||||
fn check(&mut self, name: &str) {
|
fn check(&mut self, name: &str) {
|
||||||
if !self.banned && PTT_BANNED_FIELDS.iter().any(|b| *b == name) {
|
if !self.banned && PTT_BANNED_FIELDS.contains(&name) {
|
||||||
self.banned = true;
|
self.banned = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,7 +761,6 @@ mod tests {
|
|||||||
v.check("key_code"); // banned
|
v.check("key_code"); // banned
|
||||||
assert!(v.banned);
|
assert!(v.banned);
|
||||||
|
|
||||||
let mut v2 = PttBanCheckVisitor::default();
|
|
||||||
for name in [
|
for name in [
|
||||||
"scan_code",
|
"scan_code",
|
||||||
"virtual_key",
|
"virtual_key",
|
||||||
@@ -772,7 +771,7 @@ mod tests {
|
|||||||
"key_press_history",
|
"key_press_history",
|
||||||
"key_timing",
|
"key_timing",
|
||||||
] {
|
] {
|
||||||
v2 = PttBanCheckVisitor::default();
|
let mut v2 = PttBanCheckVisitor::default();
|
||||||
v2.check(name);
|
v2.check(name);
|
||||||
assert!(v2.banned, "expected {name} to be banned");
|
assert!(v2.banned, "expected {name} to be banned");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,767 @@
|
|||||||
|
{
|
||||||
|
"document_id": "CHANORA_P1_VOICE_AGENT_SPEC",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"language": "en",
|
||||||
|
"audience": "ai_agents",
|
||||||
|
"scope": "P1_voice_only",
|
||||||
|
"mode": "modify_existing_scaffold_do_not_rewrite",
|
||||||
|
"based_on": {
|
||||||
|
"archive": "chanora-product-scaffold-v0.zip",
|
||||||
|
"previous_spec": "chanora_p1_voice_design_from_current_scaffold.json"
|
||||||
|
},
|
||||||
|
"project_context": {
|
||||||
|
"ui": "Flutter",
|
||||||
|
"core": "Rust",
|
||||||
|
"network": "tsclientlib",
|
||||||
|
"audio_crate": "crates/chanora_audio",
|
||||||
|
"bridge_crate": "crates/chanora_bridge",
|
||||||
|
"flutter_app": "apps/chanora_flutter",
|
||||||
|
"phase_1_platform": "iOS",
|
||||||
|
"future_platforms": [
|
||||||
|
"Android",
|
||||||
|
"Windows",
|
||||||
|
"macOS",
|
||||||
|
"Linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"current_scaffold_facts": {
|
||||||
|
"ios_audio_backend_exists": true,
|
||||||
|
"ios_current_backend": "VoiceProcessingIO AudioUnit",
|
||||||
|
"android_audio_backend_exists": true,
|
||||||
|
"android_current_backend": "Oboe",
|
||||||
|
"desktop_audio_backend_exists": true,
|
||||||
|
"desktop_current_backend": "cpal / SDL depending on platform",
|
||||||
|
"current_network_frame": {
|
||||||
|
"sample_rate_hz": 48000,
|
||||||
|
"channels": 1,
|
||||||
|
"frame_ms": 20,
|
||||||
|
"samples": 960,
|
||||||
|
"codec": "Opus VoIP"
|
||||||
|
},
|
||||||
|
"current_effects_api_exists": true,
|
||||||
|
"current_effects_are_real_dsp": false,
|
||||||
|
"current_voice_activity_is_real_vad": false,
|
||||||
|
"current_voice_activity_behavior": "VoiceActivity behaves like Continuous",
|
||||||
|
"current_ios_has_platform_voice_processing": true,
|
||||||
|
"current_ios_platform_processing_likely_includes": [
|
||||||
|
"AEC",
|
||||||
|
"NS",
|
||||||
|
"AGC"
|
||||||
|
],
|
||||||
|
"current_rust_software_ns_exists": false,
|
||||||
|
"current_rust_software_aec_exists": false,
|
||||||
|
"current_rust_software_agc_exists": false
|
||||||
|
},
|
||||||
|
"p1_primary_goals": [
|
||||||
|
"Add real VoiceActivity mode using ML VAD.",
|
||||||
|
"Keep iOS VoiceProcessingIO as default shipping voice-processing path.",
|
||||||
|
"Add optional iOS Sonora mode for experimental raw Rust AEC3/NS/AGC2/HPF.",
|
||||||
|
"Make AEC/NS/AGC route-aware and backend-aware.",
|
||||||
|
"Expose voice processing configuration and stats to Flutter.",
|
||||||
|
"Preserve current 20ms Opus/tsclientlib transport behavior.",
|
||||||
|
"Add internal 10ms processing frames without changing network framing."
|
||||||
|
],
|
||||||
|
"non_goals": [
|
||||||
|
"Do not rewrite P0.",
|
||||||
|
"Do not move PCM processing to Flutter.",
|
||||||
|
"Do not replace tsclientlib.",
|
||||||
|
"Do not require Android/Desktop implementation in Phase 1.",
|
||||||
|
"Do not require CoreML conversion for P1.",
|
||||||
|
"Do not require GPU/NPU execution providers for correctness.",
|
||||||
|
"Do not ship AI noise suppression as P1 default.",
|
||||||
|
"Do not use energy VAD as production transmit gate."
|
||||||
|
],
|
||||||
|
"hard_invariants": {
|
||||||
|
"INV_001": "Flutter MUST NOT process realtime PCM.",
|
||||||
|
"INV_002": "Rust AudioEngine MUST own voice state, VAD state, processing config, route policy, stats, and diagnostics.",
|
||||||
|
"INV_003": "Platform audio layer MUST only provide audio I/O, device/session config, and route detection.",
|
||||||
|
"INV_004": "tsclientlib MUST remain the voice network/session layer.",
|
||||||
|
"INV_005": "Keep existing 20ms Opus frame/network contract in P1.",
|
||||||
|
"INV_006": "Add internal 10ms processing frames by splitting 20ms frames into two 10ms frames.",
|
||||||
|
"INV_007": "Realtime callbacks MUST NOT call Flutter, block on network, perform file I/O, allocate unbounded memory, or run ML inference.",
|
||||||
|
"INV_008": "Rust panics MUST NOT cross FFI/bridge boundaries.",
|
||||||
|
"INV_009": "Never enable platform AEC and Rust AEC simultaneously on the same route.",
|
||||||
|
"INV_010": "Never enable VoiceProcessingIO and Sonora AEC3 simultaneously.",
|
||||||
|
"INV_011": "Software AEC-capable backends MUST receive both capture and render-reference streams.",
|
||||||
|
"INV_012": "Render reference MUST be copied from decoded/mixed remote PCM before playout.",
|
||||||
|
"INV_013": "VAD MUST NOT stop capture or playback; VAD may only gate transmit and update speaking/debug state."
|
||||||
|
},
|
||||||
|
"canonical_audio_model": {
|
||||||
|
"network_frame": {
|
||||||
|
"sample_rate_hz": 48000,
|
||||||
|
"channels": 1,
|
||||||
|
"frame_ms": 20,
|
||||||
|
"samples": 960,
|
||||||
|
"sample_format": "i16_or_f32_internal_conversion",
|
||||||
|
"status": "keep_existing"
|
||||||
|
},
|
||||||
|
"processing_frame": {
|
||||||
|
"sample_rate_hz": 48000,
|
||||||
|
"channels": 1,
|
||||||
|
"frame_ms": 10,
|
||||||
|
"samples": 480,
|
||||||
|
"sample_format": "f32",
|
||||||
|
"status": "new_internal_contract"
|
||||||
|
},
|
||||||
|
"vad_model_input": {
|
||||||
|
"sample_rate_hz": 16000,
|
||||||
|
"channels": 1,
|
||||||
|
"sample_format": "f32",
|
||||||
|
"source": "processed_capture",
|
||||||
|
"resample_from": "48kHz"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"target_architecture": {
|
||||||
|
"layers": [
|
||||||
|
"Flutter UI",
|
||||||
|
"Typed Bridge / FFI",
|
||||||
|
"Rust Application Core",
|
||||||
|
"tsclientlib Adapter",
|
||||||
|
"Rust AudioEngine",
|
||||||
|
"AudioDevice backend",
|
||||||
|
"AudioProcessor backend",
|
||||||
|
"VoiceActivityDetector backend",
|
||||||
|
"VoiceTransmitStateMachine",
|
||||||
|
"AudioStats",
|
||||||
|
"WavDebugRecorder"
|
||||||
|
],
|
||||||
|
"capture_path": [
|
||||||
|
"Platform mic input",
|
||||||
|
"Platform audio callback",
|
||||||
|
"Minimal realtime-safe push into Rust",
|
||||||
|
"Canonical conversion to 48kHz mono f32",
|
||||||
|
"20ms network frame split into 2x10ms processing frames",
|
||||||
|
"Raw mic debug tap",
|
||||||
|
"AudioProcessor.process_capture",
|
||||||
|
"Processed mic debug tap",
|
||||||
|
"ML VAD worker consumes processed capture",
|
||||||
|
"VoiceActivityStateMachine updates transmit state",
|
||||||
|
"If transmitting, collect 10ms frames into 20ms Opus frame",
|
||||||
|
"tsclientlib / existing send path"
|
||||||
|
],
|
||||||
|
"render_path": [
|
||||||
|
"tsclientlib receive/decode/jitter/mix",
|
||||||
|
"Remote mixer output",
|
||||||
|
"Copy render_reference before playout",
|
||||||
|
"If software AEC backend active, call AudioProcessor.process_render on 10ms slices",
|
||||||
|
"Platform output callback",
|
||||||
|
"Speaker / receiver / headset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"modules_to_add": {
|
||||||
|
"crates/chanora_audio/src/frame.rs": "AudioFrame10ms, AudioFrame20ms, split/merge helpers, i16/f32 conversion, dBFS helpers.",
|
||||||
|
"crates/chanora_audio/src/processor.rs": "AudioProcessor trait, AudioBackend enum, backend selector.",
|
||||||
|
"crates/chanora_audio/src/processor/platform.rs": "PlatformVoiceProcessing backend marker/policy.",
|
||||||
|
"crates/chanora_audio/src/processor/sonora.rs": "Optional Sonora backend: HPF, AEC3, NS, AGC2, limiter hook.",
|
||||||
|
"crates/chanora_audio/src/processor/noop.rs": "No-op backend for tests/headset/debug.",
|
||||||
|
"crates/chanora_audio/src/vad/mod.rs": "VoiceActivityDetector trait, VadOutput, VadBackend enum.",
|
||||||
|
"crates/chanora_audio/src/vad/silero_onnx.rs": "Silero ONNX VAD runtime wrapper.",
|
||||||
|
"crates/chanora_audio/src/vad/webrtc.rs": "WebRTC VAD fallback.",
|
||||||
|
"crates/chanora_audio/src/vad/energy_debug.rs": "Debug-only energy VAD.",
|
||||||
|
"crates/chanora_audio/src/voice_activity.rs": "VoiceActivity state machine.",
|
||||||
|
"crates/chanora_audio/src/stats.rs": "Expanded AudioStats schema.",
|
||||||
|
"crates/chanora_audio/src/debug_wav.rs": "Async WAV dump writer.",
|
||||||
|
"crates/chanora_audio/src/route_policy.rs": "AudioRoute enum and route-to-processing policy.",
|
||||||
|
"crates/chanora_audio/src/ios_raw_unit.rs": "Optional raw iOS RemoteIO path for Sonora mode."
|
||||||
|
},
|
||||||
|
"modules_to_modify": {
|
||||||
|
"crates/chanora_audio/src/lib.rs": [
|
||||||
|
"Export AudioProcessingConfig, AudioStats, AudioRoute, AudioBackend, VadBackend.",
|
||||||
|
"Replace no-op effects semantics with backend-aware processing config."
|
||||||
|
],
|
||||||
|
"crates/chanora_audio/src/engine.rs": [
|
||||||
|
"Own AudioProcessingConfig.",
|
||||||
|
"Own AudioProcessor backend.",
|
||||||
|
"Own VoiceActivityDetector backend.",
|
||||||
|
"Own VoiceActivityStateMachine.",
|
||||||
|
"Expose expanded stats.",
|
||||||
|
"Preserve existing 20ms Opus send/receive behavior."
|
||||||
|
],
|
||||||
|
"crates/chanora_audio/src/transmit_selector.rs": [
|
||||||
|
"Remove VoiceActivity == Continuous behavior.",
|
||||||
|
"Accept VoiceActivityStateMachine result for VoiceActivity transmit mode."
|
||||||
|
],
|
||||||
|
"crates/chanora_audio/src/ios_voice_unit.rs": [
|
||||||
|
"Keep as platform VoiceProcessingIO backend.",
|
||||||
|
"Ensure Rust AEC/NS/AGC disabled when this backend is active.",
|
||||||
|
"Feed captured audio into Rust VAD/state pipeline.",
|
||||||
|
"Do not run ML inference in callback.",
|
||||||
|
"Expose render_reference for debug/future software AEC validation."
|
||||||
|
],
|
||||||
|
"crates/chanora_audio/src/android_voice_unit.rs": [
|
||||||
|
"Keep current Oboe backend.",
|
||||||
|
"Future: adapt to same AudioProcessor/VAD config and stats."
|
||||||
|
],
|
||||||
|
"crates/chanora_bridge/src/api.rs": [
|
||||||
|
"Add set_audio_processing_config.",
|
||||||
|
"Add audio_processing_stats.",
|
||||||
|
"Add set_vad_model_path or bundled model discovery.",
|
||||||
|
"Add enable_audio_debug_wav_dump."
|
||||||
|
],
|
||||||
|
"apps/chanora_flutter/ios/Runner/AppDelegate.swift": [
|
||||||
|
"Keep AVAudioSession ownership.",
|
||||||
|
"Add/verify mediaServicesWereReset handling.",
|
||||||
|
"Send detailed route class to Rust.",
|
||||||
|
"Do not send PCM to Flutter."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"voice_processing_backends": {
|
||||||
|
"platform_voice_processing": {
|
||||||
|
"description": "OS/platform voice processing.",
|
||||||
|
"ios_implementation": "VoiceProcessingIO AudioUnit",
|
||||||
|
"ios_default": true,
|
||||||
|
"owns": [
|
||||||
|
"AEC",
|
||||||
|
"NS",
|
||||||
|
"AGC"
|
||||||
|
],
|
||||||
|
"rust_aec": "disabled",
|
||||||
|
"rust_ns": "disabled_by_default",
|
||||||
|
"rust_agc": "disabled_by_default"
|
||||||
|
},
|
||||||
|
"sonora": {
|
||||||
|
"description": "Optional Rust-native WebRTC-style voice processor.",
|
||||||
|
"ios_status": "experimental_optional",
|
||||||
|
"future_cross_platform_status": "candidate",
|
||||||
|
"owns": [
|
||||||
|
"HPF",
|
||||||
|
"AEC3",
|
||||||
|
"NS",
|
||||||
|
"AGC2",
|
||||||
|
"limiter_hook"
|
||||||
|
],
|
||||||
|
"requires": [
|
||||||
|
"raw_or_near_raw_capture",
|
||||||
|
"render_reference",
|
||||||
|
"48kHz mono 10ms frames"
|
||||||
|
],
|
||||||
|
"must_not_run_with": [
|
||||||
|
"VoiceProcessingIO",
|
||||||
|
"platform AEC"
|
||||||
|
],
|
||||||
|
"shipping_default": false
|
||||||
|
},
|
||||||
|
"webrtc_apm": {
|
||||||
|
"description": "Fallback/candidate WebRTC APM backend.",
|
||||||
|
"status": "candidate_or_fallback",
|
||||||
|
"requires": [
|
||||||
|
"render_reference_for_AEC"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"noop": {
|
||||||
|
"description": "No processing.",
|
||||||
|
"status": "debug_or_headset_policy"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ios_processing_modes": {
|
||||||
|
"platform_default": {
|
||||||
|
"mode_id": "ios_platform_voice_processing",
|
||||||
|
"shipping_default": true,
|
||||||
|
"audio_io": "VoiceProcessingIO AudioUnit",
|
||||||
|
"av_audio_session": {
|
||||||
|
"category": "playAndRecord",
|
||||||
|
"mode": "voiceChat",
|
||||||
|
"preferred_sample_rate_hz": 48000,
|
||||||
|
"preferred_io_buffer_duration_ms": [
|
||||||
|
5,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"options": [
|
||||||
|
"defaultToSpeaker_when_speaker_route",
|
||||||
|
"allowBluetoothHFP",
|
||||||
|
"allowBluetoothA2DP_only_for_output_policy"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"processing": {
|
||||||
|
"aec": "platform",
|
||||||
|
"ns": "platform",
|
||||||
|
"agc": "platform",
|
||||||
|
"hpf": "platform_or_safe_rust_optional",
|
||||||
|
"limiter": "safe_rust_optional",
|
||||||
|
"sonora": "disabled"
|
||||||
|
},
|
||||||
|
"vad": {
|
||||||
|
"backend": "silero_vad_onnx",
|
||||||
|
"input_source": "platform_processed_capture"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sonora_experimental": {
|
||||||
|
"mode_id": "ios_raw_sonora",
|
||||||
|
"shipping_default": false,
|
||||||
|
"audio_io": "RemoteIO_or_raw_input_output_path",
|
||||||
|
"av_audio_session": {
|
||||||
|
"category": "playAndRecord",
|
||||||
|
"mode": "default_or_measurement_candidate",
|
||||||
|
"avoid": [
|
||||||
|
"voiceChat",
|
||||||
|
"videoChat",
|
||||||
|
"VoiceProcessingIO",
|
||||||
|
"setVoiceProcessingEnabled(true)",
|
||||||
|
"setPrefersEchoCancelledInput(true)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"processing": {
|
||||||
|
"backend": "sonora",
|
||||||
|
"order": [
|
||||||
|
"hpf",
|
||||||
|
"aec3",
|
||||||
|
"ns",
|
||||||
|
"agc2",
|
||||||
|
"limiter",
|
||||||
|
"vad_input_tap"
|
||||||
|
],
|
||||||
|
"requires_render_reference": true,
|
||||||
|
"requires_raw_or_near_raw_capture": true
|
||||||
|
},
|
||||||
|
"vad": {
|
||||||
|
"backend": "silero_vad_onnx",
|
||||||
|
"input_source": "sonora_processed_capture"
|
||||||
|
},
|
||||||
|
"fallback": {
|
||||||
|
"on_backend_failure": "switch_to_ios_platform_voice_processing",
|
||||||
|
"on_render_reference_missing": "reject_sonora_aec_activation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"route_policy": {
|
||||||
|
"ios": {
|
||||||
|
"speaker": {
|
||||||
|
"default": "platform_voice_processing",
|
||||||
|
"optional": "sonora_experimental",
|
||||||
|
"aec": "platform_by_default",
|
||||||
|
"ns": "platform_by_default",
|
||||||
|
"agc": "platform_by_default",
|
||||||
|
"vad": "silero_vad_onnx",
|
||||||
|
"sonora_allowed_if": [
|
||||||
|
"raw_io_available",
|
||||||
|
"render_reference_available"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"earpiece": {
|
||||||
|
"default": "platform_voice_processing",
|
||||||
|
"optional": "sonora_experimental",
|
||||||
|
"vad": "silero_vad_onnx"
|
||||||
|
},
|
||||||
|
"wired_headset": {
|
||||||
|
"default": "noop_or_conservative",
|
||||||
|
"aec": "off",
|
||||||
|
"ns": "conservative_optional",
|
||||||
|
"agc": "conservative_optional",
|
||||||
|
"optional": "sonora_ns_agc_without_aec",
|
||||||
|
"vad": "silero_vad_onnx"
|
||||||
|
},
|
||||||
|
"bluetooth_hfp": {
|
||||||
|
"default": "route_managed",
|
||||||
|
"aec": "app_side_off",
|
||||||
|
"ns": "route_managed_or_conservative",
|
||||||
|
"agc": "route_managed_or_conservative",
|
||||||
|
"sonora": "disabled_by_default",
|
||||||
|
"vad": "silero_vad_onnx"
|
||||||
|
},
|
||||||
|
"bluetooth_a2dp": {
|
||||||
|
"default": "invalid_for_duplex_voice",
|
||||||
|
"transmit_allowed": false,
|
||||||
|
"vad": "disabled"
|
||||||
|
},
|
||||||
|
"unknown": {
|
||||||
|
"default": "safe_fallback",
|
||||||
|
"aec": "off_until_classified",
|
||||||
|
"vad": "silero_vad_onnx"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"future_platforms": {
|
||||||
|
"android": {
|
||||||
|
"default": "platform_effects_if_available_else_rust_backend",
|
||||||
|
"audio_io": "Oboe",
|
||||||
|
"vad": "silero_vad_onnx"
|
||||||
|
},
|
||||||
|
"windows": {
|
||||||
|
"default": "rust_backend_candidate",
|
||||||
|
"audio_io": "WASAPI_or_CPAL",
|
||||||
|
"vad": "silero_vad_onnx"
|
||||||
|
},
|
||||||
|
"macos": {
|
||||||
|
"default": "platform_or_rust_policy",
|
||||||
|
"audio_io": "CoreAudio_or_CPAL",
|
||||||
|
"vad": "silero_vad_onnx"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"default": "rust_backend_candidate",
|
||||||
|
"audio_io": "CPAL_with_ALSA_PulseAudio_PipeWire",
|
||||||
|
"vad": "silero_vad_onnx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vad_runtime_strategy": {
|
||||||
|
"owner": "Rust AudioEngine",
|
||||||
|
"default_backend": "silero_vad_onnx",
|
||||||
|
"model_artifact": "silero_vad.onnx",
|
||||||
|
"primary_runtime": "onnxruntime_c_api",
|
||||||
|
"rust_binding": "ort_or_onnxruntime_wrapper",
|
||||||
|
"fallback_backend": "webrtc_vad",
|
||||||
|
"debug_backend": "energy_vad",
|
||||||
|
"flutter_onnx_plugins": {
|
||||||
|
"allowed_in_realtime_voice_path": false,
|
||||||
|
"blocked_packages_for_core_path": [
|
||||||
|
"onnxruntime_v2",
|
||||||
|
"fonnx"
|
||||||
|
],
|
||||||
|
"reason": "Flutter must not process realtime PCM or own VAD inference."
|
||||||
|
},
|
||||||
|
"coreml": {
|
||||||
|
"p1_primary": false,
|
||||||
|
"p1_conversion_required": false,
|
||||||
|
"p2_candidate": true,
|
||||||
|
"allowed_p1_use": "CoreML Execution Provider benchmark only, not correctness dependency"
|
||||||
|
},
|
||||||
|
"execution_provider_policy": {
|
||||||
|
"correctness_provider": "CPU",
|
||||||
|
"acceleration_providers_optional": true,
|
||||||
|
"ios_optional": [
|
||||||
|
"CoreML_EP_after_benchmark"
|
||||||
|
],
|
||||||
|
"android_optional": [
|
||||||
|
"NNAPI_after_benchmark"
|
||||||
|
],
|
||||||
|
"windows_optional": [
|
||||||
|
"DirectML_after_benchmark",
|
||||||
|
"CUDA_after_benchmark"
|
||||||
|
],
|
||||||
|
"macos_optional": [
|
||||||
|
"CoreML_EP_after_benchmark"
|
||||||
|
],
|
||||||
|
"linux_optional": [
|
||||||
|
"OpenVINO_after_benchmark",
|
||||||
|
"CUDA_after_benchmark",
|
||||||
|
"ROCm_after_benchmark"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runtime_rules": {
|
||||||
|
"create_session_once": true,
|
||||||
|
"reuse_session": true,
|
||||||
|
"run_outside_realtime_callback": true,
|
||||||
|
"do_not_create_session_per_frame": true,
|
||||||
|
"fallback_if_model_missing": true,
|
||||||
|
"fallback_if_inference_lag_exceeds_budget": true,
|
||||||
|
"fallback_if_memory_budget_exceeded": true
|
||||||
|
},
|
||||||
|
"model_input": {
|
||||||
|
"sample_rate_hz": 16000,
|
||||||
|
"channels": 1,
|
||||||
|
"source": "processed_capture",
|
||||||
|
"resample_from_hz": 48000
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"open_threshold": 0.55,
|
||||||
|
"close_threshold": 0.35,
|
||||||
|
"open_after_ms": 40,
|
||||||
|
"hangover_ms": 500,
|
||||||
|
"pre_roll_ms": 160,
|
||||||
|
"min_tx_ms": 200,
|
||||||
|
"max_inference_lag_ms": 60
|
||||||
|
},
|
||||||
|
"ios_memory_budget": {
|
||||||
|
"target_extra_rss_mb": 30,
|
||||||
|
"max_acceptable_extra_rss_mb": 50,
|
||||||
|
"fail_threshold_extra_rss_mb": 80,
|
||||||
|
"measurement_required": true,
|
||||||
|
"measurement_tool": "Xcode Instruments"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"noise_suppression_strategy": {
|
||||||
|
"p1_default": "platform_or_webRTC_style_NS_not_AI_NS",
|
||||||
|
"ios": {
|
||||||
|
"default": "VoiceProcessingIO_NS",
|
||||||
|
"rust_ns": "disabled_by_default",
|
||||||
|
"reason": "Avoid double noise suppression and voice degradation."
|
||||||
|
},
|
||||||
|
"ios_sonora_optional": {
|
||||||
|
"default": "Sonora_NS",
|
||||||
|
"requires": [
|
||||||
|
"raw_io",
|
||||||
|
"sonora_backend_enabled"
|
||||||
|
],
|
||||||
|
"status": "experimental"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"default": "Android NoiseSuppressor if available",
|
||||||
|
"fallback_candidate": "Sonora_NS_or_WebRTC_APM_NS"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"default_candidate": "Sonora_NS_or_WebRTC_APM_NS"
|
||||||
|
},
|
||||||
|
"not_p1_default": [
|
||||||
|
"RNNoise",
|
||||||
|
"DeepFilterNet",
|
||||||
|
"AI_noise_suppression_default"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"audio_processing_config_schema": {
|
||||||
|
"schema_version": "p1.audio_processing.v1",
|
||||||
|
"sample_rate_hz": 48000,
|
||||||
|
"network_frame_ms": 20,
|
||||||
|
"processing_frame_ms": 10,
|
||||||
|
"route": "speaker|earpiece|wired_headset|bluetooth_hfp|bluetooth_a2dp|unknown",
|
||||||
|
"ios_mode": "platform_voice_processing|sonora_experimental",
|
||||||
|
"processing_backend": "platform_voice_processing|sonora|webrtc_apm|noop",
|
||||||
|
"vad_backend": "silero_vad_onnx|webrtc_vad|energy_debug|disabled",
|
||||||
|
"aec": "platform|sonora|webrtc_apm|off",
|
||||||
|
"ns": "platform|sonora|webrtc_apm|off|conservative",
|
||||||
|
"agc": "platform|sonora|webrtc_apm|off|conservative",
|
||||||
|
"hpf_enabled": true,
|
||||||
|
"limiter_enabled": true,
|
||||||
|
"vad": {
|
||||||
|
"model": "silero_vad.onnx",
|
||||||
|
"runtime": "onnxruntime_c_api",
|
||||||
|
"input_source": "processed_capture",
|
||||||
|
"model_sample_rate_hz": 16000,
|
||||||
|
"open_threshold": 0.55,
|
||||||
|
"close_threshold": 0.35,
|
||||||
|
"open_after_ms": 40,
|
||||||
|
"hangover_ms": 500,
|
||||||
|
"pre_roll_ms": 160,
|
||||||
|
"min_tx_ms": 200,
|
||||||
|
"max_inference_lag_ms": 60,
|
||||||
|
"fallback_backend": "webrtc_vad"
|
||||||
|
},
|
||||||
|
"debug": {
|
||||||
|
"stats_enabled": true,
|
||||||
|
"wav_dump_enabled": false
|
||||||
|
},
|
||||||
|
"invalid_combinations": [
|
||||||
|
"ios_mode=platform_voice_processing AND processing_backend=sonora",
|
||||||
|
"aec=platform AND aec=sonora",
|
||||||
|
"VoiceProcessingIO enabled AND Sonora AEC3 enabled",
|
||||||
|
"vad_backend=energy_debug in production",
|
||||||
|
"route=bluetooth_a2dp AND transmit_allowed=true"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ffi_bridge_api_additions": {
|
||||||
|
"set_audio_processing_config": {
|
||||||
|
"crate": "crates/chanora_bridge/src/api.rs",
|
||||||
|
"input": "BridgeAudioProcessingConfig",
|
||||||
|
"output": "Result<(), BridgeError>"
|
||||||
|
},
|
||||||
|
"audio_processing_stats": {
|
||||||
|
"crate": "crates/chanora_bridge/src/api.rs",
|
||||||
|
"input": "none",
|
||||||
|
"output": "BridgeAudioProcessingStats"
|
||||||
|
},
|
||||||
|
"set_vad_model_path": {
|
||||||
|
"crate": "crates/chanora_bridge/src/api.rs",
|
||||||
|
"input": "String",
|
||||||
|
"output": "Result<(), BridgeError>",
|
||||||
|
"note": "May be replaced by bundled model discovery."
|
||||||
|
},
|
||||||
|
"enable_audio_debug_wav_dump": {
|
||||||
|
"crate": "crates/chanora_bridge/src/api.rs",
|
||||||
|
"input": "bool",
|
||||||
|
"output": "Result<(), BridgeError>"
|
||||||
|
},
|
||||||
|
"set_ios_voice_processing_mode": {
|
||||||
|
"crate": "crates/chanora_bridge/src/api.rs",
|
||||||
|
"input": "platform_voice_processing|sonora_experimental",
|
||||||
|
"output": "Result<(), BridgeError>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runtime_stats_schema": {
|
||||||
|
"existing_keep": [
|
||||||
|
"frames_sent",
|
||||||
|
"frames_received"
|
||||||
|
],
|
||||||
|
"add": {
|
||||||
|
"input_dbfs": "float",
|
||||||
|
"render_dbfs": "float",
|
||||||
|
"processed_dbfs": "float",
|
||||||
|
"vad_probability": "float",
|
||||||
|
"vad_active": "bool",
|
||||||
|
"speaking": "bool",
|
||||||
|
"transmitting": "bool",
|
||||||
|
"vad_backend": "enum",
|
||||||
|
"vad_fallback_active": "bool",
|
||||||
|
"vad_inference_lag_ms": "float",
|
||||||
|
"vad_runtime_memory_estimate_mb": "float_optional",
|
||||||
|
"processing_backend": "enum",
|
||||||
|
"ios_voice_processing_mode": "enum_optional",
|
||||||
|
"audio_route": "enum",
|
||||||
|
"actual_sample_rate_hz": "uint32",
|
||||||
|
"actual_io_buffer_frames": "uint32",
|
||||||
|
"capture_queue_ms": "float",
|
||||||
|
"render_queue_ms": "float",
|
||||||
|
"estimated_echo_delay_ms": "float_optional",
|
||||||
|
"input_overruns": "uint64",
|
||||||
|
"output_underruns": "uint64",
|
||||||
|
"callback_xruns": "uint64",
|
||||||
|
"clipped_samples": "uint64",
|
||||||
|
"sonora_enabled": "bool",
|
||||||
|
"platform_voice_processing_enabled": "bool"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"debug_wav": {
|
||||||
|
"enabled_by_default": false,
|
||||||
|
"streams": {
|
||||||
|
"raw_mic": "before_audio_processor",
|
||||||
|
"render_reference": "remote_mixer_output_before_playout",
|
||||||
|
"processed_mic": "after_audio_processor"
|
||||||
|
},
|
||||||
|
"rules": [
|
||||||
|
"Never write WAV from realtime callback.",
|
||||||
|
"Use bounded queues to async writer.",
|
||||||
|
"Include route/backend/timestamp in filenames or sidecar metadata."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"failure_modes": {
|
||||||
|
"silero_model_missing": [
|
||||||
|
"fallback_to_webrtc_vad",
|
||||||
|
"emit_structured_error",
|
||||||
|
"stats.vad_fallback_active=true"
|
||||||
|
],
|
||||||
|
"onnxruntime_unavailable": [
|
||||||
|
"fallback_to_webrtc_vad",
|
||||||
|
"do_not_crash_audio_engine"
|
||||||
|
],
|
||||||
|
"vad_inference_late": [
|
||||||
|
"do_not_block_audio",
|
||||||
|
"use_latest_valid_probability_if_not_stale",
|
||||||
|
"fallback_to_webrtc_vad_if_stale"
|
||||||
|
],
|
||||||
|
"onnx_memory_budget_exceeded": [
|
||||||
|
"fallback_to_webrtc_vad",
|
||||||
|
"mark_ml_vad_unavailable",
|
||||||
|
"emit_structured_error"
|
||||||
|
],
|
||||||
|
"sonora_requested_with_vpio": [
|
||||||
|
"reject_config",
|
||||||
|
"return_invalid_config",
|
||||||
|
"do_not_start_double_processing"
|
||||||
|
],
|
||||||
|
"sonora_aec_without_render_reference": [
|
||||||
|
"reject_sonora_aec_activation",
|
||||||
|
"allow_sonora_ns_agc_only_if_configured",
|
||||||
|
"emit_structured_error"
|
||||||
|
],
|
||||||
|
"route_change": [
|
||||||
|
"update AudioRoute",
|
||||||
|
"recompute AudioProcessingConfig",
|
||||||
|
"reset processor if needed",
|
||||||
|
"reset AEC delay state if software AEC active"
|
||||||
|
],
|
||||||
|
"ios_interruption": [
|
||||||
|
"stop_or_pause_audio_io",
|
||||||
|
"resume_after_interruption_end",
|
||||||
|
"update_stats"
|
||||||
|
],
|
||||||
|
"ios_media_services_reset": [
|
||||||
|
"rebuild_AVAudioSession",
|
||||||
|
"rebuild_AudioUnit",
|
||||||
|
"reapply_config",
|
||||||
|
"update_stats"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"implementation_phases": {
|
||||||
|
"P1A_foundation": [
|
||||||
|
"Add frame.rs 10ms/20ms types.",
|
||||||
|
"Add AudioProcessingConfig.",
|
||||||
|
"Add expanded AudioStats.",
|
||||||
|
"Add AudioRoute/AudioBackend/VadBackend enums.",
|
||||||
|
"Keep existing 20ms Opus path passing."
|
||||||
|
],
|
||||||
|
"P1B_voice_activity": [
|
||||||
|
"Implement VoiceActivityDetector trait.",
|
||||||
|
"Implement VoiceActivityStateMachine.",
|
||||||
|
"Make TransmitMode::VoiceActivity real VAD-gated mode.",
|
||||||
|
"Preserve PTT and Continuous behavior."
|
||||||
|
],
|
||||||
|
"P1C_silero_onnx_vad": [
|
||||||
|
"Choose Rust ONNX Runtime binding.",
|
||||||
|
"Bundle or locate silero_vad.onnx.",
|
||||||
|
"Create ONNX session once and reuse it.",
|
||||||
|
"Implement 48kHz-to-16kHz VAD adapter.",
|
||||||
|
"Run inference off callback thread.",
|
||||||
|
"Implement WebRTC VAD fallback."
|
||||||
|
],
|
||||||
|
"P1D_ios_platform_default": [
|
||||||
|
"Keep VoiceProcessingIO default.",
|
||||||
|
"Route VPIO processed capture to Rust VAD pipeline.",
|
||||||
|
"Expose route/backend/VAD stats.",
|
||||||
|
"Add mediaServicesWereReset handling if missing."
|
||||||
|
],
|
||||||
|
"P1E_ios_sonora_optional": [
|
||||||
|
"Add ios_raw_unit.rs RemoteIO/raw path.",
|
||||||
|
"Add processor/sonora.rs backend.",
|
||||||
|
"Wire render_reference to Sonora process_render.",
|
||||||
|
"Wire raw capture to Sonora process_capture.",
|
||||||
|
"Reject Sonora when VPIO active.",
|
||||||
|
"Mark Sonora experimental and disabled by default."
|
||||||
|
],
|
||||||
|
"P1F_diagnostics": [
|
||||||
|
"Add raw_mic/render_reference/processed_mic WAV dump.",
|
||||||
|
"Expose audio_processing_stats to Flutter debug UI.",
|
||||||
|
"No Flutter PCM."
|
||||||
|
],
|
||||||
|
"P1G_acceptance": [
|
||||||
|
"Speaker echo test VPIO default.",
|
||||||
|
"Speaker echo test Sonora optional.",
|
||||||
|
"Double-talk test VPIO vs Sonora.",
|
||||||
|
"VoiceActivity first-syllable test.",
|
||||||
|
"Quiet-room false-open test.",
|
||||||
|
"Keyboard-noise false-open test.",
|
||||||
|
"Route switching test.",
|
||||||
|
"Interruption recovery test.",
|
||||||
|
"Media services reset recovery test.",
|
||||||
|
"30-minute memory/thermal test with ONNX Runtime."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"acceptance_criteria": {
|
||||||
|
"vad": {
|
||||||
|
"VAD_001": "VoiceActivity no longer behaves as Continuous.",
|
||||||
|
"VAD_002": "Silero ONNX VAD active by default in P1 builds where runtime/model are available.",
|
||||||
|
"VAD_003": "WebRTC VAD fallback works if Silero/ONNX Runtime is unavailable, late, or over memory budget.",
|
||||||
|
"VAD_004": "First syllable preserved using pre-roll.",
|
||||||
|
"VAD_005": "Hangover prevents choppy transmit close.",
|
||||||
|
"VAD_006": "ML inference never runs inside realtime callback."
|
||||||
|
},
|
||||||
|
"ios_platform": {
|
||||||
|
"IOSP_001": "VoiceProcessingIO remains default iOS shipping backend.",
|
||||||
|
"IOSP_002": "Platform AEC/NS/AGC active on speaker/earpiece policy.",
|
||||||
|
"IOSP_003": "Rust/Sonora AEC/NS/AGC disabled by default when VPIO active.",
|
||||||
|
"IOSP_004": "Route/interruption/media-services-reset handled without app restart."
|
||||||
|
},
|
||||||
|
"ios_sonora": {
|
||||||
|
"IOSS_001": "Sonora backend exists as optional experimental mode.",
|
||||||
|
"IOSS_002": "Sonora mode uses raw or near-raw iOS I/O, not VoiceProcessingIO.",
|
||||||
|
"IOSS_003": "Sonora AEC3 receives render_reference before playout.",
|
||||||
|
"IOSS_004": "Config rejects VoiceProcessingIO + Sonora AEC3 double-processing.",
|
||||||
|
"IOSS_005": "Sonora can be compared against VPIO using same stats and WAV dumps."
|
||||||
|
},
|
||||||
|
"noise_suppression": {
|
||||||
|
"NS_001": "iOS default uses platform NS through VoiceProcessingIO.",
|
||||||
|
"NS_002": "Rust NS is not double-enabled on iOS platform path.",
|
||||||
|
"NS_003": "Sonora NS is available only in Sonora experimental mode.",
|
||||||
|
"NS_004": "AI noise suppression is not P1 default."
|
||||||
|
},
|
||||||
|
"diagnostics": {
|
||||||
|
"DIAG_001": "Stats expose route, backend, iOS mode, VAD probability, VAD backend, fallback state, sample rate, buffer size, xruns.",
|
||||||
|
"DIAG_002": "Debug WAV dump supports raw_mic, render_reference, processed_mic.",
|
||||||
|
"DIAG_003": "WAV writer never runs on realtime callback."
|
||||||
|
},
|
||||||
|
"regression": {
|
||||||
|
"REG_001": "PTT mode remains functionally unchanged except diagnostics.",
|
||||||
|
"REG_002": "Continuous mode remains functionally unchanged except diagnostics.",
|
||||||
|
"REG_003": "Existing voice_join/voice_leave lifecycle remains public lifecycle.",
|
||||||
|
"REG_004": "Existing tsclientlib receive/decode/mix path remains active."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent_execution_rules": {
|
||||||
|
"AGENT_001": "Modify existing scaffold; do not introduce a parallel audio engine.",
|
||||||
|
"AGENT_002": "Start in crates/chanora_audio; bridge and Flutter changes expose config/stats only.",
|
||||||
|
"AGENT_003": "Preserve 20ms Opus network framing.",
|
||||||
|
"AGENT_004": "Introduce 10ms processing frames internally only.",
|
||||||
|
"AGENT_005": "Do not enable Sonora on iOS default path.",
|
||||||
|
"AGENT_006": "Do not enable Rust AEC when platform AEC is active.",
|
||||||
|
"AGENT_007": "Do not run ML inference in realtime callback.",
|
||||||
|
"AGENT_008": "Do not use Flutter ONNX plugins in the core realtime path.",
|
||||||
|
"AGENT_009": "Every implementation PR should reference requirement or acceptance IDs.",
|
||||||
|
"AGENT_010": "If uncertain, preserve P0 lifecycle, tsclientlib transport, and cross-platform AudioEngine boundary."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# iOS release build
|
||||||
|
|
||||||
|
This document records the credential-free iOS P1 release path and the signing handoff for TestFlight/App Store builds.
|
||||||
|
|
||||||
|
## Unsigned verification build
|
||||||
|
|
||||||
|
Run from the repository root on macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter --version
|
||||||
|
./tools/build-ios.sh --no-codesign
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected unsigned output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/chanora_flutter/build/ios/iphoneos/Runner.app/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Store export configuration
|
||||||
|
|
||||||
|
The App Store export template lives at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/chanora_flutter/ios/ExportOptions/AppStore.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
Use it after Apple signing assets are available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/build-ios.sh --export-method app-store --export-options-plist apps/chanora_flutter/ios/ExportOptions/AppStore.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
Required signing assets:
|
||||||
|
|
||||||
|
- Apple Developer team access for `app.chanora.chanoraFlutter`
|
||||||
|
- App Store distribution certificate or automatic signing permission
|
||||||
|
- App Store provisioning profile if automatic signing is not used
|
||||||
|
- Xcode 26 or later for uploads on or after 2026-04-28
|
||||||
|
|
||||||
|
## Verification checklist
|
||||||
|
|
||||||
|
- `flutter test` passes in `apps/chanora_flutter`
|
||||||
|
- `flutter analyze` passes in `apps/chanora_flutter`
|
||||||
|
- `flutter build ios --release --no-codesign` succeeds
|
||||||
|
- Signed App Store export succeeds once credentials are installed
|
||||||
|
- App Store metadata does not imply TeamSpeak affiliation
|
||||||
@@ -33,6 +33,7 @@ VERSION="v0.2.0-beta.1"
|
|||||||
SKIP_CODESIGN=0
|
SKIP_CODESIGN=0
|
||||||
REGEN=0
|
REGEN=0
|
||||||
EXPORT_METHOD="development"
|
EXPORT_METHOD="development"
|
||||||
|
EXPORT_OPTIONS_PLIST=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -40,6 +41,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--no-codesign) SKIP_CODESIGN=1; shift;;
|
--no-codesign) SKIP_CODESIGN=1; shift;;
|
||||||
--regenerate-bindings) REGEN=1; shift;;
|
--regenerate-bindings) REGEN=1; shift;;
|
||||||
--export-method) EXPORT_METHOD="$2"; shift 2;;
|
--export-method) EXPORT_METHOD="$2"; shift 2;;
|
||||||
|
--export-options-plist) EXPORT_OPTIONS_PLIST="$2"; shift 2;;
|
||||||
*) echo "unknown arg: $1" >&2; exit 2;;
|
*) echo "unknown arg: $1" >&2; exit 2;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -153,8 +155,18 @@ if [[ $SKIP_CODESIGN -eq 1 ]]; then
|
|||||||
flutter build ios --release --no-codesign
|
flutter build ios --release --no-codesign
|
||||||
APP_PATH="$FLUTTER_APP/build/ios/iphoneos/Runner.app"
|
APP_PATH="$FLUTTER_APP/build/ios/iphoneos/Runner.app"
|
||||||
echo " Unsigned .app: $APP_PATH"
|
echo " Unsigned .app: $APP_PATH"
|
||||||
|
else
|
||||||
|
if [[ -n "$EXPORT_OPTIONS_PLIST" ]]; then
|
||||||
|
if [[ -f "$REPO_ROOT/$EXPORT_OPTIONS_PLIST" ]]; then
|
||||||
|
EXPORT_OPTIONS_PLIST="$REPO_ROOT/$EXPORT_OPTIONS_PLIST"
|
||||||
|
elif [[ ! -f "$EXPORT_OPTIONS_PLIST" ]]; then
|
||||||
|
echo " ERROR: export options plist not found: $EXPORT_OPTIONS_PLIST" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
flutter build ipa --release --export-method "$EXPORT_METHOD" --export-options-plist "$EXPORT_OPTIONS_PLIST"
|
||||||
else
|
else
|
||||||
flutter build ipa --release --export-method "$EXPORT_METHOD"
|
flutter build ipa --release --export-method "$EXPORT_METHOD"
|
||||||
|
fi
|
||||||
IPA_PATH="$FLUTTER_APP/build/ios/ipa/chanora_flutter.ipa"
|
IPA_PATH="$FLUTTER_APP/build/ios/ipa/chanora_flutter.ipa"
|
||||||
if [[ ! -f "$IPA_PATH" ]]; then
|
if [[ ! -f "$IPA_PATH" ]]; then
|
||||||
echo " ERROR: expected $IPA_PATH, did not find one." >&2
|
echo " ERROR: expected $IPA_PATH, did not find one." >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user