feat: Android Oboe voice backend — WebRTC APM, VAD, HW/SW toggle, BBCode welcome, link trust, foreground task

Audio engine (Rust):
- Android Oboe: WebRTC APM (AEC/NS/AGC/HPF) + TEN/Silero ONNX VAD
- Hardware effects (JNI) with software fallback per-effect
- Render reference buffer for AEC between output/capture callbacks
- Voice activity gate: suppress transmission when speaker muted (all platforms)
- Audio focus (SDD-109) + Bluetooth SCO (SDD-110) via JNI
- ONNX Runtime 1.26 via ort 2.0.0-rc.12 (down from rc.10, ndarray 0.17)
- VAD worker channel capacity 8→32, initial seq u64::MAX (warm-up fix)
- TEN VAD default backend (was Silero)
- Platform→WebrtcApm resolution after hardware binding
- oboe-rs edisonjwa fork with get_raw_session_id()

Android Kotlin:
- AndroidAudioFocusController + AndroidBluetoothScoController
- AndroidAudioLifecycleController (route changes to Flutter)
- ProGuard rules for new controllers

Flutter UI:
- VoiceSettings: Android HW/SW toggle (Platform auto / WebRTC APM)
- VoiceStatusChip: mute warning border + Speaker muted label
- BBCode welcome message parser (BbCodeText, case-insensitive)
- Welcome message foldable (expanded by default)
- Link trust dialog (domain wildcards, SharedPreferences)
- HapticFeedback on voice sheet opener
- Server name in AppBar, version v0.1.0
- Default channel (id=1) visible, serverquery clients hidden
- flutter_foreground_task integration

