diff --git a/.cargo/config.toml b/.cargo/config.toml
index 901eb32..b0ca965 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -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).
#
diff --git a/Cargo.lock b/Cargo.lock
index c6b1d95..da608c9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index bce3861..0000000
--- a/LICENSE
+++ /dev/null
@@ -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.
diff --git a/LICENSE-APACHE b/LICENSE-APACHE
deleted file mode 100644
index 20670b0..0000000
--- a/LICENSE-APACHE
+++ /dev/null
@@ -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.
diff --git a/LICENSE-MIT b/LICENSE-MIT
deleted file mode 100644
index 6f1e0fa..0000000
--- a/LICENSE-MIT
+++ /dev/null
@@ -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.
diff --git a/apps/chanora_flutter/android/app/build.gradle.kts b/apps/chanora_flutter/android/app/build.gradle.kts
index 9520b20..ed07d76 100644
--- a/apps/chanora_flutter/android/app/build.gradle.kts
+++ b/apps/chanora_flutter/android/app/build.gradle.kts
@@ -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 = "../.."
}
diff --git a/apps/chanora_flutter/android/app/proguard-rules.pro b/apps/chanora_flutter/android/app/proguard-rules.pro
index 072cafe..2310487 100644
--- a/apps/chanora_flutter/android/app/proguard-rules.pro
+++ b/apps/chanora_flutter/android/app/proguard-rules.pro
@@ -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
diff --git a/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml b/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml
index 6747683..b3200f3 100644
--- a/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml
+++ b/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml
@@ -22,6 +22,7 @@
foregroundServiceType="microphone". Declared here for the
entire API ladder; the platform ignores it on older releases. -->
+
+
+
+
+
+
+
+
+
+ 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
+ }
+ }
+}
diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioLifecycleController.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioLifecycleController.kt
new file mode 100644
index 0000000..2dd91d4
--- /dev/null
+++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioLifecycleController.kt
@@ -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) {
+ notifyRouteChange(addedDevices.firstOrNull())
+ }
+
+ override fun onAudioDevicesRemoved(removedDevices: Array) {
+ 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"
+ }
+}
diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidBluetoothScoController.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidBluetoothScoController.kt
new file mode 100644
index 0000000..9da82e0
--- /dev/null
+++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidBluetoothScoController.kt
@@ -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)"
+ }
+ }
+}
diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt
index 10e73a6..6e07e7c 100644
--- a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt
+++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt
@@ -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
diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MethodChannels.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MethodChannels.kt
index 98c95f2..0498277 100644
--- a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MethodChannels.kt
+++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MethodChannels.kt
@@ -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"
diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart
index 0781deb..f5eacef 100644
--- a/apps/chanora_flutter/lib/main.dart
+++ b/apps/chanora_flutter/lib/main.dart
@@ -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 _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
/// `+` 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 main() async {
@@ -146,6 +150,8 @@ Future 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+` (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 [])
- _clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel),
+ if (!cl.isServerQuery)
+ _clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel),
],
],
);
diff --git a/apps/chanora_flutter/lib/services/link_trust_service.dart b/apps/chanora_flutter/lib/services/link_trust_service.dart
new file mode 100644
index 0000000..c7f19bf
--- /dev/null
+++ b/apps/chanora_flutter/lib/services/link_trust_service.dart
@@ -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 _trusted = {};
+ bool _loaded = false;
+
+ static LinkTrustService get instance {
+ _instance ??= LinkTrustService._();
+ return _instance!;
+ }
+
+ LinkTrustService._() {
+ _load();
+ }
+
+ Future _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 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 showLinkTrustDialog(BuildContext context, String domain) async {
+ bool remember = false;
+ return showDialog(
+ 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'),
+ ),
+ ],
+ ),
+ ),
+ );
+}
diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart
index cde73e3..5b5f330 100644
--- a/apps/chanora_flutter/lib/src/rust/api.dart
+++ b/apps/chanora_flutter/lib/src/rust/api.dart
@@ -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 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 audioStats() =>
/// Apply the P1 audio-processing config.
Future 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 getAudioProcessingConfig() =>
+ RustLib.instance.api.crateApiGetAudioProcessingConfig();
/// Read P1 audio-processing diagnostics.
Future 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 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 setVadModelPath({required String path}) =>
RustLib.instance.api.crateApiSetVadModelPath(path: path);
+/// Configure the TEN VAD ONNX model path.
+Future setTenVadModelPath({required String path}) =>
+ RustLib.instance.api.crateApiSetTenVadModelPath(path: path);
+
/// Enable or disable audio debug WAV dumping.
Future enableAudioDebugWavDump({required bool enabled}) =>
RustLib.instance.api.crateApiEnableAudioDebugWavDump(enabled: enabled);
diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart
index 9959c52..f867ca5 100644
--- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart
+++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint {
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 crateApiGetAudioProcessingConfig();
+
Future<(String, String)> crateApiGetPttBinding();
Future crateApiGetReleaseTailMs();
@@ -115,6 +117,8 @@ abstract class RustLibApi extends BaseApi {
void crateApiHandleMediaServicesReset();
+ void crateApiHandleMediaServicesResetWithRoute({required String routeClass});
+
void crateApiHandleRouteChange({required BridgeAudioRoute route});
Future crateApiInitStorage({required String dir});
@@ -159,6 +163,8 @@ abstract class RustLibApi extends BaseApi {
Future crateApiSetReleaseTailMs({required int ms});
+ Future crateApiSetTenVadModelPath({required String path});
+
Future crateApiSetTransmitMode({required BridgeTransmitMode mode});
Future 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 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 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 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_,
);
},
diff --git a/apps/chanora_flutter/lib/widgets/bbcode_text.dart b/apps/chanora_flutter/lib/widgets/bbcode_text.dart
new file mode 100644
index 0000000..8bf00c2
--- /dev/null
+++ b/apps/chanora_flutter/lib/widgets/bbcode_text.dart
@@ -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 = [];
+ 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 = [];
+ final tags = [];
+
+ 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 _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,
+ );
+ }
+}
diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart
index 20877e5..46e1fc1 100644
--- a/apps/chanora_flutter/lib/widgets/voice_compact.dart
+++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart
@@ -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',
diff --git a/apps/chanora_flutter/lib/widgets/voice_settings.dart b/apps/chanora_flutter/lib/widgets/voice_settings.dart
index 00a63af..ce00bdb 100644
--- a/apps/chanora_flutter/lib/widgets/voice_settings.dart
+++ b/apps/chanora_flutter/lib/widgets/voice_settings.dart
@@ -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 {
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 {
: 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 {
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 {
const SizedBox(height: 4),
],
+ // Android HW/SW selector
+ if (_isAndroid) ...[
+ _subHeader(theme, 'Processing backend'),
+ _radioTile(
+ value: true,
+ groupValue: _preferHardware,
+ title: const Text('Platform (auto)'),
+ subtitle: _tileSubtitle(
+ 'Try hardware JNI effects · software fallback',
+ ),
+ onSelected: (v) => setState(() => _preferHardware = v),
+ ),
+ _radioTile(
+ 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 {
),
_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)',
diff --git a/apps/chanora_flutter/linux/flutter/generated_plugins.cmake b/apps/chanora_flutter/linux/flutter/generated_plugins.cmake
index be1ee3e..df8d2f7 100644
--- a/apps/chanora_flutter/linux/flutter/generated_plugins.cmake
+++ b/apps/chanora_flutter/linux/flutter/generated_plugins.cmake
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
+ url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
diff --git a/apps/chanora_flutter/pubspec.lock b/apps/chanora_flutter/pubspec.lock
index 81ec45f..5f98783 100644
--- a/apps/chanora_flutter/pubspec.lock
+++ b/apps/chanora_flutter/pubspec.lock
@@ -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:
diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml
index fe22e18..58957a9 100644
--- a/apps/chanora_flutter/pubspec.yaml
+++ b/apps/chanora_flutter/pubspec.yaml
@@ -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:
diff --git a/apps/chanora_flutter/windows/flutter/generated_plugins.cmake b/apps/chanora_flutter/windows/flutter/generated_plugins.cmake
index 7e0ec45..0b0bda0 100644
--- a/apps/chanora_flutter/windows/flutter/generated_plugins.cmake
+++ b/apps/chanora_flutter/windows/flutter/generated_plugins.cmake
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
+ url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml
index a7dc7e4..0bff277 100644
--- a/crates/chanora_audio/Cargo.toml
+++ b/crates/chanora_audio/Cargo.toml
@@ -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
diff --git a/crates/chanora_audio/src/android_voice_unit.rs b/crates/chanora_audio/src/android_voice_unit.rs
index d3d05f9..430e7aa 100644
--- a/crates/chanora_audio/src/android_voice_unit.rs
+++ b/crates/chanora_audio/src/android_voice_unit.rs
@@ -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 {
+ 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,
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender,
transmit_active: Arc,
+ output_muted: Arc,
frames_sent: Arc,
mic_gain: f32,
+ voice_activity_selector: Option>,
+ vad_detector: crate::vad::WebRtcFallbackVad,
+ silero_vad_worker: Option,
+ ten_vad_worker: Option,
+ 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>,
+ audio_processing_stats: Arc,
+ render_reference: Arc,
+ pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
+ pending_10ms_len: usize,
+ fallback_warned_backend: Option,
}
impl AndroidCaptureState {
fn new(
voice_out_tx: mpsc::Sender,
transmit_active: Arc,
+ output_muted: Arc,
frames_sent: Arc,
mic_gain: f32,
+ voice_activity_selector: Option>,
+ audio_processing_config: Arc>,
+ audio_processing_stats: Arc,
+ render_reference: Arc,
) -> Result {
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,
event_tx: BackendEventTx,
scratch: Arc>>,
+ render_reference: Arc,
}
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,
/// 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>>,
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
@@ -281,6 +593,14 @@ pub struct VoiceAudioParams {
pub output_gain: Arc,
/// True = output silence regardless of incoming voice frames.
pub output_muted: Arc,
+ /// 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>,
+ /// Shared audio-processing config (WebRTC APM flags, VAD backend).
+ pub audio_processing_config: Arc>,
+ /// Shared audio-processing statistics for diagnostics.
+ pub audio_processing_stats: Arc,
}
// --- The backend itself ------------------------------------------
@@ -334,22 +654,30 @@ impl AndroidVoiceUnit {
) -> Result {
let (event_tx, event_rx) = mpsc::unbounded_channel();
- // SDD-120: build the capture state that the Oboe input callback
- // will own via Arc. 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 = 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,
output_muted: Arc,
scratch: Arc>>,
+ render_reference: Arc,
) -> Result, 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::()
@@ -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