Config:
- ort load-dynamic on all non-iOS (Android/Linux/Windows)
- ONNX Runtime AAR 1.26.0
- ndarray moved to common deps (was Apple-only)
This commit is contained in:
Edison Jwa
2026-05-22 09:29:57 +09:00
parent 6af4ecab0f
commit bf284018e6
37 changed files with 3676 additions and 703 deletions
+6 -12
View File
@@ -3,20 +3,14 @@
# Opus CMake build to succeed on CMake 4.x (which removed compatibility
# with cmake_minimum_required < 3.5). audiopus_sys v0.2.2 bundles
# Opus 1.3.1 whose CMakeLists.txt uses a very old minimum version.
#
# IPHONEOS_DEPLOYMENT_TARGET=13.0 is required for iOS builds because
# ___chkstk_darwin (the stack-probe symbol emitted by clang for functions
# with large stack frames) only exists in the iOS 13.0+ runtime. The
# pre-built libopus.a and any C code compiled by the cc crate reference
# this symbol. Without this env var, the cc crate compiles C code
# targeting iOS 10.0 (the Rust default), producing object files that
# reference ___chkstk_darwin but can't resolve it against the 10.0
# runtime. Setting IPHONEOS_DEPLOYMENT_TARGET=13.0 ensures the cc crate
# and CMake both target iOS 13.0+, where ___chkstk_darwin exists.
# (DEC-003: minimum deployment target iOS 13.0)
[env]
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
IPHONEOS_DEPLOYMENT_TARGET = "13.0"
# iOS builds must set IPHONEOS_DEPLOYMENT_TARGET in the invoking script
# or Xcode build phase. Do not set it globally here: native macOS cargo
# checks also compile bundled C/C++ dependencies, and a global iOS
# deployment target makes clang try to link iPhone objects against the
# macOS SDK.
# iOS target linker flags (DEC-003: minimum deployment target iOS 13.0).
#
Generated
+139 -32
View File
@@ -316,6 +316,20 @@ name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
version = "1.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "byteorder"
@@ -421,11 +435,13 @@ dependencies = [
"reqwest",
"sdl2",
"serde_json",
"sonora",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-subscriber",
"tsclientlib",
"webrtc-vad",
"windows 0.54.0",
"zbus",
]
@@ -963,6 +979,28 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn",
"unicode-xid",
]
[[package]]
name = "dhat"
version = "0.3.3"
@@ -1620,7 +1658,7 @@ dependencies = [
"parking_lot",
"rand 0.10.1",
"resolv-conf",
"smallvec 1.15.1",
"smallvec",
"system-configuration",
"thiserror 2.0.18",
"tokio",
@@ -1700,7 +1738,7 @@ dependencies = [
"httparse",
"itoa",
"pin-project-lite",
"smallvec 1.15.1",
"smallvec",
"tokio",
"want",
]
@@ -1796,7 +1834,7 @@ dependencies = [
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec 1.15.1",
"smallvec",
"zerovec",
]
@@ -1854,7 +1892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec 1.15.1",
"smallvec",
"utf8_iter",
]
@@ -2087,9 +2125,9 @@ dependencies = [
[[package]]
name = "libloading"
version = "0.8.9"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
@@ -2252,7 +2290,7 @@ dependencies = [
"equivalent",
"parking_lot",
"portable-atomic",
"smallvec 1.15.1",
"smallvec",
"tagptr",
"uuid",
]
@@ -2276,9 +2314,9 @@ dependencies = [
[[package]]
name = "ndarray"
version = "0.16.1"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex",
@@ -2523,9 +2561,7 @@ dependencies = [
[[package]]
name = "oboe"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
version = "0.6.2"
dependencies = [
"num-derive",
"num-traits",
@@ -2534,9 +2570,7 @@ dependencies = [
[[package]]
name = "oboe-sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
version = "0.6.2"
dependencies = [
"cc",
]
@@ -2627,25 +2661,22 @@ dependencies = [
[[package]]
name = "ort"
version = "2.0.0-rc.10"
version = "2.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721"
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
dependencies = [
"libloading",
"ndarray",
"ort-sys",
"smallvec 2.0.0-alpha.10",
"smallvec",
"tracing",
]
[[package]]
name = "ort-sys"
version = "2.0.0-rc.10"
version = "2.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2aba9f5c7c479925205799216e7e5d07cc1d4fa76ea8058c60a9a30f6a4e890"
dependencies = [
"pkg-config",
]
checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
[[package]]
name = "oslog"
@@ -2695,7 +2726,7 @@ dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec 1.15.1",
"smallvec",
"windows-link",
]
@@ -3198,7 +3229,7 @@ dependencies = [
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec 1.15.1",
"smallvec",
]
[[package]]
@@ -3597,12 +3628,6 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smallvec"
version = "2.0.0-alpha.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b"
[[package]]
name = "socket2"
version = "0.6.3"
@@ -3613,6 +3638,79 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "sonora"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "559f6011d42721f7b4599ef4cf3224ac07539ee5bc25967169701c7aeb6c6bb1"
dependencies = [
"sonora-aec3",
"sonora-agc2",
"sonora-common-audio",
"sonora-ns",
"sonora-simd",
"tracing",
]
[[package]]
name = "sonora-aec3"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65d8b7e53b058aa4631cf2a8f483bd7a0dec093aa413d21b44b86d5704deedeb"
dependencies = [
"sonora-common-audio",
"sonora-fft",
"sonora-simd",
]
[[package]]
name = "sonora-agc2"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "132876fc38c4e191dee939a38243df5eb5830cd29f1d1ac9a67377389d74a10e"
dependencies = [
"bytemuck",
"derive_more",
"sonora-common-audio",
"sonora-fft",
"sonora-simd",
"tracing",
]
[[package]]
name = "sonora-common-audio"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a16fa975fe4aa12e4d980d5453452bd3e6df2b33abfb89840e1757ebd212e6"
dependencies = [
"derive_more",
"sonora-simd",
]
[[package]]
name = "sonora-fft"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cca158c4b6ace2cb3f4fa8084842da2b4ffcc25cf8ac4366675aea541aabc6af"
[[package]]
name = "sonora-ns"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc549f9bca1d60e7a2483e4949e9649467c5cca371a4341e44aaf24dae338fd4"
dependencies = [
"sonora-fft",
]
[[package]]
name = "sonora-simd"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a053f93cc01f4e1cc51c61844a196b6d1da7e054cdd406d2cd27677ba4d898e0"
dependencies = [
"cpufeatures 0.3.0",
]
[[package]]
name = "spki"
version = "0.7.3"
@@ -4087,7 +4185,7 @@ dependencies = [
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec 1.15.1",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
@@ -4492,6 +4590,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webrtc-vad"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39a1e40fd6ca90be95459152a2537f2ba4286ee1b13073f7ebcaa74fc94e3008"
dependencies = [
"cc",
]
[[package]]
name = "widestring"
version = "1.2.1"
-27
View File
@@ -1,27 +0,0 @@
Chanora is dual-licensed under either of:
* Apache License, Version 2.0
([LICENSE-APACHE](LICENSE-APACHE) or https://www.apache.org/licenses/LICENSE-2.0)
* MIT license
([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT)
at your option.
This dual-license model is recorded in
`docs/governance/product-decision-register.md` (decision DEC-020,
Accepted on 2026-05-14).
## Contribution
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in Chanora by you, as defined in the
Apache-2.0 license, shall be dual-licensed as above, without any
additional terms or conditions.
## Third-party software
Chanora bundles or links to third-party software whose own licenses
apply. See `NOTICE` for an inventory and the per-dependency license
texts that ship with the released artefacts. The legal review of the
full dependency set is tracked by DEC-012 and must complete before any
public/store release.
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing the Work or
Derivative Works thereof, You may choose to offer, and charge a
fee for, acceptance of support, warranty, indemnity, or other
liability obligations and/or rights consistent with this License.
However, in accepting such obligations, You may act only on Your
own behalf and on Your sole responsibility, not on behalf of any
other Contributor, and only if You agree to indemnify, defend,
and hold each Contributor harmless for any liability incurred by,
or claims asserted against, such Contributor by reason of your
accepting any such warranty or support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 The Chanora Project Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 The Chanora Project Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -77,7 +77,7 @@ android {
// ANDROID_PLATFORM as -D variables to the child cmake
// invocation). See Cargo.toml [patch.crates-io] block and
// docs/governance/product-decision-register.md DEC-032.
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
abiFilters += listOf("arm64-v8a", "x86_64")
}
}
@@ -154,6 +154,14 @@ android {
}
}
// ONNX Runtime native library for Silero / TEN VAD.
// The ort crate (Rust) loads libonnxruntime.so via dlopen at runtime
// (`load-dynamic` feature). The AAR ships the .so for arm64-v8a,
// armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB.
dependencies {
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
}
flutter {
source = "../.."
}
+5
View File
@@ -25,6 +25,11 @@
-keep class app.chanora.chanora_flutter.AndroidVoiceForegroundService { *; }
-keep class app.chanora.chanora_flutter.AndroidPermissionRequester { *; }
-keep class app.chanora.chanora_flutter.BackIntentBridge { *; }
# SDD-109 / SDD-110 / SDD-111: JNI-referenced voice controllers.
# Called from the Rust audio engine via JNI static methods.
-keep class app.chanora.chanora_flutter.AndroidAudioFocusController { *; }
-keep class app.chanora.chanora_flutter.AndroidBluetoothScoController { *; }
-keep class app.chanora.chanora_flutter.AndroidAudioLifecycleController { *; }
-keep class io.flutter.plugins.** { *; }
# flutter_rust_bridge generated bindings (SDD-079 TypedBridgeFacade) keep
@@ -22,6 +22,7 @@
foregroundServiceType="microphone". Declared here for the
entire API ladder; the platform ignores it on older releases. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 6).
Required on API 33+ to display the ongoing voice-session
@@ -34,6 +35,17 @@
related in-call audio routing operations. -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- SDD-trace: SDD-110 AndroidBluetoothScoController.
BLUETOOTH_ADMIN required for startBluetoothSco() / stopBluetoothSco()
on API 23-30. Superseded by BLUETOOTH_CONNECT on API 31+. -->
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- SDD-trace: SDD-110 AndroidBluetoothScoController.
BLUETOOTH required to query BluetoothAdapter on API < 31. -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<!-- SDD-trace: Android Oboe voice backend; supports SCO/BLE
headset routing on API 31+. Declared here so the platform
allows querying / connecting to bonded Bluetooth audio devices
@@ -80,6 +92,13 @@
android:exported="false"
android:foregroundServiceType="microphone" />
<!-- flutter_foreground_task: keeps the Flutter engine alive when
connected to a server so voice chat is not killed in background. -->
<service
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -0,0 +1,162 @@
package app.chanora.chanora_flutter
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
/**
* Manages Android audio focus (requestAudioFocus / abandonAudioFocus) for
* the Chanora voice session.
*
* Trace: SDD-109 (Android Audio Focus)
*
* ## Lifecycle
*
* 1. [start] — called by the Rust audio engine (via JNI) after the Oboe
* voice streams are opened. Requests `AUDIOFOCUS_GAIN` for
* `USAGE_VOICE_COMMUNICATION` / `CONTENT_TYPE_SPEECH` and registers
* an `OnAudioFocusChangeListener`.
* 2. Focus-change callbacks are forwarded to the Rust engine via
* `publishFocusChange(int)`, a JNI function declared in
* `crates/chanora_audio/src/android_voice_unit.rs`.
* 3. [stop] — called by the Rust engine on voice stop. Abandons focus
* and clears the listener.
*
* ## Thread model
*
* `start` / `stop` are called from a tokio worker thread (via JNI), not
* the Android main thread. The `AudioManager` API is thread-safe.
* `OnAudioFocusChangeListener` callbacks arrive on the main thread; we
* forward to Rust via JNI which attaches the calling thread to the JVM.
*/
internal class AndroidAudioFocusController {
companion object {
private const val TAG = "ChanoraAudioFocus"
/**
* JNI entry point implemented in
* `crates/chanora_audio/src/android_voice_unit.rs`.
*
* Kotlin calls this from [OnAudioFocusChangeListener] to forward
* the focus-change integer to the Rust engine's BackendEvent channel.
*/
@JvmStatic
external fun publishFocusChange(state: Int)
/**
* Start audio focus management.
*
* Called from Rust via JNI after voice unit start.
* Idempotent: repeated calls against an already-started instance
* are silently ignored.
*/
@JvmStatic
fun start(context: Context) {
if (focusRequested) {
Log.d(TAG, "start() called but focus already held; no-op")
return
}
requestFocus(context)
}
/**
* Stop audio focus management.
*
* Called from Rust via JNI on voice stop.
* Idempotent: safe to call when no focus is held.
*/
@JvmStatic
fun stop(context: Context) {
if (!focusRequested) {
Log.d(TAG, "stop() called but no focus held; no-op")
return
}
abandonFocus(context)
}
private var focusRequested: Boolean = false
private var focusRequestHandle: AudioFocusRequest? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val audioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
Log.i(TAG, "onAudioFocusChange: $focusChange")
try {
publishFocusChange(focusChange)
} catch (t: Throwable) {
Log.w(TAG, "publishFocusChange JNI failed: ${t.message}", t)
}
}
private fun requestFocus(context: Context) {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
Log.e(TAG, "AudioManager unavailable; cannot request audio focus")
return
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attr = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attr)
.setOnAudioFocusChangeListener(audioFocusListener, mainHandler)
.build()
val result = am.requestAudioFocus(request)
focusRequested = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
if (focusRequested) {
focusRequestHandle = request
}
Log.i(TAG, "requestAudioFocus result=$result granted=$focusRequested")
} else {
@Suppress("DEPRECATION")
val result = am.requestAudioFocus(
audioFocusListener,
AudioManager.STREAM_VOICE_CALL,
AudioManager.AUDIOFOCUS_GAIN,
)
focusRequested = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
Log.i(TAG, "requestAudioFocus result=$result granted=$focusRequested")
}
} catch (e: SecurityException) {
Log.e(TAG, "requestAudioFocus denied by platform: ${e.message}", e)
focusRequested = false
}
}
private fun abandonFocus(context: Context) {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
Log.e(TAG, "AudioManager unavailable; cannot abandon audio focus")
focusRequested = false
focusRequestHandle = null
return
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val handle = focusRequestHandle
focusRequestHandle = null
if (handle != null) {
am.abandonAudioFocusRequest(handle)
}
} else {
@Suppress("DEPRECATION")
am.abandonAudioFocus(audioFocusListener)
}
Log.i(TAG, "abandonAudioFocus dispatched")
} catch (e: SecurityException) {
Log.w(TAG, "abandonAudioFocus failed: ${e.message}", e)
}
focusRequested = false
focusRequestHandle = null
}
}
}
@@ -0,0 +1,199 @@
package app.chanora.chanora_flutter
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
/**
* Android audio lifecycle controller.
*
* Mirrors the iOS `AppDelegate` audio lifecycle events — route changes,
* device connectivity changes, and app lifecycle transitions — and
* forwards them to the Dart layer over a MethodChannel. The Dart side
* dispatches these to the Rust bridge, the same pattern as
* `_wireIosAudioLifecycle()` in `main.dart`.
*
* Trace: SDD-111 (MobileVoiceAudioBackend cross-platform)
*
* ## Events published
*
* `routeChange` — audio device connected/disconnected (payload:
* `{"routeType": String}`). Mimics iOS
* `handleRouteChange`.
* `interruptionBegan` — audio interruption started (e.g. phone call).
* `interruptionEnded` — audio interruption ended, with `shouldResume`.
* `appDidEnterBackground` — app moved to background.
* `appWillEnterForeground` — app returned to foreground.
* `mediaServicesReset` — equivalent of iOS media services reset
* (Android: audio devices changed significantly).
*
* ## Thread model
*
* All callbacks from `AudioDeviceCallback` arrive on the main thread
* (registered with the main `Handler`). App lifecycle observation is
* driven by the Flutter `AppLifecycleListener` on the Dart side;
* this controller only owns the audio-device side. The Flutter side
* is responsible for wiring lifecycle and forwarding events to Rust.
*/
internal class AndroidAudioLifecycleController(
private val context: Context,
) {
private val audioManager: AudioManager =
context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val mainHandler = Handler(Looper.getMainLooper())
private var callbackRegistered = false
private var currentRouteType: String? = null
private val audioDeviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
notifyRouteChange(addedDevices.firstOrNull())
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
notifyRouteChange(removedDevices.firstOrNull())
}
}
private var channel: MethodChannel? = null
/**
* Attach to [flutterEngine]'s binary messenger.
*
* Creates a MethodChannel named `chanora/android_audio_lifecycle`
* and starts observing audio device changes.
*/
fun attach(flutterEngine: FlutterEngine) {
channel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
CHANNEL_NAME,
)
startObservingAudioDevices()
Log.i(TAG, "attached to flutter engine")
}
/**
* Detach from the Flutter engine and stop observing.
*/
fun detach() {
stopObservingAudioDevices()
channel?.setMethodCallHandler(null)
channel = null
Log.i(TAG, "detached")
}
/**
* Call from `Activity.onResume` to re-evaluate the current route.
*/
fun onResume() {
notifyRouteChange(null)
}
/**
* Call from `Activity.onDestroy` to tear down.
*/
fun onDestroy() {
detach()
}
private fun startObservingAudioDevices() {
if (callbackRegistered) return
audioManager.registerAudioDeviceCallback(audioDeviceCallback, mainHandler)
callbackRegistered = true
Log.d(TAG, "audio device observer started")
}
private fun stopObservingAudioDevices() {
if (!callbackRegistered) return
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
callbackRegistered = false
Log.d(TAG, "audio device observer stopped")
}
private fun notifyRouteChange(
device: AudioDeviceInfo?,
) {
val routeType = classifyCurrentRoute(device)
if (routeType == currentRouteType) {
return
}
currentRouteType = routeType
Log.i(TAG, "route changed to: $routeType")
channel?.invokeMethod(
"handleRouteChange",
mapOf("routeType" to routeType),
)
}
/**
* Classify the current audio output route into a stable string
* matching the iOS route-classification schema so the Dart-side
* parser (`_parseBridgeAudioRoute`) works identically across
* platforms.
*/
private fun classifyCurrentRoute(specificDevice: AudioDeviceInfo?): String {
// If a specific device was added/removed, prefer its type.
if (specificDevice != null) {
return classifyDevice(specificDevice)
}
// Otherwise, classify based on the current output devices.
val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
if (outputs.isEmpty()) return "Unknown"
// Prefer wired/Bluetooth headset if connected.
for (d in outputs) {
val t = classifyDevice(d)
when (t) {
"WiredHeadset", "BluetoothHfp", "BluetoothA2dp", "UsbHeadset" -> return t
else -> {}
}
}
// Fall back to the first output device classification.
val first = classifyDevice(outputs.first())
return when (first) {
"Speaker" -> "Speaker"
"Earpiece" -> "Earpiece"
else -> "Speaker" // Default to Speaker for unknown outputs.
}
}
private fun classifyDevice(d: AudioDeviceInfo): String = when (d.type) {
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Earpiece"
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker"
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "WiredHeadset"
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BluetoothHfp"
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BluetoothA2dp"
AudioDeviceInfo.TYPE_BLE_HEADSET,
AudioDeviceInfo.TYPE_BLE_SPEAKER -> if (Build.VERSION.SDK_INT >= 31) "BluetoothHfp" else "BluetoothA2dp"
AudioDeviceInfo.TYPE_USB_HEADSET -> "UsbHeadset"
AudioDeviceInfo.TYPE_HDMI -> "Hdmi"
else -> {
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
d.type == AudioDeviceInfo.TYPE_BLE_BROADCAST
) {
"BluetoothA2dp"
} else {
"Unknown"
}
}
}
companion object {
private const val TAG = "ChanoraAudioLifecycle"
/**
* MethodChannel name for Android audio lifecycle events.
* Mirrors `chanora/ios_audio_lifecycle` on iOS.
*/
const val CHANNEL_NAME: String = "chanora/android_audio_lifecycle"
}
}
@@ -0,0 +1,241 @@
package app.chanora.chanora_flutter
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.util.Log
/**
* Manages Android Bluetooth SCO (Synchronous Connection-Oriented) audio
* for the Chanora voice session.
*
* Trace: SDD-110 (Android Bluetooth SCO)
*
* Bluetooth SCO is the low-latency, monaural audio path used by Bluetooth
* headsets for phone calls. Without SCO, voice audio may route through
* A2DP which is stereo, high-latency, and lacks the codec support for
* two-way communication. On Android we must explicitly start/stop SCO
* when a Bluetooth headset is present; the platform does not auto-manage
* this for VoIP apps.
*
* ## Lifecycle
*
* 1. [start] — called by the Rust audio engine (via JNI) after the Oboe
* voice streams are opened. Calls `AudioManager.startBluetoothSco()`
* if a Bluetooth SCO-capable device is connected and registers a
* `BroadcastReceiver` for `ACTION_SCO_AUDIO_STATE_UPDATED`.
* 2. SCO state changes are forwarded to the Rust engine via
* `publishScoStateChange(int)`, a JNI function declared in
* `crates/chanora_audio/src/android_voice_unit.rs`.
* 3. [stop] — called by the Rust engine on voice stop. Calls
* `AudioManager.stopBluetoothSco()` and unregisters the receiver.
*
* ## Thread model
*
* `start` / `stop` are called from a tokio worker thread (via JNI).
* `AudioManager.startBluetoothSco` is asynchronous — the platform
* responds with `ACTION_SCO_AUDIO_STATE_UPDATED` which arrives on the
* main thread via the `BroadcastReceiver`.
*/
internal class AndroidBluetoothScoController {
companion object {
private const val TAG = "ChanoraBluetoothSco"
/**
* JNI entry point implemented in
* `crates/chanora_audio/src/android_voice_unit.rs`.
*
* Kotlin calls this from the SCO state `BroadcastReceiver` to
* forward the state integer to the Rust engine's BackendEvent
* channel.
*/
@JvmStatic
external fun publishScoStateChange(state: Int)
/**
* Start Bluetooth SCO management.
*
* Called from Rust via JNI after voice unit start.
* Idempotent: repeated calls against an already-started instance
* are silently ignored.
*/
@JvmStatic
fun start(context: Context) {
val appContext = context.applicationContext
if (scoStarted) {
Log.d(TAG, "start() called but SCO already active; no-op")
return
}
scoStarted = true
registerScoReceiver(appContext)
tryStartSco(appContext)
}
/**
* Stop Bluetooth SCO management.
*
* Called from Rust via JNI on voice stop.
* Idempotent: safe to call when SCO is not active.
*/
@JvmStatic
fun stop(context: Context) {
val appContext = context.applicationContext
scoStarted = false
unregisterScoReceiver(appContext)
tryStopSco(appContext)
}
private var scoStarted: Boolean = false
private var receiverRegistered: Boolean = false
private val scoReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) return
val state = intent.getIntExtra(
AudioManager.EXTRA_SCO_AUDIO_STATE,
AudioManager.SCO_AUDIO_STATE_ERROR,
)
val prevState = intent.getIntExtra(
AudioManager.EXTRA_SCO_AUDIO_PREVIOUS_STATE,
-1,
)
Log.i(
TAG,
"SCO state: ${scoStateName(state)} (prev: ${scoStateName(prevState)})",
)
try {
publishScoStateChange(state)
} catch (t: Throwable) {
Log.w(TAG, "publishScoStateChange JNI failed: ${t.message}", t)
}
}
}
private fun registerScoReceiver(context: Context) {
if (receiverRegistered) return
try {
val filter = IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(scoReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(scoReceiver, filter)
}
receiverRegistered = true
Log.d(TAG, "SCO receiver registered")
} catch (e: Exception) {
Log.w(TAG, "Failed to register SCO receiver: ${e.message}", e)
}
}
private fun unregisterScoReceiver(context: Context) {
if (!receiverRegistered) return
try {
context.unregisterReceiver(scoReceiver)
receiverRegistered = false
Log.d(TAG, "SCO receiver unregistered")
} catch (e: IllegalArgumentException) {
// Already unregistered — ignore silently.
receiverRegistered = false
}
}
private val bluetoothAdapter: BluetoothAdapter?
get() = try {
BluetoothAdapter.getDefaultAdapter()
} catch (e: SecurityException) {
Log.w(TAG, "BluetoothAdapter unavailable: ${e.message}")
null
}
private fun isBluetoothScoOn(am: AudioManager): Boolean = try {
am.isBluetoothScoOn
} catch (e: SecurityException) {
Log.w(TAG, "isBluetoothScoOn failed: ${e.message}")
false
}
private fun isBluetoothScoAvailableOffCall(am: AudioManager): Boolean = try {
am.isBluetoothScoAvailableOffCall
} catch (e: SecurityException) {
Log.w(TAG, "isBluetoothScoAvailableOffCall failed: ${e.message}")
false
}
private fun tryStartSco(context: Context) {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
Log.e(TAG, "AudioManager unavailable; cannot start SCO")
return
}
if (isBluetoothScoOn(am)) {
Log.i(TAG, "SCO already on; no-op")
return
}
val adapter = bluetoothAdapter
if (adapter == null || !adapter.isEnabled) {
Log.i(TAG, "Bluetooth not enabled; skipping SCO start")
return
}
val scoAvailableOffCall = isBluetoothScoAvailableOffCall(am)
if (!scoAvailableOffCall) {
Log.i(TAG, "Bluetooth SCO not available off-call; skipping SCO start")
return
}
val hasScoHeadset = try {
val state = adapter.getProfileConnectionState(BluetoothProfile.HEADSET)
state == BluetoothProfile.STATE_CONNECTED
} catch (e: SecurityException) {
Log.w(TAG, "getProfileConnectionState(HEADSET) failed: ${e.message}")
false
}
if (!hasScoHeadset) {
Log.i(TAG, "No SCO-capable Bluetooth headset connected; skipping SCO start")
return
}
try {
am.startBluetoothSco()
Log.i(TAG, "startBluetoothSco() dispatched")
} catch (e: SecurityException) {
Log.e(TAG, "startBluetoothSco denied: ${e.message}", e)
}
}
private fun tryStopSco(context: Context) {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (am == null) {
Log.e(TAG, "AudioManager unavailable; cannot stop SCO")
return
}
if (!isBluetoothScoOn(am)) {
Log.d(TAG, "SCO not on; no-op")
return
}
try {
am.stopBluetoothSco()
Log.i(TAG, "stopBluetoothSco() dispatched")
} catch (e: SecurityException) {
Log.w(TAG, "stopBluetoothSco denied: ${e.message}", e)
}
}
private fun scoStateName(state: Int): String = when (state) {
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> "DISCONNECTED"
AudioManager.SCO_AUDIO_STATE_CONNECTED -> "CONNECTED"
AudioManager.SCO_AUDIO_STATE_CONNECTING -> "CONNECTING"
AudioManager.SCO_AUDIO_STATE_ERROR -> "ERROR"
else -> "UNKNOWN($state)"
}
}
}
@@ -76,6 +76,11 @@ class MainActivity : FlutterActivity() {
private var audioOutputEvents: EventChannel? = null
private var audioOutputController: AndroidAudioOutputController? = null
// SDD-111: Android audio lifecycle controller (route changes, device
// add/remove, app lifecycle). Mirrors the iOS
// `chanora/ios_audio_lifecycle` channel pattern.
private var audioLifecycleController: AndroidAudioLifecycleController? = null
// SDD-028 (M-3 strict-review fix): the back-intent bridge is owned
// by this Activity instance, not a process-wide `object`. Constructed
// in configureFlutterEngine and cleared in onDestroy after detach().
@@ -202,6 +207,12 @@ class MainActivity : FlutterActivity() {
)
this.audioOutputEvents = audioOutputEvents
audioOutputEvents.setStreamHandler(audioOutputController)
// SDD-111: attach the Android audio lifecycle controller
// (route changes, device add/remove, interruptions).
val lifecycleController = AndroidAudioLifecycleController(applicationContext)
lifecycleController.attach(flutterEngine)
audioLifecycleController = lifecycleController
}
override fun onResume() {
@@ -217,6 +228,8 @@ class MainActivity : FlutterActivity() {
// SDD-106: state already emitted by AndroidPermissionRequester via stateChangeListener; do NOT double-emit (M-4 fix)
requester.onResume(this) { _ -> }
}
// SDD-111: re-evaluate the current audio route on resume.
audioLifecycleController?.onResume()
}
override fun onRequestPermissionsResult(
@@ -252,6 +265,9 @@ class MainActivity : FlutterActivity() {
audioOutputChannel?.setMethodCallHandler(null)
audioOutputEvents?.setStreamHandler(null)
audioOutputController?.detach()
// SDD-111: detach the audio lifecycle controller.
audioLifecycleController?.onDestroy()
audioLifecycleController = null
permissionRequester = null
permissionsChannel = null
audioOutputChannel = null
@@ -65,6 +65,15 @@ internal object MethodChannels {
*/
const val METHOD_POP_TO_SYSTEM: String = "popToSystem"
/**
* Channel for Android audio lifecycle events (route changes,
* interruptions, app lifecycle). Mirrors the iOS
* `chanora/ios_audio_lifecycle` channel.
*
* Trace: SDD-111 (cross-platform mobile voice backend)
*/
const val ANDROID_AUDIO_LIFECYCLE: String = "chanora/android_audio_lifecycle"
const val AUDIO_OUTPUT: String = "app.audio_output"
const val AUDIO_OUTPUT_EVENTS: String = "app.audio_output/events"
const val METHOD_GET_OUTPUT_DEVICES: String = "getOutputDevices"
+134 -17
View File
@@ -18,6 +18,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart';
@@ -30,6 +31,8 @@ import 'widgets/voice_platform.dart';
import 'widgets/voice_bar.dart';
import 'widgets/voice_compact.dart';
import 'widgets/voice_settings.dart';
import 'widgets/bbcode_text.dart';
import 'services/link_trust_service.dart';
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
@@ -62,11 +65,12 @@ Future<void> _configureBundledVadModels() async {
assetPath: _sileroVadAsset,
fileName: 'silero_vad.onnx',
);
await _copyBundledAssetToDocuments(
final ten = await _copyBundledAssetToDocuments(
assetPath: _tenVadAsset,
fileName: 'ten_vad.onnx',
);
await rust.setVadModelPath(path: silero.path);
await rust.setTenVadModelPath(path: ten.path);
}
/// Top padding for macOS to clear traffic-light buttons.
@@ -136,7 +140,7 @@ String? _pttDisplayLabelForKey(LogicalKeyboardKey k) {
/// pubspec.yaml advances (e.g. rc.8 -> rc.9 -> 1.0.0). The
/// build-counter suffix changes automatically on every pubspec
/// `+<n>` bump because Flutter writes it into Info.plist.
const String _kSemverBaseline = 'v1.0.0-rc.8';
const String _kSemverBaseline = 'v0.1.0';
String _kAppVersion = _kSemverBaseline;
Future<void> main() async {
@@ -146,6 +150,8 @@ Future<void> main() async {
unawaited(_wireStorage());
unawaited(_wireConnectivity());
_wireIosAudioLifecycle();
_wireAndroidAudioLifecycle();
await _configureBundledVadModels();
runApp(const ChanoraApp());
}
@@ -214,6 +220,37 @@ void _wireIosAudioLifecycle() {
});
}
/// Wire the Android audio lifecycle MethodChannel.
///
/// Kotlin side (`AndroidAudioLifecycleController`) posts route-change
/// events through `FlutterMethodChannel` named
/// `"chanora/android_audio_lifecycle"`. This handler dispatches them to
/// the FRB bridge functions on the Rust side, mirroring the iOS pattern.
void _wireAndroidAudioLifecycle() {
if (!Platform.isAndroid) return;
const channel = MethodChannel('chanora/android_audio_lifecycle');
channel.setMethodCallHandler((call) async {
try {
switch (call.method) {
case 'handleRouteChange':
final args = call.arguments;
final routeStr = args is Map
? (args['routeType'] as String? ?? 'Unknown')
: (args as String? ?? 'Unknown');
final route = _parseBridgeAudioRoute(routeStr);
rust.handleRouteChange(route: route);
break;
default:
// Unknown method — ignore gracefully rather than crashing.
break;
}
} catch (_) {
// Errors from the Rust side are already logged there;
// don't propagate exceptions to the Android framework.
}
});
}
/// Populate `_kAppVersion` by suffixing the platform-canonical
/// build number to `_kSemverBaseline`. Format:
/// `v1.0.0-rc.8+<build>` (e.g. `v1.0.0-rc.8+64`). The build
@@ -728,6 +765,15 @@ class _BetaHomeState extends State<_BetaHome> {
_phase = _Phase.connected;
_applySnapshot(snap);
});
try {
await FlutterForegroundTask.startService(
notificationTitle: 'Chanora',
notificationText: 'Connected to ${snap.serverName}',
notificationButtons: const [
NotificationButton(id: 'disconnect', text: 'Disconnect'),
],
);
} catch (_) {}
} catch (e) {
if (!mounted) return;
final errorStr = e.toString();
@@ -1186,6 +1232,9 @@ class _BetaHomeState extends State<_BetaHome> {
try {
await rust.disconnect();
} catch (_) {}
try {
await FlutterForegroundTask.stopService();
} catch (_) {}
if (!mounted) return;
setState(() {
_phase = _Phase.idle;
@@ -1506,6 +1555,7 @@ class _BetaHomeState extends State<_BetaHome> {
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
isSelected: _hardMute,
selectedIcon: const Icon(Icons.mic_off),
color: _hardMute ? theme.colorScheme.error : null,
onPressed: _onToggleHardMute,
),
IconButton(
@@ -1513,6 +1563,7 @@ class _BetaHomeState extends State<_BetaHome> {
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
isSelected: _outputMuted,
selectedIcon: const Icon(Icons.headset_off),
color: _outputMuted ? theme.colorScheme.error : null,
onPressed: _toggleOutputMute,
),
],
@@ -1532,6 +1583,10 @@ class _BetaHomeState extends State<_BetaHome> {
const headerTitle = SizedBox.shrink();
final appBarTitle = _phase == _Phase.connected
? Text(_snapshot?.serverName ?? l10n.appTitle, style: theme.textTheme.titleMedium)
: headerTitle;
final bodyContent = LayoutBuilder(
builder: (ctx, bodyConstraints) {
const wideBreakpoint = 600.0;
@@ -1554,7 +1609,8 @@ class _BetaHomeState extends State<_BetaHome> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
Text(statusText(), style: theme.textTheme.titleMedium),
if (_phase != _Phase.connected)
Text(statusText(), style: theme.textTheme.titleMedium),
if (_lostReason != null || _reconnectAttempt != null) ...[
const SizedBox(height: 8),
Container(
@@ -1707,6 +1763,8 @@ class _BetaHomeState extends State<_BetaHome> {
pttBoundKeyLabel: _pttBoundKeyLabel,
audioStats: _audioStats,
isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute,
outputMuted: _outputMuted,
onTap: () => _onOpenVoiceDetailsSheet(),
),
if (_inChannel &&
@@ -1756,7 +1814,7 @@ class _BetaHomeState extends State<_BetaHome> {
}
return Scaffold(
appBar: AppBar(title: headerTitle, actions: headerActions),
appBar: AppBar(title: appBarTitle, actions: headerActions),
body: SafeArea(
top: false,
child: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
@@ -2168,6 +2226,75 @@ class _BookmarkList extends StatelessWidget {
///
/// Driven by the `BridgeEvent::PttCapability` stream published by
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
class _WelcomeMessageTile extends StatefulWidget {
const _WelcomeMessageTile({required this.welcomeMessage});
final String welcomeMessage;
@override
State<_WelcomeMessageTile> createState() => _WelcomeMessageTileState();
}
class _WelcomeMessageTileState extends State<_WelcomeMessageTile> {
bool _expanded = true;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
HapticFeedback.selectionClick();
setState(() => _expanded = !_expanded);
},
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Server welcome message',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
AnimatedCrossFade(
firstChild: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: BbCodeText(
widget.welcomeMessage,
linkTrust: LinkTrustService.instance,
),
),
secondChild: const SizedBox(width: double.infinity),
crossFadeState: _expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
],
),
);
}
}
/// updates the props on each transition.
class _SnapshotView extends StatelessWidget {
const _SnapshotView({
@@ -2242,17 +2369,7 @@ class _SnapshotView extends StatelessWidget {
),
if (snapshot.welcomeMessage.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
snapshot.welcomeMessage,
style: theme.textTheme.bodySmall,
),
),
_WelcomeMessageTile(welcomeMessage: snapshot.welcomeMessage),
],
const Divider(height: 24),
Text(l10n.channelsHeading, style: theme.textTheme.titleMedium),
@@ -2271,7 +2388,6 @@ class _SnapshotView extends StatelessWidget {
: null,
),
title: Text(ch.name),
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
selected: ch.id == currentVoiceChannelId,
onTap:
hasJoinPending ||
@@ -2284,7 +2400,8 @@ class _SnapshotView extends StatelessWidget {
),
),
for (final cl in byChannel[ch.id] ?? const <rust.BridgeClient>[])
_clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel),
if (!cl.isServerQuery)
_clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel),
],
],
);
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
class LinkTrustService extends ChangeNotifier {
static LinkTrustService? _instance;
final Set<String> _trusted = {};
bool _loaded = false;
static LinkTrustService get instance {
_instance ??= LinkTrustService._();
return _instance!;
}
LinkTrustService._() {
_load();
}
Future<void> _load() async {
if (_loaded) return;
_loaded = true;
final prefs = await SharedPreferences.getInstance();
final domains = prefs.getStringList('trusted_domains') ?? [];
_trusted.addAll(domains);
notifyListeners();
}
bool isTrusted(String host) {
host = host.toLowerCase();
for (final pattern in _trusted) {
if (_matches(host, pattern)) return true;
}
return false;
}
Future<void> addTrustedDomain(String host) async {
host = host.toLowerCase();
_trusted.add(host);
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList('trusted_domains', _trusted.toList());
}
bool _matches(String host, String pattern) {
if (pattern.startsWith('*.')) {
final suffix = pattern.substring(2);
return host == suffix || host.endsWith('.$suffix');
}
return host == pattern;
}
}
Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
bool remember = false;
return showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Open external link?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('You are about to open a link to:\n\n$domain'),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: remember,
onChanged: (v) => setDialogState(() => remember = v ?? false),
),
),
const SizedBox(width: 8),
const Flexible(
child: Text('Trust all links from this domain'),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(remember),
child: const Text('Open'),
),
],
),
),
);
}
+28 -46
View File
@@ -10,7 +10,7 @@ import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
part 'api.freezed.dart';
// 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`, `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 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`, `from`, `from`
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
/// Return the platform-conventional log-file path as a string, or
@@ -46,10 +46,24 @@ Future<bool> isConnected() => RustLib.instance.api.crateApiIsConnected();
void handleRouteChange({required BridgeAudioRoute route}) =>
RustLib.instance.api.crateApiHandleRouteChange(route: route);
/// Handle iOS AVAudioSession media-services reset.
/// 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.
void handleMediaServicesReset() =>
RustLib.instance.api.crateApiHandleMediaServicesReset();
/// 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").
void handleMediaServicesResetWithRoute({required String routeClass}) => RustLib
.instance
.api
.crateApiHandleMediaServicesResetWithRoute(routeClass: routeClass);
/// Handle iOS AVAudioSession interruption begin (SDD-101).
void handleInterruptionBegan() =>
RustLib.instance.api.crateApiHandleInterruptionBegan();
@@ -229,59 +243,27 @@ Future<BridgeAudioStats> audioStats() =>
/// Apply the P1 audio-processing config.
Future<void> setAudioProcessingConfig({
required BridgeAudioProcessingConfig config,
}) async {
_lastAppliedAudioConfig = config;
return RustLib.instance.api.crateApiSetAudioProcessingConfig(config: config);
}
}) => RustLib.instance.api.crateApiSetAudioProcessingConfig(config: config);
/// 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.
Future<BridgeAudioProcessingConfig> getAudioProcessingConfig() =>
RustLib.instance.api.crateApiGetAudioProcessingConfig();
/// 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);
/// Configure the TEN VAD ONNX model path.
Future<void> setTenVadModelPath({required String path}) =>
RustLib.instance.api.crateApiSetTenVadModelPath(path: path);
/// Enable or disable audio debug WAV dumping.
Future<void> enableAudioDebugWavDump({required bool enabled}) =>
RustLib.instance.api.crateApiEnableAudioDebugWavDump(enabled: enabled);
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0';
@override
int get rustContentHash => -1835973251;
int get rustContentHash => -436507436;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -103,6 +103,8 @@ abstract class RustLibApi extends BaseApi {
String crateApiExportDiagnostics();
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig();
Future<(String, String)> crateApiGetPttBinding();
Future<int> crateApiGetReleaseTailMs();
@@ -115,6 +117,8 @@ abstract class RustLibApi extends BaseApi {
void crateApiHandleMediaServicesReset();
void crateApiHandleMediaServicesResetWithRoute({required String routeClass});
void crateApiHandleRouteChange({required BridgeAudioRoute route});
Future<void> crateApiInitStorage({required String dir});
@@ -159,6 +163,8 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiSetReleaseTailMs({required int ms});
Future<void> crateApiSetTenVadModelPath({required String path});
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode});
Future<void> crateApiSetVadModelPath({required String path});
@@ -469,7 +475,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
const TaskConstMeta(debugName: "export_diagnostics", argNames: []);
@override
Future<(String, String)> crateApiGetPttBinding() {
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
@@ -481,6 +487,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_bridge_audio_processing_config,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiGetAudioProcessingConfigConstMeta,
argValues: [],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiGetAudioProcessingConfigConstMeta =>
const TaskConstMeta(
debugName: "get_audio_processing_config",
argNames: [],
);
@override
Future<(String, String)> crateApiGetPttBinding() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 12,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_record_string_string,
decodeErrorData: null,
@@ -504,7 +540,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 12,
funcId: 13,
port: port_,
);
},
@@ -531,7 +567,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 13,
funcId: 14,
port: port_,
);
},
@@ -555,7 +591,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -578,7 +614,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bool(shouldResume, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -603,7 +639,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -622,6 +658,32 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: [],
);
@override
void crateApiHandleMediaServicesResetWithRoute({required String routeClass}) {
return handler.executeSync(
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(routeClass, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: null,
),
constMeta: kCrateApiHandleMediaServicesResetWithRouteConstMeta,
argValues: [routeClass],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiHandleMediaServicesResetWithRouteConstMeta =>
const TaskConstMeta(
debugName: "handle_media_services_reset_with_route",
argNames: ["routeClass"],
);
@override
void crateApiHandleRouteChange({required BridgeAudioRoute route}) {
return handler.executeSync(
@@ -629,7 +691,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_audio_route(route, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -657,7 +719,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 18,
funcId: 20,
port: port_,
);
},
@@ -684,7 +746,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 19,
funcId: 21,
port: port_,
);
},
@@ -711,7 +773,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 20,
funcId: 22,
port: port_,
);
},
@@ -735,7 +797,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -765,7 +827,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 22,
funcId: 24,
port: port_,
);
},
@@ -794,7 +856,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 23,
funcId: 25,
port: port_,
);
},
@@ -827,7 +889,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 24,
funcId: 26,
port: port_,
);
},
@@ -858,7 +920,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 25,
funcId: 27,
port: port_,
);
},
@@ -886,7 +948,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 26,
funcId: 28,
port: port_,
);
},
@@ -916,7 +978,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 27,
funcId: 29,
port: port_,
);
},
@@ -944,7 +1006,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_network_state(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -970,7 +1032,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 29,
funcId: 31,
port: port_,
);
},
@@ -998,7 +1060,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 30,
funcId: 32,
port: port_,
);
},
@@ -1026,7 +1088,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 31,
funcId: 33,
port: port_,
);
},
@@ -1058,7 +1120,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 32,
funcId: 34,
port: port_,
);
},
@@ -1088,7 +1150,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 33,
funcId: 35,
port: port_,
);
},
@@ -1106,6 +1168,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiSetReleaseTailMsConstMeta =>
const TaskConstMeta(debugName: "set_release_tail_ms", argNames: ["ms"]);
@override
Future<void> crateApiSetTenVadModelPath({required String path}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(path, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 36,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiSetTenVadModelPathConstMeta,
argValues: [path],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiSetTenVadModelPathConstMeta => const TaskConstMeta(
debugName: "set_ten_vad_model_path",
argNames: ["path"],
);
@override
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode}) {
return handler.executeNormal(
@@ -1116,7 +1208,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 34,
funcId: 37,
port: port_,
);
},
@@ -1144,7 +1236,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 35,
funcId: 38,
port: port_,
);
},
@@ -1171,7 +1263,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 36,
funcId: 39,
port: port_,
);
},
@@ -1199,7 +1291,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 37,
funcId: 40,
port: port_,
);
},
@@ -1231,7 +1323,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 38,
funcId: 41,
port: port_,
);
},
@@ -1260,7 +1352,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 39,
funcId: 42,
port: port_,
);
},
@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/link_trust_service.dart';
final _tagRe = RegExp(
r'\[(\/?(?:b|i|u|s'
r'|color(?:=[^\]]*)?'
r'|size(?:=\d+)?'
r'|url(?:=[^\]]*)?'
r'|img(?:=[^\]]*)?'
r'|list|\*|quote|code|center|left|right'
r'))\]',
caseSensitive: false,
);
final _colorRe = RegExp(r'color=([#\w]+)');
final _sizeRe = RegExp(r'size=(\d+)');
final _urlRe = RegExp(r'url=(.+)');
final _imgRe = RegExp(r'img=(.+)');
final _urlAutoRe = RegExp(r'(?:\[url\])?(https?://[^\s\[\]]+)(?:\[/url\])?', caseSensitive: false);
final _closeUrlRe = RegExp(r'\[/url\]', caseSensitive: false);
int? _findCloseUrl(String src, int from) {
final m = _closeUrlRe.matchAsPrefix(src, from);
if (m != null) return from;
final idx = src.indexOf('[/url]', from);
if (idx >= 0) return idx;
final idxu = src.indexOf('[/URL]', from);
if (idxu >= 0) return idxu;
return null;
}
Color? _parseColor(String hex) {
try {
var h = hex.replaceFirst('#', '');
if (h.length == 6) h = 'FF$h';
if (h.length == 8) {
return Color(int.parse(h, radix: 16));
}
} catch (_) {}
return null;
}
class BbCodeText extends StatelessWidget {
const BbCodeText(this.text, {super.key, required this.linkTrust});
final String text;
final LinkTrustService linkTrust;
@override
Widget build(BuildContext context) {
if (!text.contains('[') || !text.contains(']')) {
return _plainWithAutoLinks(context, text);
}
return _render(context, text);
}
Widget _plainWithAutoLinks(BuildContext context, String src) {
final parts = <InlineSpan>[];
int last = 0;
for (final m in _urlAutoRe.allMatches(src)) {
if (m.start > last) {
parts.add(TextSpan(text: src.substring(last, m.start)));
}
final url = m.group(1)!;
parts.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url,
linkTrust: linkTrust,
child: Text(
url,
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
));
last = m.end;
}
if (last < src.length) {
parts.add(TextSpan(text: src.substring(last)));
}
if (parts.isEmpty) return Text(src);
return Text.rich(TextSpan(children: parts));
}
Widget _render(BuildContext context, String src) {
final spans = <InlineSpan>[];
final tags = <String>[];
void flush(StringBuffer buf) {
if (buf.isEmpty) return;
var t = buf.toString();
buf.clear();
bool bold = false;
bool italic = false;
bool underline = false;
bool strikethrough = false;
Color? color;
double? size;
for (final tag in tags) {
if (tag == 'b') {
bold = true;
} else if (tag == 'i') {
italic = true;
} else if (tag == 'u') {
underline = true;
} else if (tag == 's') {
strikethrough = true;
} else if (tag.startsWith('color=')) {
color = _parseColor(tag.substring(6));
} else if (tag.startsWith('size=')) {
size = double.tryParse(tag.substring(5));
}
}
spans.add(TextSpan(
text: t,
style: TextStyle(
fontWeight: bold ? FontWeight.bold : null,
fontStyle: italic ? FontStyle.italic : null,
decoration: TextDecoration.combine([
if (underline) TextDecoration.underline,
if (strikethrough) TextDecoration.lineThrough,
]),
color: color,
fontSize: size,
),
));
}
final buf = StringBuffer();
int i = 0;
while (i < src.length) {
if (src[i] != '[') {
buf.write(src[i]);
i++;
continue;
}
final m = _tagRe.matchAsPrefix(src, i);
if (m == null) {
buf.write(src[i]);
i++;
continue;
}
flush(buf);
final raw = m.group(1)!.toLowerCase();
i = m.end;
if (raw.startsWith('/')) {
final closeTag = raw.substring(1);
if (closeTag == 'url' || closeTag == 'img') {
continue;
}
tags.remove(closeTag);
continue;
}
switch (raw) {
case 'b':
case 'i':
case 'u':
case 's':
case 'list':
case 'quote':
case 'code':
case 'center':
case 'left':
case 'right':
tags.add(raw);
break;
case '*':
spans.add(const TextSpan(text: '\n \u2022 '));
break;
default:
if (raw.startsWith('color=') || raw.startsWith('size=')) {
tags.add(raw);
} else if (raw.startsWith('url=')) {
final url = _urlRe.firstMatch(raw)?.group(1) ?? '';
final closeIdx = _findCloseUrl(src, i);
String inner;
if (closeIdx != null) {
inner = src.substring(i, closeIdx);
i = closeIdx + 6;
} else {
inner = url;
}
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url.isNotEmpty ? url : inner,
linkTrust: linkTrust,
child: Text(
inner,
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
));
} else if (raw.startsWith('img=')) {
final src2 = _imgRe.firstMatch(raw)?.group(1) ?? '';
if (src2.isNotEmpty) {
spans.add(WidgetSpan(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
Uri.tryParse(src2)?.toString() ?? src2,
fit: BoxFit.scaleDown,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
));
}
} else if (raw == 'url') {
final closeIdx = _findCloseUrl(src, i);
if (closeIdx != null) {
final url = src.substring(i, closeIdx).trim();
i = closeIdx + 6;
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url,
linkTrust: linkTrust,
child: Text(
url,
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
));
}
}
break;
}
}
flush(buf);
if (spans.isEmpty) return Text(src);
return Text.rich(TextSpan(children: spans));
}
}
class _LinkTap extends StatefulWidget {
const _LinkTap({
required this.url,
required this.child,
required this.linkTrust,
});
final String url;
final Widget child;
final LinkTrustService linkTrust;
@override
State<_LinkTap> createState() => _LinkTapState();
}
class _LinkTapState extends State<_LinkTap> {
@override
void initState() {
super.initState();
widget.linkTrust.addListener(_onChanged);
}
@override
void dispose() {
widget.linkTrust.removeListener(_onChanged);
super.dispose();
}
void _onChanged() => mounted ? setState(() {}) : null;
Future<void> _open() async {
final uri = Uri.tryParse(widget.url);
if (uri == null) return;
final host = uri.host;
if (host.isEmpty) return;
if (widget.linkTrust.isTrusted(host)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
return;
}
if (!mounted) return;
final trust = await showLinkTrustDialog(context, host);
if (trust == null) return;
if (trust) {
await widget.linkTrust.addTrustedDomain(host);
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _open,
child: widget.child,
);
}
}
@@ -40,6 +40,8 @@ class VoiceStatusChip extends StatelessWidget {
required this.audioStats,
required this.isTouchOnly,
required this.onTap,
this.inputMuted = false,
this.outputMuted = false,
});
/// Current transmit mode.
@@ -57,6 +59,12 @@ class VoiceStatusChip extends StatelessWidget {
/// True on iOS / iPadOS / Android.
final bool isTouchOnly;
/// True when local mic is muted (hard mute or permission mute).
final bool inputMuted;
/// True when local speaker is muted.
final bool outputMuted;
/// Open the voice details modal.
final VoidCallback onTap;
@@ -95,9 +103,15 @@ class VoiceStatusChip extends StatelessWidget {
final tailText = transmitMode == rust.BridgeTransmitMode.ptt
? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}'
: null;
final micText = micOn ? l10n.voiceMicOn : l10n.voiceMicOff;
final micText = inputMuted
? '${l10n.voiceMicOff} (muted)'
: outputMuted
? 'Speaker muted'
: (micOn ? l10n.voiceMicOn : l10n.voiceMicOff);
final line2 = tailText == null ? micText : '$tailText \u00b7 $micText';
final muted = inputMuted || outputMuted;
return Semantics(
button: true,
label: '${l10n.voiceSheetTitle}: $line1, $line2',
@@ -105,17 +119,24 @@ class VoiceStatusChip extends StatelessWidget {
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
onTap: () {
HapticFeedback.lightImpact();
onTap();
},
borderRadius: BorderRadius.circular(12),
child: ExcludeSemantics(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
color: muted
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outlineVariant,
width: 0.5,
color: muted
? theme.colorScheme.error
: theme.colorScheme.outlineVariant,
width: muted ? 1.5 : 0.5,
),
),
child: Row(
@@ -394,6 +415,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
late bool _aecEnabled;
late bool _agcEnabled;
late bool _hpfEnabled;
late bool _preferHardware;
late rust.BridgeVadBackend _vadBackend;
@override
@@ -404,6 +426,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
_aecEnabled = c.aec != rust.BridgeEffectOwner.off;
_agcEnabled = c.agc != rust.BridgeEffectOwner.off;
_hpfEnabled = c.hpfEnabled;
_preferHardware = c.aec == rust.BridgeEffectOwner.platform;
_vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: c.vadBackend;
@@ -440,23 +463,51 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
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 isAndroid = Platform.isAndroid;
if (isAndroid) {
final owner = _preferHardware
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.webrtcApm;
return rust.BridgeAudioProcessingConfig(
route: c.route,
iosMode: c.iosMode,
processingBackend: _preferHardware
? rust.BridgeAudioBackend.platformVoiceProcessing
: rust.BridgeAudioBackend.webrtcApm,
vadBackend: _vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: _vadBackend,
aec: _aecEnabled ? owner : rust.BridgeEffectOwner.off,
ns: _nsEnabled ? owner : rust.BridgeEffectOwner.off,
agc: _agcEnabled ? owner : rust.BridgeEffectOwner.off,
hpfEnabled: _hpfEnabled,
limiterEnabled: c.limiterEnabled,
vadHangoverMs: c.vadHangoverMs,
vadPreRollMs: c.vadPreRollMs,
vadMinTxMs: c.vadMinTxMs,
debugWavDumpEnabled: c.debugWavDumpEnabled,
);
}
// iOS / macOS: VPIO vs Sonora paths.
// VPIO owns enabled effects on the default path. The experimental raw path
// delegates app-side processing to WebRTC APM.
final aecOwner = isSonora
? (_aecEnabled
? rust.BridgeEffectOwner.sonora
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: rust.BridgeEffectOwner.platform;
final nsOwner = isSonora
? (_nsEnabled
? rust.BridgeEffectOwner.sonora
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (_nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final agcOwner = isSonora
? (_agcEnabled
? rust.BridgeEffectOwner.sonora
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (_agcEnabled
? rust.BridgeEffectOwner.platform
@@ -631,6 +682,22 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
),
),
const SizedBox(height: 4),
// Android: hardware (JNI) vs software (WebRTC APM)
if (Platform.isAndroid) ...[
_AudioToggleRow(
label: 'Prefer hardware effects',
subtitle: _preferHardware
? 'Try JNI hardware · software fallback'
: 'Software WebRTC AEC3 · NS · AGC2',
value: _preferHardware,
onChanged: (v) {
setState(() => _preferHardware = v);
_notifyAudioConfig();
},
),
],
_AudioToggleRow(
label: 'Noise suppression',
subtitle: 'Wiener filter',
@@ -642,25 +709,40 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
),
_AudioToggleRow(
label: 'Echo cancellation',
subtitle:
widget.initialAudioConfig.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
? 'Always on · managed by platform VPIO'
: 'AEC3 adaptive filter · 80 ms tail',
subtitle: () {
if (Platform.isAndroid) {
return 'WebRTC AEC3 · adaptive filter';
}
return 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,
() {
if (Platform.isAndroid) return _aecEnabled;
return widget.initialAudioConfig.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
? true
: _aecEnabled;
}(),
// AEC is always on in VPIO — disable the toggle.
onChanged:
widget.initialAudioConfig.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
? null
: (v) {
setState(() => _aecEnabled = v);
_notifyAudioConfig();
},
// On Android, AEC is user-selectable.
onChanged: () {
if (Platform.isAndroid) {
return (v) {
setState(() => _aecEnabled = v);
_notifyAudioConfig();
};
}
return widget.initialAudioConfig.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
? null
: (v) {
setState(() => _aecEnabled = v);
_notifyAudioConfig();
};
}(),
),
_AudioToggleRow(
label: 'Auto gain control',
@@ -18,6 +18,11 @@ import '../l10n/generated/app_localizations.dart';
import 'voice_platform.dart';
import '../src/rust/api.dart' as rust;
bool get _isAndroid {
if (kIsWeb) return false;
return Platform.isAndroid;
}
bool get _isIos {
if (kIsWeb) return false;
return Platform.isIOS;
@@ -68,6 +73,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
late rust.BridgeVadBackend _vadBackend;
late rust.BridgeIosVoiceProcessingMode _iosMode;
late bool _debugWavDump;
late bool _preferHardware; // Android only: try JNI hardware effects
@override
void initState() {
@@ -86,29 +92,59 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
: c.vadBackend;
_iosMode = c.iosMode;
_debugWavDump = c.debugWavDumpEnabled;
_preferHardware = c.aec == rust.BridgeEffectOwner.platform
|| c.ns == rust.BridgeEffectOwner.platform
|| c.agc == rust.BridgeEffectOwner.platform;
}
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.
if (_isAndroid) {
final owner = _preferHardware
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.webrtcApm;
return rust.BridgeAudioProcessingConfig(
route: c.route,
iosMode: _iosMode,
processingBackend: _preferHardware
? rust.BridgeAudioBackend.platformVoiceProcessing
: rust.BridgeAudioBackend.webrtcApm,
vadBackend: _vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: _vadBackend,
aec: _aecEnabled ? owner : rust.BridgeEffectOwner.off,
ns: _nsEnabled ? owner : rust.BridgeEffectOwner.off,
agc: _agcEnabled ? owner : rust.BridgeEffectOwner.off,
hpfEnabled: _hpfEnabled,
limiterEnabled: _limiterEnabled,
vadHangoverMs: c.vadHangoverMs,
vadPreRollMs: c.vadPreRollMs,
vadMinTxMs: c.vadMinTxMs,
debugWavDumpEnabled: _debugWavDump,
);
}
// iOS / macOS: VPIO vs Sonora paths.
// In VPIO mode, enabled effects are platform-owned. The experimental raw
// path uses WebRTC APM ownership so config validation stays honest.
final aecOwner = isSonora
? (_aecEnabled
? rust.BridgeEffectOwner.sonora
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: rust.BridgeEffectOwner.platform; // VPIO always owns AEC
final nsOwner = isSonora
? (_nsEnabled
? rust.BridgeEffectOwner.sonora
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (_nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final agcOwner = isSonora
? (_agcEnabled
? rust.BridgeEffectOwner.sonora
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (_agcEnabled
? rust.BridgeEffectOwner.platform
@@ -120,7 +156,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
route: c.route,
iosMode: _iosMode,
processingBackend: isSonora
? rust.BridgeAudioBackend.sonora
? rust.BridgeAudioBackend.webrtcApm
: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: vadBackend,
aec: aecOwner,
@@ -244,6 +280,30 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
const SizedBox(height: 4),
],
// Android HW/SW selector
if (_isAndroid) ...[
_subHeader(theme, 'Processing backend'),
_radioTile<bool>(
value: true,
groupValue: _preferHardware,
title: const Text('Platform (auto)'),
subtitle: _tileSubtitle(
'Try hardware JNI effects · software fallback',
),
onSelected: (v) => setState(() => _preferHardware = v),
),
_radioTile<bool>(
value: false,
groupValue: _preferHardware,
title: const Text('WebRTC APM'),
subtitle: _tileSubtitle(
'Software AEC3 · NS · AGC2',
),
onSelected: (v) => setState(() => _preferHardware = v),
),
const SizedBox(height: 4),
],
// DSP toggles
_subHeader(theme, 'DSP stages'),
_switchTile(
@@ -254,12 +314,14 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
),
_switchTile(
title: 'Echo cancellation (AEC3)',
subtitle: platformVpio
? 'Managed by platform VPIO'
: 'Adaptive NLMS · 80 ms tail',
subtitle: _isAndroid
? 'WebRTC AEC3 · adaptive filter'
: 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,
onSelected: (_isAndroid || !platformVpio) ? (v) => _aecEnabled = v : null,
),
_switchTile(
title: 'Auto gain control (AGC2)',
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+128
View File
@@ -246,6 +246,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_foreground_task:
dependency: "direct main"
description:
name: flutter_foreground_task
sha256: fc5c01a5e1b8f7bb51d0c737714f0c50440dbdf1aeddc5f8cbba313aa6fd4856
url: "https://pub.dev"
source: hosted
version: "9.2.2"
flutter_lints:
dependency: "direct dev"
description:
@@ -629,6 +637,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
@@ -722,6 +786,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_math:
dependency: transitive
description:
+3
View File
@@ -77,6 +77,9 @@ dependencies:
# 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
flutter_foreground_task: ^9.2.2
url_launcher: ^6.3.2
shared_preferences: ^2.5.5
dev_dependencies:
flutter_test:
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+17 -8
View File
@@ -13,6 +13,12 @@ publish.workspace = true
chanora_protocol = { path = "../chanora_protocol" }
thiserror.workspace = true
tracing.workspace = true
sonora = "0.1"
webrtc-vad = "0.4"
# ndarray is required by ort's tensor construction API and by
# Silero / TEN VAD ONNX inference across all platforms.
ndarray = "0.17"
# Opus encoder. tsclientlib already pulls this; we depend explicitly so
# this crate can compile against it without going through tsclientlib.
@@ -47,19 +53,15 @@ 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"] }
ort = { version = "2.0.0-rc.12", 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(not(target_os = "ios"))'.dependencies]
ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "api-24"] }
[target.'cfg(target_os = "android")'.dependencies]
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
@@ -75,7 +77,14 @@ ndk-context = "0.1"
# shipped with `oboe-sys` 0.6 covers armv7 / aarch64 / x86 / x86_64.
# Default features keep the precompiled library + pregenerated bindings
# so we avoid the clang-sys / libclang requirement on the build host.
oboe = "0.6"
#
# Using local fork edisonjwa/oboe-rs (v0.6.2) with:
# - catch_unwind safety in callbacks
# - unwrap_or_default in enum getters (no more SessionId panic)
# - get_raw_session_id() for JNI hardware effect binding
# - deduplicated macro impls
# - PowerSavingOffloaded PerformanceMode variant
oboe = { path = "../../../oboe-rs" }
[target.'cfg(target_os = "windows")'.dependencies]
# Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices
+631 -45
View File
@@ -66,72 +66,194 @@ use oboe::{
PerformanceMode, SessionId, SharingMode, Usage,
};
use crate::processor::AudioProcessor;
// `BackendEvent` / `BackendEventRx` / `BackendEventTx` moved to
// `mobile_voice_backend` so the trait can expose `take_event_rx`
// (SDD-111 item 1) cross-platform.
// --- Render-reference buffer for AEC (SDD-111 / SDD-120) ---------
//
// The output (render) callback writes the audio that will be played
// into this ring buffer. The capture callback reads the latest render
// frame and feeds it to WebRTC APM's `process_render` so AEC can
// subtract the speaker output from the microphone input.
//
// 4 slots × 10 ms × 48 kHz mono f32. One slot is always being written
// by the render callback; the capture callback reads the slot that was
// most recently completed.
const RENDER_REF_SLOTS: usize = 4;
const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES;
struct RenderReferenceBuffer {
buf: Box<[[f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]>,
write_idx: std::sync::atomic::AtomicUsize,
}
impl RenderReferenceBuffer {
fn new() -> Arc<Self> {
Arc::new(Self {
buf: Box::new([[0.0_f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]),
write_idx: std::sync::atomic::AtomicUsize::new(0),
})
}
fn write(&self, frame: &[f32; RENDER_REF_SAMPLES]) {
let idx = self.write_idx.load(Ordering::Relaxed);
unsafe {
let slot =
&self.buf[idx] as *const [f32; RENDER_REF_SAMPLES] as *mut [f32; RENDER_REF_SAMPLES];
(*slot).copy_from_slice(frame);
}
self.write_idx
.store((idx + 1) % RENDER_REF_SLOTS, Ordering::Relaxed);
}
fn read_latest(&self) -> [f32; RENDER_REF_SAMPLES] {
let wi = self.write_idx.load(Ordering::Relaxed);
let ri = (wi + RENDER_REF_SLOTS - 1) % RENDER_REF_SLOTS;
self.buf[ri]
}
}
unsafe impl Send for RenderReferenceBuffer {}
unsafe impl Sync for RenderReferenceBuffer {}
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
//
// Mirrors the iOS `IosCaptureState` and the cpal-side `CaptureState`.
// Oboe delivers 48 kHz mono i16 PCM; we apply mic gain, accumulate to
// FRAME_20MS_SAMPLES, encode to Opus 32 kbps (complexity 10, inband FEC, 5 % PLC),
// and try-send the resulting packet on `voice_out_tx`.
// Enhanced with WebRTC APM (AEC/NS/AGC) and VAD (voice activity
// detection). Oboe delivers 48 kHz mono i16 PCM in variable-size
// chunks. We accumulate into 10 ms frames, then:
//
// 1. i16 → f32 conversion
// 2. Read render reference (for AEC)
// 3. WebRtcApmProcessor::process_render + process_capture
// 4. VAD → VoiceActivityStateMachine → TransmitModeSelector
// 5. f32 → i16 conversion + mic gain
// 6. Accumulate to 20 ms → Opus encode → send
struct AndroidCaptureState {
encoder: OpusEncoder,
/// Accumulator for 48 kHz mono PCM. 2x capacity to absorb
/// cpal-style buffer-size jitter without reallocating.
pcm_accum: Vec<i16>,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
vad_detector: crate::vad::WebRtcFallbackVad,
silero_vad_worker: Option<crate::vad::silero_onnx::SileroOnnxVadWorker>,
ten_vad_worker: Option<crate::vad::TenOnnxVadWorker>,
current_vad_backend: crate::VadBackend,
silero_model_epoch: u64,
capture_frame_seq: u64,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
webrtc_apm_processor: crate::processor::WebRtcApmProcessor,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
render_reference: Arc<RenderReferenceBuffer>,
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: usize,
fallback_warned_backend: Option<crate::VadBackend>,
}
impl AndroidCaptureState {
fn new(
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
output_muted: 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("android")?;
// Android always uses software WebRTC APM for AEC/NS/AGC/HPF.
// The config's EffectOwner fields are resolved by open() AFTER
// hardware-effect binding; the processor is constructed here
// with all modules enabled regardless, so the resolved config
// (Platform vs WebrtcApm) only affects diagnostics, not behaviour.
let webrtc_apm_config = audio_processing_config
.lock()
.map(|cfg| {
let mut c = crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(&cfg);
c.aec = true;
c.ns = true;
c.agc = true;
c
})
.unwrap_or(crate::processor::webrtc_apm::WebRtcApmConfig {
aec: true,
ns: true,
agc: true,
hpf: true,
..Default::default()
});
Ok(Self {
encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
output_muted,
frames_sent,
mic_gain,
voice_activity_selector,
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_vad_worker: None,
ten_vad_worker: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
silero_model_epoch: crate::vad::silero_model_epoch(),
capture_frame_seq: 0,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
webrtc_apm_config,
)?,
audio_processing_config,
audio_processing_stats,
render_reference,
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: 0,
fallback_warned_backend: None,
})
}
/// Consume i16 mono frames from Oboe, accumulate to FRAME_20MS_SAMPLES,
/// encode + send when PTT is held. Oboe delivers at the device's
/// native sample rate (always 48 kHz for modern Android per SRS-210),
/// so no resampling is needed.
fn ingest(&mut self, samples: &[i16]) {
/// Consume i16 mono frames from Oboe. Accumulate to 10 ms chunks,
/// process each through WebRTC APM + VAD, then encode 20 ms frames.
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) {
self.pcm_accum.clear();
return;
}
// Mic-gain application.
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| {
let scaled = (s as f32) * gain;
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
}));
}
// Drain complete 20 ms 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);
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(
@@ -154,11 +276,186 @@ impl AndroidCaptureState {
);
}
Err(e) => {
warn!(target: "chanora_audio", error = %e, "android Oboe opus encode failed");
warn!(
target: "chanora_audio",
error = %e,
"android Oboe opus encode failed"
);
}
}
}
}
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
if self.fallback_warned_backend != Some(failed_backend) {
self.fallback_warned_backend = Some(failed_backend);
warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"android: VAD backend unavailable; using WebRTC fallback for runtime detection"
);
}
}
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);
let render_ref = self.render_reference.read_latest();
self.webrtc_apm_processor.process_render(&render_ref);
self.webrtc_apm_processor.process_capture(&mut frame);
let (vad_hangover, vad_backend) = self
.audio_processing_config
.try_lock()
.map(|cfg| (cfg.vad_hangover_ms, cfg.vad_backend))
.unwrap_or((
crate::voice_activity::VAD_HANGOVER_MS,
crate::VadBackend::WebrtcVad,
));
self.vad_state.configure(
crate::voice_activity::VAD_OPEN_AFTER_MS,
vad_hangover,
crate::voice_activity::VAD_MIN_TX_MS,
);
// VAD backend switching (mirrors iOS Raw path).
let silero_epoch = crate::vad::silero_model_epoch();
let silero_changed = vad_backend == crate::VadBackend::SileroOnnx
&& silero_epoch != self.silero_model_epoch;
if vad_backend != self.current_vad_backend || silero_changed {
self.current_vad_backend = vad_backend;
self.silero_model_epoch = silero_epoch;
self.fallback_warned_backend = None;
match vad_backend {
crate::VadBackend::SileroOnnx => {
let path = crate::vad::silero_model_bundle_path();
self.silero_vad_worker =
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path);
self.ten_vad_worker = None;
if self.silero_vad_worker.is_none() {
warn!(
target: "chanora_audio",
"android: Silero VAD model not found at {path}; falling back to WebRTC VAD"
);
}
}
crate::VadBackend::TenVad => {
let path = crate::vad::ten_model_bundle_path();
self.ten_vad_worker = crate::vad::TenOnnxVadWorker::try_new(&path);
self.silero_vad_worker = None;
if self.ten_vad_worker.is_none() {
warn!(
target: "chanora_audio",
"android: TEN VAD ONNX model not found at {path}; falling back to WebRTC VAD"
);
}
}
_ => {
self.silero_vad_worker = None;
self.ten_vad_worker = None;
}
}
self.vad_state.reset();
}
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() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else if vad_backend == crate::VadBackend::TenVad {
if let Some(worker) = self.ten_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
};
self.audio_processing_stats
.set_vad_fallback_active(used_fallback_vad);
let active = self.vad_state.update(vad.speech);
let output_muted = self.output_muted.load(Ordering::Relaxed);
if let Some(sel) = &self.voice_activity_selector {
sel.set_voice_activity_open(active && !output_muted);
}
self.audio_processing_stats.update_capture(
input_dbfs,
crate::frame::dbfs(&frame),
vad.probability,
active && !output_muted,
self.transmit_active.load(Ordering::Relaxed),
);
if !self.transmit_active.load(Ordering::Relaxed) || output_muted {
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
}));
}
}
}
struct InputCallback {
@@ -176,7 +473,7 @@ impl AudioInputCallback for InputCallback {
) -> DataCallbackResult {
let _ = catch_unwind(AssertUnwindSafe(|| {
if let Ok(mut s) = self.state.lock() {
s.ingest(frames);
s.ingest_i16(frames);
}
}));
DataCallbackResult::Continue
@@ -203,6 +500,7 @@ struct OutputCallback {
output_muted: Arc<AtomicBool>,
event_tx: BackendEventTx,
scratch: Arc<Mutex<Vec<f32>>>,
render_reference: Arc<RenderReferenceBuffer>,
}
impl AudioOutputCallback for OutputCallback {
@@ -223,16 +521,17 @@ impl AudioOutputCallback for OutputCallback {
*s = 0.0;
}
}
// Non-blocking pull from AudioHandler (same pattern as iOS VPIO).
match self.handler.try_lock() {
Ok(mut h) => {
let _ = h.fill_buffer(&mut scratch[..needed]);
}
Err(std::sync::TryLockError::WouldBlock) => {
// scratch already zeroed above.
}
Err(std::sync::TryLockError::WouldBlock) => {}
Err(std::sync::TryLockError::Poisoned(e)) => {
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {}", e);
warn!(
target: "chanora_audio",
"AudioHandler mutex poisoned: {}",
e
);
}
}
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
@@ -243,6 +542,19 @@ impl AudioOutputCallback for OutputCallback {
gain,
muted,
);
// Write the first 10 ms of render audio into the reference
// buffer for the capture-side AEC.
let mono_n = needed / 2;
let render_n = mono_n.min(crate::frame::FRAME_10MS_SAMPLES);
let mut ref_frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
for (i, chunk) in scratch[..render_n * 2].chunks_exact(2).enumerate() {
if i >= render_n {
break;
}
ref_frame[i] = (chunk[0] + chunk[1]) * 0.5;
}
self.render_reference.write(&ref_frame);
}));
DataCallbackResult::Continue
}
@@ -273,7 +585,7 @@ pub struct VoiceAudioParams {
pub frames_sent: Arc<AtomicU32>,
/// Pre-encode amplitude scale (1.0 = unity).
pub mic_gain: f32,
/// AudioHandler that inbound解码+混合 feeds into; the Oboe output
/// AudioHandler that inbound decode+mix feeds into; the Oboe output
/// callback pulls mixed stereo f32 from it.
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
@@ -281,6 +593,14 @@ pub struct VoiceAudioParams {
pub output_gain: Arc<AtomicU32>,
/// True = output silence regardless of incoming voice frames.
pub output_muted: Arc<AtomicBool>,
/// Optional TransmitModeSelector for VoiceActivity transmit mode.
/// The capture callback calls set_voice_activity_open on this when
/// VAD detects speech. None means VoiceActivity mode is disabled.
pub voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
/// Shared audio-processing config (WebRTC APM flags, VAD backend).
pub audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
/// Shared audio-processing statistics for diagnostics.
pub audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
}
// --- The backend itself ------------------------------------------
@@ -334,22 +654,30 @@ impl AndroidVoiceUnit {
) -> Result<Self, BackendError> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
// SDD-120: build the capture state that the Oboe input callback
// will own via Arc<Mutex>. Same Opus VoIP tuning as iOS and
// desktop (32 kbps, complexity 10, inband FEC, 5 % PLC).
// Shared render-reference buffer (for AEC). The output
// callback writes; the capture callback reads.
let render_ref_buf = RenderReferenceBuffer::new();
let render_ref_for_capture = render_ref_buf.clone();
// Clone the APM config Arc before params is partially moved
// into the capture state constructor below.
let apm_config_clone = params.audio_processing_config.clone();
let capture_state = Arc::new(Mutex::new(
AndroidCaptureState::new(
params.voice_out_tx,
params.transmit_active,
params.output_muted.clone(),
params.frames_sent,
params.mic_gain,
params.voice_activity_selector,
params.audio_processing_config,
params.audio_processing_stats,
render_ref_for_capture,
)
.map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?,
));
// Scratch buffer for the output callback (realtime-safe
// pre-allocation). 8192 floats covers the largest practical
// burst size at 48 kHz with headroom.
let scratch = Arc::new(Mutex::new(Vec::with_capacity(8192)));
// --- Open input stream (SDD-112) ---------------------------
@@ -423,13 +751,19 @@ impl AndroidVoiceUnit {
.as_mut()
.map(|s| s.get_frames_per_burst())
.unwrap_or(0);
let session_id = None;
warn!(
target: "chanora_audio",
"android: oboe-rs get_session_id is skipped because oboe 0.6.1 \
panics on some Android allocated-session values; hardware \
effects are disabled for this stream"
);
// The oboe-rs fork (edisonjwa/oboe-rs 0.6.2) fixes the
// get_session_id() panic with unwrap_or_default(), but the
// SessionId enum still only models builder parameters (None =
// -1, Allocate = 0). The actual system audio session ID (>0)
// written by AAudio to mSessionId after stream open cannot be
// expressed in the current enum; get_raw_session_id() is a
// follow-up addition to the fork.
//
// For now: hardware effects require the system session ID.
// WebRTC APM software processing handles AEC/NS/AGC/HPF.
let session_id: Option<i32> = input_stream
.as_ref()
.and_then(|s| s.get_raw_session_id());
// --- Open output stream (SDD-112) --------------------------
let output_builder = AudioStreamBuilder::default()
@@ -450,12 +784,14 @@ impl AndroidVoiceUnit {
.set_usage(Usage::VoiceCommunication)
.set_content_type(oboe::ContentType::Speech);
let render_ref_for_output = render_ref_buf.clone();
let output_cb = OutputCallback {
handler: params.handler.clone(),
output_gain: params.output_gain.clone(),
output_muted: params.output_muted.clone(),
event_tx: event_tx.clone(),
scratch: scratch.clone(),
render_reference: render_ref_for_output,
};
let output_builder = output_builder.set_callback(output_cb);
@@ -474,6 +810,7 @@ impl AndroidVoiceUnit {
params.output_gain.clone(),
params.output_muted.clone(),
scratch.clone(),
render_ref_buf,
)?
}
};
@@ -511,6 +848,52 @@ impl AndroidVoiceUnit {
HardwareEffectHandles::default()
};
// --- SDD-113 config resolution: hardware-available? -------
// Android gives the user a choice between hardware (JNI) and
// software (WebRTC APM) effects. The AudioProcessingConfig's
// EffectOwner fields encode that choice:
// Platform → prefer hardware; software fallback if missing
// WebrtcApm → always software WebRTC APM
// Off → disable effect entirely
//
// Here we resolve Platform → WebrtcApm for each effect whose
// hardware binding failed (or wasn't attempted). This is read
// by the capture callback's WebRtcApmProcessor.
{
use crate::audio_processing::EffectOwner;
let mut apm_cfg = apm_config_clone.lock().unwrap();
let hw_aec = hw_effects.aec.is_some();
let hw_ns = hw_effects.ns.is_some();
let hw_agc = hw_effects.agc.is_some();
if apm_cfg.aec == EffectOwner::Platform && !hw_aec {
apm_cfg.aec = EffectOwner::WebrtcApm;
}
if apm_cfg.ns == EffectOwner::Platform && !hw_ns {
apm_cfg.ns = EffectOwner::WebrtcApm;
}
if apm_cfg.agc == EffectOwner::Platform && !hw_agc {
apm_cfg.agc = EffectOwner::WebrtcApm;
}
if apm_cfg.processing_backend
== crate::audio_processing::AudioBackend::PlatformVoiceProcessing
&& (!hw_aec || !hw_ns || !hw_agc)
{
apm_cfg.processing_backend = crate::audio_processing::AudioBackend::WebrtcApm;
}
info!(
target: "chanora_audio",
aec = ?apm_cfg.aec,
ns = ?apm_cfg.ns,
agc = ?apm_cfg.agc,
hpf = apm_cfg.hpf_enabled,
hw_aec,
hw_ns,
hw_agc,
session_id,
"android: audio processing config resolved (hardware effects: aec={hw_aec} ns={hw_ns} agc={hw_agc})"
);
}
// --- SDD-112 item 10 / SDD-113 item 7 / SDD-116 item 3 ---
// Publish the diagnostics snapshot. Per-effect engagement is
// derived from (a) the JNI handle (`Hardware`) or (b) the
@@ -647,6 +1030,7 @@ impl AndroidVoiceUnit {
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
scratch: Arc<Mutex<Vec<f32>>>,
render_reference: Arc<RenderReferenceBuffer>,
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
let cb = OutputCallback {
handler,
@@ -654,6 +1038,7 @@ impl AndroidVoiceUnit {
output_muted,
event_tx: event_tx.clone(),
scratch,
render_reference,
};
let builder = AudioStreamBuilder::default()
.set_direction::<OboeOutput>()
@@ -806,6 +1191,7 @@ fn perf_from_oboe(p: PerformanceMode) -> AchievedPerformanceMode {
match p {
PerformanceMode::LowLatency => AchievedPerformanceMode::LowLatency,
PerformanceMode::PowerSaving => AchievedPerformanceMode::PowerSaving,
PerformanceMode::PowerSavingOffloaded => AchievedPerformanceMode::PowerSaving,
PerformanceMode::None => AchievedPerformanceMode::None,
}
}
@@ -1041,6 +1427,51 @@ fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) {
}
}
// --- Process-global BackendEvent sender for JNI callbacks --------
//
// Kotlin-side listeners (audio focus, Bluetooth SCO, device route
// changes) need to publish events into the Rust engine's event
// channel. Since the engine's `BackendEventTx` is created at voice
// start, we store it here as a process-global so the JNI callbacks
// can reach it without holding a direct Rust reference.
//
// Cleared on voice stop; the Kotlin listeners are idempotent when
// no sender is registered (they log and continue).
static GLOBAL_BACKEND_EVENT_TX: std::sync::OnceLock<
std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<BackendEvent>>>,
> = std::sync::OnceLock::new();
fn global_event_tx_slot(
) -> &'static std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<BackendEvent>>> {
GLOBAL_BACKEND_EVENT_TX.get_or_init(|| std::sync::Mutex::new(None))
}
pub(crate) fn register_global_event_sender(tx: BackendEventTx) {
if let Ok(mut g) = global_event_tx_slot().lock() {
*g = Some(tx);
}
}
pub(crate) fn clear_global_event_sender() {
if let Ok(mut g) = global_event_tx_slot().lock() {
*g = None;
}
}
fn try_send_backend_event(event: BackendEvent) {
if let Ok(g) = global_event_tx_slot().lock() {
if let Some(tx) = g.as_ref() {
if tx.send(event).is_err() {
warn!(
target: "chanora_audio",
"android: global BackendEvent channel closed; event dropped"
);
}
}
}
}
// --- SDD-115 foreground-service JNI helpers ----------------------
//
// The Kotlin class `AndroidVoiceForegroundService` (Wave 2B-2)
@@ -1174,3 +1605,158 @@ fn load_app_class<'local>(
}
}
}
// --- SDD-109 / SDD-110 JNI callbacks: focus + SCO events ----------
//
// Kotlin-side OnAudioFocusChangeListener and BroadcastReceiver for
// ACTION_SCO_AUDIO_STATE_UPDATED call these Rust entry points via
// JNI. Each function marshals the platform event into a BackendEvent
// and posts it through the global event sender registered by the
// engine at voice start.
//
// SDD-115 callback safety: every entry point is wrapped in
// catch_unwind so a panic in the Rust engine can never unwind
// into the JVM.
/// SDD-109: audio focus change published by Kotlin's
/// `AndroidAudioFocusController`. `state` is the `focusChange`
/// value from `OnAudioFocusChangeListener`.
///
/// Symbol naming: JNI function declared in
/// `app.chanora.chanora_flutter.AndroidAudioFocusController`.
#[no_mangle]
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
'local,
>(
_env: jni::JNIEnv<'local>,
_class: jni::objects::JClass<'local>,
state: jni::sys::jint,
) {
let _ = catch_unwind(AssertUnwindSafe(|| {
// AUDIOFOCUS_LOSS = -1, LOSS_TRANSIENT = -2, LOSS_TRANSIENT_CAN_DUCK = -3,
// GAIN = 1 (AudioManager.AUDIOFOCUS_REQUEST_GRANTED is also 1, but we only
// call this from the listener callback so the values are well-known).
let event = match state {
-1 => BackendEvent::FocusLost,
-2 => BackendEvent::FocusTransient,
-3 => BackendEvent::FocusTransientCanDuck,
1 | 2 | 3 | 4 => BackendEvent::FocusGain,
_ => {
warn!(
target: "chanora_audio",
state,
"android: unknown audio focus change value; treating as FocusLost"
);
BackendEvent::FocusLost
}
};
try_send_backend_event(event);
}));
}
/// SDD-110: Bluetooth SCO state change published by Kotlin's
/// `AndroidBluetoothScoController`. `state` is the `STATE`
/// value from `ACTION_SCO_AUDIO_STATE_UPDATED`:
/// - `ACTION_SCO_AUDIO_STATE_UPDATED` is always fired with `EXTRA_SCO_AUDIO_STATE`
/// - `SCO_STATE_CONNECTING = 0`, `SCO_STATE_CONNECTED = 1`, `SCO_STATE_DISCONNECTED = 2`
///
/// Symbol naming: JNI function declared in
/// `app.chanora.chanora_flutter.AndroidBluetoothScoController`.
#[no_mangle]
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
'local,
>(
_env: jni::JNIEnv<'local>,
_class: jni::objects::JClass<'local>,
state: jni::sys::jint,
) {
let _ = catch_unwind(AssertUnwindSafe(|| {
try_send_backend_event(BackendEvent::BluetoothScoStateChanged(state));
}));
}
/// SDD-115 integration: start the Android audio focus listener.
/// Called by the engine after the voice unit is started. Uses JNI
/// to invoke `AndroidAudioFocusController.start(Context)`.
pub fn chanora_android_request_audio_focus() -> bool {
call_static_void_context(
"app/chanora/chanora_flutter/AndroidAudioFocusController",
"start",
)
}
/// SDD-115 integration: stop the Android audio focus listener.
/// Called by the engine on voice stop.
pub fn chanora_android_abandon_audio_focus() -> bool {
call_static_void_context(
"app/chanora/chanora_flutter/AndroidAudioFocusController",
"stop",
)
}
/// SDD-115 integration: start Bluetooth SCO.
/// Called by the engine after the voice unit is started.
pub fn chanora_android_start_bluetooth_sco() -> bool {
call_static_void_context(
"app/chanora/chanora_flutter/AndroidBluetoothScoController",
"start",
)
}
/// SDD-115 integration: stop Bluetooth SCO.
/// Called by the engine on voice stop.
pub fn chanora_android_stop_bluetooth_sco() -> bool {
call_static_void_context(
"app/chanora/chanora_flutter/AndroidBluetoothScoController",
"stop",
)
}
fn call_static_void_context(fqcn: &str, method: &str) -> bool {
use jni::objects::{JObject, JValue};
let ctx = ndk_context::android_context();
if ctx.vm().is_null() || ctx.context().is_null() {
warn!(
target: "chanora_audio",
class = fqcn,
method,
"android: ndk_context not initialised; call skipped"
);
return false;
}
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
Ok(v) => v,
Err(e) => {
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: JavaVM::from_raw failed");
return false;
}
};
let mut env = match jvm.attach_current_thread() {
Ok(e) => e,
Err(e) => {
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: attach_current_thread failed");
return false;
}
};
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
let class = match load_app_class(&mut env, &context_obj, fqcn) {
Some(c) => c,
None => return false,
};
match env.call_static_method(
&class,
method,
"(Landroid/content/Context;)V",
&[JValue::Object(&context_obj)],
) {
Ok(_) => {
info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
true
}
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
false
}
}
}
+24 -20
View File
@@ -58,7 +58,7 @@ impl AudioRoute {
pub enum IosVoiceProcessingMode {
/// Shipping default: Apple VoiceProcessingIO owns AEC/NS/AGC.
PlatformVoiceProcessing,
/// Experimental Sonora capture-processing path.
/// Experimental raw capture-processing path.
SonoraExperimental,
}
@@ -69,7 +69,7 @@ pub enum AudioBackend {
PlatformVoiceProcessing,
/// Rust-native Sonora backend.
Sonora,
/// Future WebRTC APM backend.
/// WebRTC Audio Processing Module backend.
WebrtcApm,
/// No processing.
Noop,
@@ -168,9 +168,9 @@ impl Default for AudioProcessingConfig {
route: AudioRoute::Speaker,
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
processing_backend: AudioBackend::PlatformVoiceProcessing,
vad_backend: VadBackend::SileroOnnx,
vad_backend: VadBackend::TenVad,
aec: EffectOwner::Platform,
// iOS VPIO owns NS/AGC on the default shipping path. Rust/Sonora
// iOS VPIO owns NS/AGC on the default shipping path. Software
// effects are opt-in through the experimental raw route only.
ns: EffectOwner::Platform,
agc: EffectOwner::Platform,
@@ -194,19 +194,23 @@ impl AudioProcessingConfig {
}
if self.ios_mode == IosVoiceProcessingMode::PlatformVoiceProcessing
&& (self.processing_backend == AudioBackend::Sonora
|| self.processing_backend == AudioBackend::WebrtcApm
|| self.aec == EffectOwner::Sonora
|| self.aec == EffectOwner::WebrtcApm
|| self.ns == EffectOwner::Sonora
|| self.agc == EffectOwner::Sonora)
|| self.ns == EffectOwner::WebrtcApm
|| self.agc == EffectOwner::Sonora
|| self.agc == EffectOwner::WebrtcApm)
{
return Err(AudioError::InvalidAudioProcessingConfig(
"Sonora cannot be enabled with iOS VoiceProcessingIO".to_string(),
"software audio processing cannot be enabled with iOS VoiceProcessingIO"
.to_string(),
));
}
if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental {
if self.processing_backend != AudioBackend::Sonora {
if self.processing_backend != AudioBackend::WebrtcApm {
return Err(AudioError::InvalidAudioProcessingConfig(
"ios Sonora experimental mode requires the Sonora processing backend"
.to_string(),
"ios raw processing mode requires the WebRTC APM backend".to_string(),
));
}
}
@@ -246,9 +250,9 @@ mod tests {
}
#[test]
fn platform_voice_processing_rejects_sonora_effects() {
fn platform_voice_processing_rejects_software_effects() {
let config = AudioProcessingConfig {
ns: EffectOwner::Sonora,
ns: EffectOwner::WebrtcApm,
..AudioProcessingConfig::default()
};
@@ -261,13 +265,13 @@ mod tests {
}
#[test]
fn sonora_experimental_allows_full_sonora_chain() {
fn raw_processing_allows_full_webrtc_apm_chain() {
let config = AudioProcessingConfig {
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
processing_backend: AudioBackend::Sonora,
aec: EffectOwner::Sonora,
ns: EffectOwner::Sonora,
agc: EffectOwner::Sonora,
processing_backend: AudioBackend::WebrtcApm,
aec: EffectOwner::WebrtcApm,
ns: EffectOwner::WebrtcApm,
agc: EffectOwner::WebrtcApm,
..AudioProcessingConfig::default()
};
@@ -275,13 +279,13 @@ mod tests {
}
#[test]
fn sonora_experimental_rejects_non_sonora_backend() {
fn raw_processing_rejects_non_webrtc_apm_backend() {
let config = AudioProcessingConfig {
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
processing_backend: AudioBackend::PlatformVoiceProcessing,
aec: EffectOwner::Sonora,
ns: EffectOwner::Sonora,
agc: EffectOwner::Sonora,
aec: EffectOwner::WebrtcApm,
ns: EffectOwner::WebrtcApm,
agc: EffectOwner::WebrtcApm,
..AudioProcessingConfig::default()
};
+76 -10
View File
@@ -310,14 +310,14 @@ fn open_ios_voice_backend(
audio_processing_stats.clone(),
) {
Ok(unit) => {
info!(target: "chanora_audio", "ios: RemoteIO/Sonora experimental backend selected");
info!(target: "chanora_audio", "ios: RemoteIO/WebRTC APM backend selected");
return Ok(IosVoiceBackend::Raw(unit));
}
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
"ios: RemoteIO/Sonora backend failed; falling back to VoiceProcessingIO"
"ios: RemoteIO/WebRTC APM backend failed; falling back to VoiceProcessingIO"
);
}
}
@@ -724,6 +724,9 @@ impl AudioEngine {
handler: audio_handler.clone(),
output_gain: output_gain.clone(),
output_muted: output_muted.clone(),
voice_activity_selector: cfg.voice_activity_selector.clone(),
audio_processing_config: audio_processing_config.clone(),
audio_processing_stats: audio_processing_stats.clone(),
};
let mut android_voice_unit =
crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params).map_err(|e| {
@@ -739,16 +742,75 @@ impl AudioEngine {
if let Some(mut event_rx) = android_voice_unit.take_event_rx() {
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if let BackendEvent::Disconnected = event {
warn!(
target: "chanora_audio",
"android: backend disconnected event received; stream reconnect requires session restart"
);
match event {
BackendEvent::Disconnected => {
warn!(
target: "chanora_audio",
"android: backend disconnected event received; stream reconnect requires session restart"
);
}
BackendEvent::FocusLost => {
warn!(
target: "chanora_audio",
"android: audio focus lost permanently (SDD-109); engine should leave session"
);
}
BackendEvent::FocusTransient => {
info!(
target: "chanora_audio",
"android: transient audio focus loss (SDD-109); pausing capture"
);
}
BackendEvent::FocusTransientCanDuck => {
info!(
target: "chanora_audio",
"android: transient audio focus loss with ducking (SDD-109); continuing"
);
}
BackendEvent::FocusGain => {
info!(
target: "chanora_audio",
"android: audio focus regained (SDD-109); resuming capture"
);
}
BackendEvent::BluetoothScoStateChanged(s) => {
info!(
target: "chanora_audio",
sco_state = s,
"android: Bluetooth SCO state changed (SDD-110)"
);
}
}
}
});
}
crate::android_voice_unit::register_global_event_sender(android_voice_unit.event_sender());
if crate::android_voice_unit::chanora_android_request_audio_focus() {
info!(
target: "chanora_audio",
"android: audio focus requested (SDD-109)"
);
} else {
warn!(
target: "chanora_audio",
"android: audio focus request failed; engine operates without focus (SDD-109)"
);
}
if crate::android_voice_unit::chanora_android_start_bluetooth_sco() {
info!(
target: "chanora_audio",
"android: Bluetooth SCO started (SDD-110)"
);
} else {
warn!(
target: "chanora_audio",
"android: Bluetooth SCO start failed; BT HFP may not route correctly (SDD-110)"
);
}
let capture_active = true;
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
@@ -949,13 +1011,17 @@ impl AudioEngine {
let _ = self._ios_voice_backend.lock().unwrap().take();
}
// SDD-115 reverse-order teardown on Android:
// 1) close the voice unit (releases SDD-113 hardware
// 1) stop Bluetooth SCO + abandon audio focus;
// 2) close the voice unit (releases SDD-113 hardware
// effects then stops + closes the Oboe streams);
// 2) restore the prior audio mode (SDD-108 §1 on 1 → 0
// 3) restore the prior audio mode (SDD-108 §1 on 1 → 0
// transition);
// 3) stop the foreground service.
// 4) stop the foreground service.
#[cfg(target_os = "android")]
{
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
crate::android_voice_unit::chanora_android_abandon_audio_focus();
crate::android_voice_unit::clear_global_event_sender();
if let Some(mut unit) = self._android_voice_unit.lock().unwrap().take() {
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
if let Err(e) = unit.close() {
+132 -27
View File
@@ -1,15 +1,15 @@
//! Optional raw iOS RemoteIO path for the Sonora experimental mode.
//! Optional raw iOS RemoteIO path for the WebRTC APM experimental mode.
//!
//! Provides an alternative to `ios_voice_unit.rs` for the
//! `SonoraExperimental` processing mode. Instead of
//! `kAudioUnitSubType_VoiceProcessingIO` (which owns AEC/NS/AGC), it
//! opens `kAudioUnitSubType_RemoteIO` with voice processing explicitly
//! disabled so Rust's Sonora DSP chain can own the full signal path.
//! disabled so WebRTC APM can own the full signal path.
//!
//! ## Hard invariants enforced here
//!
//! * INV_009: Rust AEC only active when platform AEC is disabled.
//! * INV_010: VoiceProcessingIO and Sonora AEC3 are mutually exclusive.
//! * INV_010: VoiceProcessingIO and WebRTC APM AEC are mutually exclusive.
//! * INV_011: Software AEC backend receives both capture and render-reference.
//! * INV_012: Render reference is copied from decoded/mixed remote PCM
//! before playout.
@@ -112,19 +112,25 @@ mod inner {
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
vad_detector: crate::vad::WebRtcFallbackVad,
silero_vad_worker: Option<crate::vad::silero_onnx::SileroOnnxVadWorker>,
ten_vad_worker: Option<crate::vad::TenOnnxVadWorker>,
current_vad_backend: crate::VadBackend,
silero_model_epoch: u64,
capture_frame_seq: u64,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
/// Processing config — retained for route-change reloads; not read in the hot path.
#[allow(dead_code)]
/// Processing config — retained for route-change reloads.
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
sonora_processor: crate::processor::SonoraProcessor,
webrtc_apm_processor: crate::processor::WebRtcApmProcessor,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
render_reference: Arc<RenderReferenceBuffer>,
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: usize,
fallback_warned_backend: Option<crate::VadBackend>,
wav_recorder: Option<Arc<crate::debug_wav::WavDebugRecorder>>,
}
@@ -132,6 +138,7 @@ mod inner {
fn new(
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
@@ -140,32 +147,48 @@ mod inner {
render_reference: Arc<RenderReferenceBuffer>,
) -> Result<Self, AudioError> {
let encoder = crate::opus_voice::new_voip_encoder("ios raw")?;
let webrtc_apm_config = audio_processing_config
.lock()
.map(|cfg| crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(&cfg))
.unwrap_or_default();
Ok(Self {
encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
output_muted,
frames_sent,
mic_gain,
voice_activity_selector,
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_vad_worker: None,
ten_vad_worker: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
silero_model_epoch: crate::vad::silero_model_epoch(),
capture_frame_seq: 0,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
audio_processing_config,
sonora_processor: crate::processor::SonoraProcessor::with_config(
crate::processor::sonora::SonoraConfig::with_aec3(),
),
webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
webrtc_apm_config,
)?,
audio_processing_stats,
render_reference,
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: 0,
fallback_warned_backend: None,
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 mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
if self.fallback_warned_backend != Some(failed_backend) {
self.fallback_warned_backend = Some(failed_backend);
tracing::warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"VAD backend unavailable; using WebRTC fallback for runtime detection"
);
}
}
@@ -237,12 +260,12 @@ mod inner {
rec.push_raw_mic(&frame);
}
// INV_012: feed render reference to Sonora AEC3 before capture.
// Feed render reference to WebRTC APM before capture so AEC can adapt.
let render_ref = self.render_reference.read_latest();
self.sonora_processor.process_render(&render_ref);
self.sonora_processor.process_capture(&mut frame);
self.webrtc_apm_processor.process_render(&render_ref);
self.webrtc_apm_processor.process_capture(&mut frame);
// WAV tap: processed mic (after Sonora).
// WAV tap: processed mic (after WebRTC APM).
if let Some(ref rec) = self.wav_recorder {
rec.push_processed_mic(&frame);
}
@@ -260,37 +283,118 @@ mod inner {
vad_hangover,
crate::voice_activity::VAD_MIN_TX_MS,
);
// Switch VAD backend when config changes.
let silero_epoch = crate::vad::silero_model_epoch();
let silero_changed = vad_backend == crate::VadBackend::SileroOnnx
&& silero_epoch != self.silero_model_epoch;
if vad_backend != self.current_vad_backend || silero_changed {
self.current_vad_backend = vad_backend;
self.silero_model_epoch = silero_epoch;
self.fallback_warned_backend = None;
match vad_backend {
crate::VadBackend::SileroOnnx => {
let path = crate::vad::silero_model_bundle_path();
self.silero_vad_worker =
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path);
self.ten_vad_worker = None;
if self.silero_vad_worker.is_none() {
tracing::warn!(
target: "chanora_audio",
"Silero VAD model not found at {path}; falling back to WebRTC VAD"
);
}
}
crate::VadBackend::TenVad => {
let path = crate::vad::ten_model_bundle_path();
self.ten_vad_worker = crate::vad::TenOnnxVadWorker::try_new(&path);
self.silero_vad_worker = None;
if self.ten_vad_worker.is_none() {
tracing::warn!(
target: "chanora_audio",
"TEN VAD ONNX model not found at {path}; falling back to WebRTC VAD"
);
}
}
_ => {
self.silero_vad_worker = None;
self.ten_vad_worker = None;
}
}
self.vad_state.reset();
}
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 {
used_fallback_vad = matches!(
vad_backend,
crate::VadBackend::SileroOnnx | crate::VadBackend::TenVad
);
if used_fallback_vad {
self.disable_failed_vad_backend(vad_backend);
} else if vad_backend == crate::VadBackend::SileroOnnx {
if let Some(worker) = self.silero_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
}
} else if vad_backend == crate::VadBackend::TenVad {
if let Some(worker) = self.ten_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
}
} else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
};
self.audio_processing_stats
.set_vad_fallback_active(used_fallback_vad);
let active = self.vad_state.update(vad.speech);
let output_muted = self.output_muted.load(Ordering::Relaxed);
if let Some(sel) = &self.voice_activity_selector {
sel.set_voice_activity_open(active);
sel.set_voice_activity_open(active && !output_muted);
}
self.audio_processing_stats.update_capture(
input_dbfs,
crate::frame::dbfs(&frame),
vad.probability,
active,
active && !output_muted,
self.transmit_active.load(Ordering::Relaxed),
);
if !self.transmit_active.load(Ordering::Relaxed) {
if !self.transmit_active.load(Ordering::Relaxed) || output_muted {
return;
}
@@ -336,7 +440,7 @@ mod inner {
let cfg = audio_processing_config.lock().unwrap();
if cfg.ios_mode == crate::IosVoiceProcessingMode::PlatformVoiceProcessing {
return Err(AudioError::InvalidAudioProcessingConfig(
"IosRawUnit requires SonoraExperimental mode".to_string(),
"IosRawUnit requires raw WebRTC APM mode".to_string(),
));
}
}
@@ -369,6 +473,7 @@ mod inner {
let mut capture_state = RawCaptureState::new(
voice_out_tx,
transmit_active,
output_muted.clone(),
frames_sent,
mic_gain,
voice_activity_selector,
+59 -24
View File
@@ -139,6 +139,7 @@ struct IosCaptureState {
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
@@ -146,10 +147,12 @@ struct IosCaptureState {
/// Background Silero worker — enqueues frames off the realtime
/// callback and publishes the latest probability atomically.
silero_vad_worker: Option<crate::vad::silero_onnx::SileroOnnxVadWorker>,
ten_vad: Option<crate::vad::TenOnnxVadWorker>,
/// 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,
fallback_warned_backend: Option<crate::VadBackend>,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
sonora_processor: crate::processor::SonoraProcessor,
@@ -173,6 +176,7 @@ impl IosCaptureState {
fn new(
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
@@ -188,13 +192,16 @@ impl IosCaptureState {
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
output_muted,
frames_sent,
mic_gain,
voice_activity_selector,
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_vad_worker: None,
ten_vad: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
silero_model_epoch: crate::vad::silero_model_epoch(),
fallback_warned_backend: None,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
audio_processing_config,
sonora_processor: crate::processor::SonoraProcessor::new(),
@@ -210,12 +217,16 @@ impl IosCaptureState {
})
}
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;
}
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
if self.fallback_warned_backend == Some(failed_backend) {
return;
}
self.fallback_warned_backend = Some(failed_backend);
tracing::warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"VAD backend unavailable; using WebRTC fallback for runtime detection"
);
}
/// Consume the i16 mono buffer delivered by VPIO, accumulate
@@ -367,6 +378,7 @@ impl IosCaptureState {
if vad_backend != self.current_vad_backend || silero_model_changed {
self.current_vad_backend = vad_backend;
self.fallback_warned_backend = None;
self.silero_model_epoch = silero_model_epoch;
match vad_backend {
crate::VadBackend::SileroOnnx => {
@@ -383,22 +395,28 @@ impl IosCaptureState {
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.mark_vad_fallback_active(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);
let model_path = crate::vad::ten_model_bundle_path();
self.ten_vad = crate::vad::TenOnnxVadWorker::try_new(&model_path);
if self.ten_vad.is_none() {
warn!(
target: "chanora_audio",
"TEN VAD ONNX model not available at {model_path}; falling back to WebRTC VAD"
);
self.mark_vad_fallback_active(crate::VadBackend::TenVad);
}
self.audio_processing_stats
.set_vad_fallback_active(self.ten_vad.is_none());
}
_ => {
self.silero_vad_worker = None;
self.ten_vad = None;
self.audio_processing_stats.set_vad_fallback_active(false);
}
}
@@ -419,14 +437,14 @@ impl IosCaptureState {
);
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.
// VPIO owns AEC. Keep the legacy non-AEC conditioning path here until
// the raw WebRTC APM path is explicitly selected.
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)
aec3: false,
ns: run_ns,
agc2: run_agc,
};
@@ -448,7 +466,8 @@ impl IosCaptureState {
}
} 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 enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
let probability = worker.latest_probability();
crate::vad::VadOutput {
probability,
@@ -456,33 +475,48 @@ impl IosCaptureState {
}
} else {
used_fallback_vad = true;
self.silero_vad_worker = None;
self.disable_failed_vad_backend(crate::VadBackend::SileroOnnx);
self.mark_vad_fallback_active(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);
self.mark_vad_fallback_active(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)
if let Some(worker) = self.ten_vad.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
let probability = worker.latest_probability();
crate::vad::VadOutput {
probability,
speech: probability >= 0.5,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(crate::VadBackend::TenVad);
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(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);
let output_muted = self.output_muted.load(Ordering::Relaxed);
if let Some(selector) = &self.voice_activity_selector {
selector.set_voice_activity_open(gate_open);
selector.set_voice_activity_open(gate_open && !output_muted);
}
self.audio_processing_stats.update_capture(
input_dbfs,
crate::frame::dbfs(&frame),
vad.probability,
gate_open,
gate_open && !output_muted,
transmit_active,
);
@@ -700,6 +734,7 @@ impl IosVoiceUnit {
let mut capture_state = IosCaptureState::new(
voice_out_tx,
transmit_active,
output_muted.clone(),
frames_sent,
mic_gain,
voice_activity_selector,
@@ -4,10 +4,12 @@ pub mod dsp;
pub mod noop;
pub mod platform;
pub mod sonora;
pub mod webrtc_apm;
pub use noop::NoopProcessor;
pub use platform::PlatformVoiceProcessor;
pub use sonora::SonoraProcessor;
pub use webrtc_apm::WebRtcApmProcessor;
/// 10 ms mono f32 processing frame at 48 kHz (480 samples).
pub const FRAME_SAMPLES: usize = 480;
@@ -0,0 +1,193 @@
//! WebRTC Audio Processing Module backend.
//!
//! This backend delegates capture-side voice processing to WebRTC APM
//! instead of Chanora's experimental Rust DSP chain. Frames are 10 ms,
//! mono, 48 kHz f32, matching the rest of the voice pipeline.
use sonora::config::{
AdaptiveDigital, EchoCanceller, GainController2, HighPassFilter, NoiseSuppression,
NoiseSuppressionLevel, Pipeline,
};
use sonora::{AudioProcessing, Config, StreamConfig};
use super::{AudioProcessor, FRAME_SAMPLES};
/// Per-module WebRTC APM enable flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WebRtcApmConfig {
/// Acoustic echo cancellation.
pub aec: bool,
/// Automatic gain control.
pub agc: bool,
/// High-pass filter.
pub hpf: bool,
/// Noise suppression.
pub ns: bool,
/// WebRTC VAD is available for transmit gating.
pub vad: bool,
}
impl WebRtcApmConfig {
/// Resolve WebRTC APM flags from the shared audio-processing config.
pub fn from_audio_config(config: &crate::AudioProcessingConfig) -> Self {
Self {
aec: config.aec == crate::EffectOwner::WebrtcApm,
agc: matches!(
config.agc,
crate::EffectOwner::WebrtcApm | crate::EffectOwner::Conservative
),
hpf: config.hpf_enabled,
ns: matches!(
config.ns,
crate::EffectOwner::WebrtcApm | crate::EffectOwner::Conservative
),
vad: config.vad_backend == crate::VadBackend::WebrtcVad,
}
}
fn to_webrtc_config(self) -> Config {
Config {
pipeline: Pipeline {
maximum_internal_processing_rate: sonora::config::MaxProcessingRate::Rate48kHz,
..Default::default()
},
high_pass_filter: self.hpf.then_some(HighPassFilter::default()),
echo_canceller: self.aec.then_some(EchoCanceller::default()),
noise_suppression: self.ns.then_some(NoiseSuppression {
level: NoiseSuppressionLevel::Moderate,
analyze_linear_aec_output_when_available: false,
}),
gain_controller2: self.agc.then_some(GainController2 {
input_volume_controller: false,
adaptive_digital: Some(AdaptiveDigital::default()),
fixed_digital: Default::default(),
}),
..Default::default()
}
}
/// Stable runtime-log summary.
pub fn summary(self) -> String {
format!(
"aec={} agc={} hpf={} ns={} vad={}",
self.aec, self.agc, self.hpf, self.ns, self.vad
)
}
}
impl Default for WebRtcApmConfig {
fn default() -> Self {
Self {
aec: true,
agc: true,
hpf: true,
ns: true,
vad: true,
}
}
}
/// WebRTC APM processor with preallocated render scratch.
pub struct WebRtcApmProcessor {
processor: AudioProcessing,
config: WebRtcApmConfig,
capture_scratch: [f32; FRAME_SAMPLES],
render_scratch: [f32; FRAME_SAMPLES],
}
impl WebRtcApmProcessor {
/// Construct with a specific module configuration.
pub fn with_config(config: WebRtcApmConfig) -> Result<Self, crate::AudioError> {
let stream_config = StreamConfig::new(crate::frame::SAMPLE_RATE_HZ, 1);
let processor = AudioProcessing::builder()
.config(config.to_webrtc_config())
.capture_config(stream_config)
.render_config(stream_config)
.echo_detector(config.aec)
.build();
tracing::info!(
target: "chanora_audio",
modules = %config.summary(),
"WebRTC APM processor active"
);
Ok(Self {
processor,
config,
capture_scratch: [0.0; FRAME_SAMPLES],
render_scratch: [0.0; FRAME_SAMPLES],
})
}
/// Current module configuration.
pub fn config(&self) -> WebRtcApmConfig {
self.config
}
/// Apply updated module flags outside the realtime callback.
pub fn apply_config(&mut self, config: WebRtcApmConfig) {
if config == self.config {
return;
}
self.processor.apply_config(config.to_webrtc_config());
self.config = config;
tracing::info!(
target: "chanora_audio",
modules = %config.summary(),
"WebRTC APM processor reconfigured"
);
}
}
impl AudioProcessor for WebRtcApmProcessor {
fn process_capture(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
let channels = [&frame[..]];
let mut out = [&mut self.capture_scratch[..]];
if let Err(error) = self.processor.process_capture_f32(&channels, &mut out) {
tracing::trace!(target: "chanora_audio", %error, "WebRTC APM capture frame skipped");
} else {
frame.copy_from_slice(&self.capture_scratch);
}
}
fn process_render(&mut self, frame: &[f32; FRAME_SAMPLES]) {
let channels = [&frame[..]];
let mut out = [&mut self.render_scratch[..]];
if let Err(error) = self.processor.process_render_f32(&channels, &mut out) {
tracing::trace!(target: "chanora_audio", %error, "WebRTC APM render frame skipped");
}
}
fn has_aec(&self) -> bool {
self.config.aec
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_enables_all_modules() {
let cfg = WebRtcApmConfig::default();
assert!(cfg.aec && cfg.agc && cfg.hpf && cfg.ns && cfg.vad);
}
#[test]
fn process_silence_is_stable() {
let mut processor = WebRtcApmProcessor::with_config(WebRtcApmConfig {
aec: false,
agc: true,
hpf: true,
ns: true,
vad: true,
})
.expect("webrtc apm init");
let render = [0.0_f32; FRAME_SAMPLES];
let mut capture = [0.0_f32; FRAME_SAMPLES];
processor.process_render(&render);
processor.process_capture(&mut capture);
assert!(capture.iter().all(|s| s.is_finite()));
}
}
+124 -75
View File
@@ -7,15 +7,17 @@
pub mod resampler;
pub mod silero_onnx;
pub mod ten_onnx;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{OnceLock, RwLock};
use crate::frame::{dbfs, i16_to_f32};
use crate::frame::{f32_to_i16, i16_to_f32};
use crate::AudioError;
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
pub use silero_onnx::SileroOnnxVad;
pub use ten_onnx::{TenOnnxVad, TenOnnxVadWorker};
/// Voice activity detector output for one 10 ms frame.
#[derive(Debug, Clone, Copy)]
@@ -32,83 +34,38 @@ pub trait VoiceActivityDetector: Send {
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)]
/// Realtime-safe WebRTC VAD used when a model runtime is unavailable.
pub struct WebRtcFallbackVad {
open_dbfs: f32,
close_dbfs: f32,
active: bool,
vad: webrtc_vad::Vad,
frame_i16: [i16; INPUT_FRAME_10MS],
}
// `webrtc_vad::Vad` owns an FFI pointer and is only touched from the
// capture thread after construction. Moving the wrapper between threads is
// safe; sharing it concurrently is not required and not implemented.
unsafe impl Send for WebRtcFallbackVad {}
impl Default for WebRtcFallbackVad {
fn default() -> Self {
Self {
open_dbfs: -42.0,
close_dbfs: -50.0,
active: false,
vad: webrtc_vad::Vad::new_with_rate_and_mode(
webrtc_vad::SampleRate::Rate48kHz,
webrtc_vad::VadMode::Aggressive,
),
frame_i16: [0; INPUT_FRAME_10MS],
}
}
}
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;
for (dst, src) in self.frame_i16.iter_mut().zip(samples.iter().copied()) {
*dst = f32_to_i16(src);
}
let speech = self.vad.is_voice_segment(&self.frame_i16).unwrap_or(false);
VadOutput {
probability,
speech: self.active,
probability: if speech { 1.0 } else { 0.0 },
speech,
}
}
}
@@ -151,11 +108,17 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
static TEN_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
static TEN_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))
}
fn ten_model_path_override() -> &'static RwLock<Option<String>> {
TEN_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
@@ -186,12 +149,39 @@ 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.
/// Configure the preferred TEN VAD ONNX model path.
pub fn set_ten_model_path(path: &str) -> Result<(), AudioError> {
let path = path.trim();
if path.is_empty() {
return Err(AudioError::InvalidAudioProcessingConfig(
"ten vad model path must not be empty".to_string(),
));
}
if !std::path::Path::new(path).is_file() {
return Err(AudioError::InvalidAudioProcessingConfig(format!(
"ten vad model path does not exist or is not a file: {path}"
)));
}
let mut guard = ten_model_path_override()
.write()
.map_err(|_| AudioError::Backend("ten vad model path lock poisoned".to_string()))?;
*guard = Some(path.to_string());
TEN_MODEL_EPOCH.fetch_add(1, Ordering::Relaxed);
Ok(())
}
/// Monotonic counter incremented whenever the configured TEN model path changes.
pub fn ten_model_epoch() -> u64 {
TEN_MODEL_EPOCH.load(Ordering::Relaxed)
}
/// Return the expected path of the Silero VAD v6 ONNX model.
/// The model is shipped as a Flutter asset and copied to the app's
/// data 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`).
/// On iOS/Android the model lives in the app's Documents/files
/// directory. On desktop, the caller should set the path explicitly
/// via `set_silero_model_path`.
pub fn silero_model_bundle_path() -> String {
if let Ok(guard) = silero_model_path_override().read() {
if let Some(path) = guard.as_ref() {
@@ -199,26 +189,85 @@ pub fn silero_model_bundle_path() -> String {
}
}
// iOS: Documents directory (written by Flutter asset loader).
// macOS: same Documents pattern.
#[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")))]
// Android: the model is in the app's files directory, same
// Documents path pattern used by Flutter's path_provider.
#[cfg(target_os = "android")]
{
String::new()
// On Android, Flutter's getApplicationDocumentsDirectory
// resolves to /data/data/<package>/app_flutter.
// The Silero model path is set explicitly via
// set_silero_model_path from Dart before voice starts,
// so this fallback is rarely needed.
"silero_vad.onnx".to_string()
}
// Desktop (Windows, Linux): rely on the override set by Dart.
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
{
if let Ok(cwd) = std::env::current_dir() {
let local = cwd.join("silero_vad.onnx");
if local.exists() {
return local.to_string_lossy().to_string();
}
}
"silero_vad.onnx".to_string()
}
}
/// Return the expected path of the TEN VAD ONNX model copied by Flutter.
pub fn ten_model_bundle_path() -> String {
if let Ok(guard) = ten_model_path_override().read() {
if let Some(path) = guard.as_ref() {
return path.clone();
}
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
if let Ok(home) = std::env::var("HOME") {
let docs = format!("{home}/Documents/ten_vad.onnx");
if std::path::Path::new(&docs).exists() {
return docs;
}
let bundle = format!("{home}/../Library/ten_vad.onnx");
if std::path::Path::new(&bundle).exists() {
return bundle;
}
}
"ten_vad.onnx".to_string()
}
#[cfg(target_os = "android")]
{
"ten_vad.onnx".to_string()
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
{
if let Ok(cwd) = std::env::current_dir() {
let local = cwd.join("ten_vad.onnx");
if local.exists() {
return local.to_string_lossy().to_string();
}
}
"ten_vad.onnx".to_string()
}
}
+13 -36
View File
@@ -75,13 +75,9 @@ pub struct SileroOnnxVad {
}
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,
}
@@ -90,20 +86,11 @@ 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.
/// is unavailable, or the platform does not support ONNX.
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
}
Self::try_new_onnx(model_path)
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn try_new_onnx(model_path: &str) -> Option<Self> {
use tracing::{error, info};
@@ -116,13 +103,8 @@ impl SileroOnnxVad {
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))
ort::session::Session::builder().and_then(|mut b| b.commit_from_file(model_path))
});
match session_result {
@@ -193,7 +175,6 @@ impl SileroOnnxVad {
/// 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;
@@ -279,7 +260,7 @@ impl SileroOnnxVad {
}
#[cfg(target_os = "macos")]
fn bundled_onnxruntime_path() -> Option<std::path::PathBuf> {
pub(crate) fn bundled_onnxruntime_path_for_vad() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let app_dir = exe.parent()?;
let framework = app_dir
@@ -307,16 +288,9 @@ impl VoiceActivityDetector for SileroOnnxVad {
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")))]
{
if matches!(self.inner, SileroInner::Onnx(_)) {
self.calc_level(&audio_frame);
} else {
self.update_context_from_frame(&audio_frame);
}
// Drain the accumulator (keep any overflow for next frame).
@@ -357,9 +331,9 @@ impl SileroOnnxVadWorker {
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 latest_processed_seq = Arc::new(AtomicU64::new(u64::MAX));
let alive = Arc::new(AtomicBool::new(true));
let (tx, rx) = std::sync::mpsc::sync_channel::<SileroFrameMessage>(8);
let (tx, rx) = std::sync::mpsc::sync_channel::<SileroFrameMessage>(32);
let latest_probability_for_thread = latest_probability.clone();
let latest_processed_seq_for_thread = latest_processed_seq.clone();
let alive_for_thread = alive.clone();
@@ -414,7 +388,10 @@ impl SileroOnnxVadWorker {
/// 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
let latest = self
.latest_processed_seq
.load(std::sync::atomic::Ordering::Relaxed);
latest == u64::MAX || capture_seq.saturating_sub(latest) > SILERO_MAX_STALE_FRAMES
}
}
+432
View File
@@ -0,0 +1,432 @@
//! TEN VAD ONNX backend.
//!
//! TEN's ONNX graph does not accept raw PCM. It expects the same feature
//! stack produced by TEN's `AUP_Aed_aivad_proc`: three context frames of
//! 40 log-mel powers plus one pitch feature, followed by four recurrent
//! state tensors. This module ports that preprocessing path to Rust and
//! keeps ONNX Runtime off the realtime callback where possible.
use crate::frame::f32_to_i16;
use super::resampler::{Downsampler48to16, INPUT_FRAME_10MS};
use super::{VadOutput, VoiceActivityDetector};
const SAMPLE_RATE_16K: f32 = 16_000.0;
const HOP_16K: usize = 256;
const WINDOW_16K: usize = 768;
const FFT_SIZE: usize = 1024;
const N_BINS: usize = FFT_SIZE / 2 + 1;
const MEL_BANDS: usize = 40;
const FEATURE_LEN: usize = 41;
const CONTEXT: usize = 3;
const HIDDEN: usize = 64;
const POWER_NORMALIZER: f32 = 32768.0 * 32768.0;
const EPS: f32 = 1.0e-20;
const FEATURE_MEANS: [f32; FEATURE_LEN] = [
-8.198236, -6.2657166, -5.4838185, -4.7586913, -4.417089, -4.142893, -3.9128504, -3.845928,
-3.6570904, -3.7234187, -3.8761342, -3.843891, -3.6904051, -3.7560658, -3.6986961, -3.650463,
-3.7004688, -3.5673213, -3.4989002, -3.477807, -3.458816, -3.4449239, -3.4013286, -3.3062613,
-3.2785568, -3.2332509, -3.198616, -3.2045264, -3.2087986, -3.257838, -3.3813767, -3.5340214,
-3.640868, -3.7268589, -3.773731, -3.8046672, -3.832901, -3.8711205, -3.990593, -4.4802895,
92.3569,
];
const FEATURE_STDS: [f32; FEATURE_LEN] = [
5.166064, 4.9772096, 4.698896, 4.6306214, 4.634348, 4.641156, 4.6406765, 4.666367, 4.6505346,
4.640021, 4.6374, 4.620099, 4.5963163, 4.562655, 4.5543604, 4.5669107, 4.56249, 4.5624127,
4.5852995, 4.6001797, 4.592846, 4.5859227, 4.5834966, 4.626093, 4.626958, 4.6262894, 4.637006,
4.683016, 4.726814, 4.7342896, 4.753227, 4.849723, 4.869435, 4.884483, 4.921327, 4.9592123,
4.996619, 5.0448236, 5.072217, 5.0964394, 115.21369,
];
/// TEN VAD using ONNX Runtime and Rust-ported TEN feature preprocessing.
pub struct TenOnnxVad {
session: ort::session::Session,
downsampler: Downsampler48to16,
hop_accum: Vec<f32>,
sample_fifo: Vec<f32>,
feature_stack: [[f32; FEATURE_LEN]; CONTEXT],
states: [[f32; HIDDEN]; 4],
mel_filters: Vec<[f32; N_BINS]>,
last_probability: f32,
last_speech: bool,
}
unsafe impl Send for TenOnnxVad {}
impl TenOnnxVad {
/// Load TEN VAD ONNX model.
pub fn try_new(model_path: &str) -> Option<Self> {
if !std::path::Path::new(model_path).exists() {
tracing::warn!(target: "chanora_audio", path = model_path, "TEN VAD ONNX model not found");
return None;
}
let session = match std::panic::catch_unwind(|| {
ort::session::Session::builder().and_then(|mut b| b.commit_from_file(model_path))
}) {
Ok(Ok(session)) => session,
Ok(Err(error)) => {
tracing::warn!(target: "chanora_audio", %error, path = model_path, "TEN VAD ONNX model load failed");
return None;
}
Err(_) => {
tracing::warn!(target: "chanora_audio", path = model_path, "TEN VAD ONNX Runtime panicked during load");
return None;
}
};
tracing::info!(target: "chanora_audio", path = model_path, "TEN VAD ONNX model loaded");
Some(Self {
session,
downsampler: Downsampler48to16::default(),
hop_accum: Vec::with_capacity(HOP_16K + super::resampler::OUTPUT_FRAME_10MS),
sample_fifo: Vec::with_capacity(WINDOW_16K + HOP_16K),
feature_stack: [[0.0; FEATURE_LEN]; CONTEXT],
states: [[0.0; HIDDEN]; 4],
mel_filters: build_mel_filters(),
last_probability: 0.0,
last_speech: false,
})
}
fn process_hop(&mut self, hop: &[f32]) {
self.sample_fifo.extend_from_slice(hop);
let frame = if self.sample_fifo.len() >= WINDOW_16K {
let start = self.sample_fifo.len() - WINDOW_16K;
self.sample_fifo[start..].to_vec()
} else {
let mut padded = vec![0.0; WINDOW_16K - self.sample_fifo.len()];
padded.extend_from_slice(&self.sample_fifo);
padded
};
if self.sample_fifo.len() > WINDOW_16K {
let excess = self.sample_fifo.len() - WINDOW_16K;
self.sample_fifo.drain(..excess);
}
let feature = compute_feature(&self.mel_filters, &frame);
self.feature_stack.copy_within(1..CONTEXT, 0);
self.feature_stack[CONTEXT - 1] = feature;
self.run_onnx();
}
fn run_onnx(&mut self) {
use ndarray::{Array, IxDyn};
use ort::value::Value;
let input: Vec<f32> = self.feature_stack.iter().flatten().copied().collect();
let input_arr = match Array::from_shape_vec(IxDyn(&[1, CONTEXT, FEATURE_LEN]), input) {
Ok(v) => v,
Err(_) => return,
};
let state_arrs = [0, 1, 2, 3]
.map(|idx| Array::from_shape_vec(IxDyn(&[1, HIDDEN]), self.states[idx].to_vec()));
let input_val = match Value::from_array(input_arr) {
Ok(v) => v,
Err(error) => {
tracing::warn!(target: "chanora_audio", %error, "TEN VAD input tensor error");
return;
}
};
let state_vals = match state_arrs {
[Ok(a), Ok(b), Ok(c), Ok(d)] => [a, b, c, d],
_ => return,
};
let state_vals = match state_vals.map(Value::from_array) {
[Ok(a), Ok(b), Ok(c), Ok(d)] => [a, b, c, d],
_ => return,
};
let outputs = match self.session.run([
(&input_val).into(),
(&state_vals[0]).into(),
(&state_vals[1]).into(),
(&state_vals[2]).into(),
(&state_vals[3]).into(),
]) {
Ok(outputs) => outputs,
Err(error) => {
tracing::warn!(target: "chanora_audio", %error, "TEN VAD ONNX inference failed");
return;
}
};
if let Ok((_, prob)) = outputs["output_1"].try_extract_tensor::<f32>() {
if let Some(&p) = prob.first() {
self.last_probability = p.clamp(0.0, 1.0);
self.last_speech = self.last_probability >= 0.5;
}
}
for (idx, name) in ["output_2", "output_3", "output_6", "output_7"]
.iter()
.enumerate()
{
if let Ok((_, state)) = outputs[*name].try_extract_tensor::<f32>() {
let copy_len = state.len().min(HIDDEN);
self.states[idx][..copy_len].copy_from_slice(&state[..copy_len]);
}
}
}
}
#[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)
}
fn compute_feature(mel_filters: &[[f32; N_BINS]], frame: &[f32]) -> [f32; FEATURE_LEN] {
let power = power_spectrum(frame);
let mut feature = [0.0; FEATURE_LEN];
for band in 0..MEL_BANDS {
let energy = mel_filters[band]
.iter()
.zip(power.iter())
.map(|(w, p)| w * p)
.sum::<f32>()
/ POWER_NORMALIZER;
let log_energy = (energy + EPS).ln();
feature[band] = (log_energy - FEATURE_MEANS[band]) / (FEATURE_STDS[band] + EPS);
}
let pitch_hz = estimate_pitch_hz(frame);
feature[MEL_BANDS] = (pitch_hz - FEATURE_MEANS[MEL_BANDS]) / (FEATURE_STDS[MEL_BANDS] + EPS);
feature
}
impl VoiceActivityDetector for TenOnnxVad {
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.hop_accum.extend_from_slice(&downsampled);
while self.hop_accum.len() >= HOP_16K {
let hop: Vec<f32> = self.hop_accum[..HOP_16K].to_vec();
self.hop_accum.drain(..HOP_16K);
self.process_hop(&hop);
}
VadOutput {
probability: self.last_probability,
speech: self.last_speech,
}
}
}
fn hz_to_mel(hz: f32) -> f32 {
2595.0 * (1.0 + hz / 700.0).log10()
}
fn mel_to_hz(mel: f32) -> f32 {
700.0 * (10.0_f32.powf(mel / 2595.0) - 1.0)
}
fn build_mel_filters() -> Vec<[f32; N_BINS]> {
let low_mel = hz_to_mel(0.0);
let high_mel = hz_to_mel(8000.0);
let mut bins = [0_usize; MEL_BANDS + 2];
for idx in 0..bins.len() {
let mel = idx as f32 * (high_mel - low_mel) / (MEL_BANDS as f32 + 1.0) + low_mel;
let hz = mel_to_hz(mel);
let mut bin = ((FFT_SIZE as f32 + 1.0) * hz / SAMPLE_RATE_16K).floor() as usize;
bin = bin.min(N_BINS - 1);
if idx > 0 && bin == bins[idx - 1] {
bin = (bin + 1).min(N_BINS - 1);
}
bins[idx] = bin;
}
let mut filters = vec![[0.0_f32; N_BINS]; MEL_BANDS];
for band in 0..MEL_BANDS {
let left = bins[band];
let center = bins[band + 1].max(left + 1);
let right = bins[band + 2].max(center + 1).min(N_BINS - 1);
for i in left..center.min(N_BINS) {
filters[band][i] = (i - left) as f32 / (center - left) as f32;
}
for i in center..=right {
filters[band][i] = (right - i) as f32 / (right - center).max(1) as f32;
}
}
filters
}
fn power_spectrum(frame: &[f32]) -> [f32; N_BINS] {
let mut windowed = [0.0_f32; FFT_SIZE];
for (idx, sample) in frame.iter().take(WINDOW_16K).enumerate() {
let hann = 0.5 - 0.5 * (2.0 * std::f32::consts::PI * idx as f32 / WINDOW_16K as f32).cos();
windowed[idx] = f32_to_i16(*sample) as f32 * hann;
}
let mut out = [0.0_f32; N_BINS];
for (k, dst) in out.iter_mut().enumerate() {
let mut re = 0.0_f32;
let mut im = 0.0_f32;
for (n, &x) in windowed.iter().enumerate() {
let phase = -2.0 * std::f32::consts::PI * k as f32 * n as f32 / FFT_SIZE as f32;
re += x * phase.cos();
im += x * phase.sin();
}
*dst = re * re + im * im;
}
out
}
fn estimate_pitch_hz(frame: &[f32]) -> f32 {
let min_lag = (SAMPLE_RATE_16K / 400.0) as usize;
let max_lag = (SAMPLE_RATE_16K / 60.0) as usize;
let mut best_lag = 0_usize;
let mut best_corr = 0.0_f32;
for lag in min_lag..=max_lag.min(frame.len().saturating_sub(1)) {
let mut corr = 0.0_f32;
let mut energy = 0.0_f32;
for i in lag..frame.len() {
corr += frame[i] * frame[i - lag];
energy += frame[i - lag] * frame[i - lag];
}
let norm = if energy > 1.0e-8 {
corr / energy.sqrt()
} else {
0.0
};
if norm > best_corr {
best_corr = norm;
best_lag = lag;
}
}
if best_lag == 0 || best_corr < 0.01 {
0.0
} else {
SAMPLE_RATE_16K / best_lag as f32
}
}
// ---------------------------------------------------------------------------
// Background worker — same pattern as SileroOnnxVadWorker so the realtime
// callback never blocks on STFT / pitch / ONNX inference.
// ---------------------------------------------------------------------------
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
use std::sync::Arc;
use std::thread::JoinHandle;
/// Maximum number of 10 ms frames the worker may lag before the callback
/// treats its output as stale and uses WebRTC fallback instead.
const TEN_MAX_STALE_FRAMES: u64 = 8;
struct TenFrameMessage {
seq: u64,
frame: [f32; INPUT_FRAME_10MS],
}
/// Background TEN VAD worker. The realtime callback only enqueues 10 ms
/// frames and reads the latest probability atomically.
pub struct TenOnnxVadWorker {
tx: Option<std::sync::mpsc::SyncSender<TenFrameMessage>>,
latest_probability: Arc<AtomicU32>,
latest_processed_seq: Arc<AtomicU64>,
alive: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
}
impl TenOnnxVadWorker {
/// Start a background TEN worker if the model loads.
pub fn try_new(model_path: &str) -> Option<Self> {
let vad = TenOnnxVad::try_new(model_path)?;
let latest_probability = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
let latest_processed_seq = Arc::new(AtomicU64::new(u64::MAX));
let alive = Arc::new(AtomicBool::new(true));
let (tx, rx) = std::sync::mpsc::sync_channel::<TenFrameMessage>(32);
let prob_arc = latest_probability.clone();
let seq_arc = latest_processed_seq.clone();
let alive_arc = alive.clone();
let handle = std::thread::Builder::new()
.name("chanora-ten-vad".to_string())
.spawn(move || {
let mut vad = vad;
while alive_arc.load(std::sync::atomic::Ordering::Relaxed) {
let msg = match rx.recv() {
Ok(m) => m,
Err(_) => break,
};
let mut frame_f32 = [0.0_f32; INPUT_FRAME_10MS];
frame_f32.copy_from_slice(&msg.frame);
let out = VoiceActivityDetector::process_10ms(&mut vad, &frame_f32);
prob_arc.store(
out.probability.clamp(0.0, 1.0).to_bits(),
std::sync::atomic::Ordering::Relaxed,
);
seq_arc.store(msg.seq, std::sync::atomic::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; INPUT_FRAME_10MS]) -> bool {
let Some(tx) = &self.tx else {
return false;
};
tx.try_send(TenFrameMessage { 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(std::sync::atomic::Ordering::Relaxed),
)
}
/// True when the worker is too far behind to trust its output.
pub fn is_stale(&self, capture_seq: u64) -> bool {
let latest = self
.latest_processed_seq
.load(std::sync::atomic::Ordering::Relaxed);
latest == u64::MAX || capture_seq.saturating_sub(latest) > TEN_MAX_STALE_FRAMES
}
}
impl Drop for TenOnnxVadWorker {
fn drop(&mut self) {
self.alive
.store(false, std::sync::atomic::Ordering::Relaxed);
drop(self.tx.take());
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mel_filter_bank_has_expected_shape() {
let filters = build_mel_filters();
assert_eq!(filters.len(), MEL_BANDS);
assert!(filters.iter().all(|f| f.iter().any(|&v| v > 0.0)));
}
#[test]
fn preprocessing_produces_finite_features() {
let filters = build_mel_filters();
let frame = vec![0.0_f32; WINDOW_16K];
let feature = compute_feature(&filters, &frame);
assert!(feature.iter().all(|v| v.is_finite()));
}
}
+19 -4
View File
@@ -1725,6 +1725,21 @@ pub async fn set_vad_model_path(path: String) -> Result<(), BridgeError> {
Ok(())
}
/// Configure the TEN VAD ONNX model path.
pub async fn set_ten_vad_model_path(path: String) -> Result<(), BridgeError> {
if path.trim().is_empty() {
return Err(BridgeError::InvalidCommand(
"ten vad model path must not be empty".to_string(),
));
}
runtime()
.spawn(async move { chanora_audio::vad::set_ten_model_path(&path).map_err(|e| e) })
.await
.map_err(|e| task_join_error("set_ten_vad_model_path", e))?
.map_err(|e| BridgeError::Unmapped(format!("set_ten_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()
@@ -1746,20 +1761,20 @@ pub async fn set_ios_voice_processing_mode(
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => {
BridgeAudioBackend::PlatformVoiceProcessing
}
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::Sonora,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::WebrtcApm,
},
vad_backend: BridgeVadBackend::SileroOnnx,
aec: match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::Sonora,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
},
ns: match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::Sonora,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
},
agc: match mode {
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::Sonora,
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
},
hpf_enabled: true,
limiter_enabled: true,
+140 -30
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1835973251;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -436507436;
// Section: executor
@@ -398,6 +398,41 @@ fn wire__crate__api__export_diagnostics_impl(
},
)
}
fn wire__crate__api__get_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: "get_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);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::get_audio_processing_config().await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__get_ptt_binding_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -598,6 +633,38 @@ fn wire__crate__api__handle_media_services_reset_impl(
},
)
}
fn wire__crate__api__handle_media_services_reset_with_route_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_with_route",
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);
let api_route_class = <String>::sse_decode(&mut deserializer);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::handle_media_services_reset_with_route(api_route_class);
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__handle_route_change_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
@@ -1198,6 +1265,42 @@ fn wire__crate__api__set_release_tail_ms_impl(
},
)
}
fn wire__crate__api__set_ten_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_ten_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_ten_vad_model_path(api_path).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__set_transmit_mode_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -2090,31 +2193,33 @@ fn pde_ffi_dispatcher_primary_impl(
7 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
8 => wire__crate__api__enable_audio_debug_wav_dump_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__get_transmit_mode_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__is_connected_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__move_to_channel_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_audio_processing_config_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_input_muted_impl(port, ptr, rust_vec_len, data_len),
27 => {
11 => wire__crate__api__get_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
29 => {
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
}
29 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_ptt_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),
31 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
36 => wire__crate__api__set_ten_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2128,12 +2233,17 @@ fn pde_ffi_dispatcher_sync_impl(
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id {
10 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
14 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
15 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
16 => wire__crate__api__handle_media_services_reset_impl(ptr, rust_vec_len, data_len),
17 => wire__crate__api__handle_route_change_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),
15 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
16 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
17 => wire__crate__api__handle_media_services_reset_impl(ptr, rust_vec_len, data_len),
18 => wire__crate__api__handle_media_services_reset_with_route_impl(
ptr,
rust_vec_len,
data_len,
),
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
23 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}