diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 250f45f..030c1a5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -106,4 +106,19 @@ jobs:
run: flutter analyze
- name: flutter test (unit only)
working-directory: apps/chanora_flutter
- run: flutter test --exclude-tags e2e || true
+ run: flutter test --exclude-tags e2e
+
+ flutter-ios-release-build:
+ name: flutter iOS unsigned release build
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ - name: flutter pub get
+ working-directory: apps/chanora_flutter
+ run: flutter pub get
+ - name: flutter build ios --no-codesign
+ working-directory: apps/chanora_flutter
+ run: flutter build ios --release --no-codesign
diff --git a/Cargo.lock b/Cargo.lock
index 97a71a6..c6b1d95 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -410,10 +410,13 @@ dependencies = [
"cpal",
"criterion",
"dhat",
+ "dispatch2",
"futures-util",
"jni 0.21.1",
+ "ndarray",
"ndk-context",
"oboe",
+ "ort",
"rand 0.8.6",
"reqwest",
"sdl2",
@@ -1004,6 +1007,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.11.1",
+ "block2",
+ "libc",
"objc2",
]
@@ -1615,7 +1620,7 @@ dependencies = [
"parking_lot",
"rand 0.10.1",
"resolv-conf",
- "smallvec",
+ "smallvec 1.15.1",
"system-configuration",
"thiserror 2.0.18",
"tokio",
@@ -1695,7 +1700,7 @@ dependencies = [
"httparse",
"itoa",
"pin-project-lite",
- "smallvec",
+ "smallvec 1.15.1",
"tokio",
"want",
]
@@ -1791,7 +1796,7 @@ dependencies = [
"icu_normalizer_data",
"icu_properties",
"icu_provider",
- "smallvec",
+ "smallvec 1.15.1",
"zerovec",
]
@@ -1849,7 +1854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
- "smallvec",
+ "smallvec 1.15.1",
"utf8_iter",
]
@@ -2080,6 +2085,16 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
@@ -2152,6 +2167,16 @@ dependencies = [
"regex-automata",
]
+[[package]]
+name = "matrixmultiply"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
[[package]]
name = "md-5"
version = "0.10.6"
@@ -2227,7 +2252,7 @@ dependencies = [
"equivalent",
"parking_lot",
"portable-atomic",
- "smallvec",
+ "smallvec 1.15.1",
"tagptr",
"uuid",
]
@@ -2249,6 +2274,21 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "ndarray"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
+dependencies = [
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rawpointer",
+]
+
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2307,6 +2347,15 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -2576,6 +2625,28 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "ort"
+version = "2.0.0-rc.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721"
+dependencies = [
+ "libloading",
+ "ndarray",
+ "ort-sys",
+ "smallvec 2.0.0-alpha.10",
+ "tracing",
+]
+
+[[package]]
+name = "ort-sys"
+version = "2.0.0-rc.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2aba9f5c7c479925205799216e7e5d07cc1d4fa76ea8058c60a9a30f6a4e890"
+dependencies = [
+ "pkg-config",
+]
+
[[package]]
name = "oslog"
version = "0.2.0"
@@ -2624,7 +2695,7 @@ dependencies = [
"cfg-if",
"libc",
"redox_syscall",
- "smallvec",
+ "smallvec 1.15.1",
"windows-link",
]
@@ -2716,6 +2787,15 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2952,6 +3032,12 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
[[package]]
name = "rayon"
version = "1.12.0"
@@ -3112,7 +3198,7 @@ dependencies = [
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
- "smallvec",
+ "smallvec 1.15.1",
]
[[package]]
@@ -3511,6 +3597,12 @@ 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"
@@ -3995,7 +4087,7 @@ dependencies = [
"once_cell",
"regex-automata",
"sharded-slab",
- "smallvec",
+ "smallvec 1.15.1",
"thread_local",
"tracing",
"tracing-core",
diff --git a/NOTICE b/NOTICE
index 23520c0..5dee472 100644
--- a/NOTICE
+++ b/NOTICE
@@ -65,6 +65,8 @@ Direct Flutter / Dart dependencies of the MVP product code:
* path_provider — BSD-3-Clause
* intl — BSD-3-Clause
* cupertino_icons — MIT
+ * haptic_kit — MIT
+ https://github.com/erykkruk/flutter_vibration_animation
* freezed_annotation — MIT
* flutter_lints (dev) — BSD-3-Clause
* build_runner (dev) — BSD-3-Clause
diff --git a/apps/chanora_flutter/assets/models/silero_vad.onnx b/apps/chanora_flutter/assets/models/silero_vad.onnx
new file mode 100644
index 0000000..80c5592
Binary files /dev/null and b/apps/chanora_flutter/assets/models/silero_vad.onnx differ
diff --git a/apps/chanora_flutter/assets/models/ten_vad.onnx b/apps/chanora_flutter/assets/models/ten_vad.onnx
new file mode 100644
index 0000000..07b2816
Binary files /dev/null and b/apps/chanora_flutter/assets/models/ten_vad.onnx differ
diff --git a/apps/chanora_flutter/ios/ExportOptions/AppStore.plist b/apps/chanora_flutter/ios/ExportOptions/AppStore.plist
new file mode 100644
index 0000000..bd1bf63
--- /dev/null
+++ b/apps/chanora_flutter/ios/ExportOptions/AppStore.plist
@@ -0,0 +1,18 @@
+
+
+
+
+ method
+ app-store
+ destination
+ export
+ signingStyle
+ automatic
+ stripSwiftSymbols
+
+ uploadBitcode
+
+ uploadSymbols
+
+
+
diff --git a/apps/chanora_flutter/ios/Podfile b/apps/chanora_flutter/ios/Podfile
index fe7a2ff..ef65559 100644
--- a/apps/chanora_flutter/ios/Podfile
+++ b/apps/chanora_flutter/ios/Podfile
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
-platform :ios, '13.0'
+platform :ios, '15.1'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -39,6 +39,7 @@ target 'Runner' do
# flutter_rust_bridge can dlopen() it at runtime via FRB's
# default `chanora_bridge.framework/chanora_bridge` lookup path.
pod 'chanora_bridge', :path => '.'
+ pod 'onnxruntime-c', '1.22.0'
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
diff --git a/apps/chanora_flutter/ios/Podfile.lock b/apps/chanora_flutter/ios/Podfile.lock
index 531805f..3f215db 100644
--- a/apps/chanora_flutter/ios/Podfile.lock
+++ b/apps/chanora_flutter/ios/Podfile.lock
@@ -5,6 +5,9 @@ PODS:
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
+ - haptic_kit (1.0.0):
+ - Flutter
+ - onnxruntime-c (1.22.0)
- package_info_plus (0.4.5):
- Flutter
@@ -13,8 +16,14 @@ DEPENDENCIES:
- chanora_bridge (from `.`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
+ - haptic_kit (from `.symlinks/plugins/haptic_kit/ios`)
+ - onnxruntime-c (= 1.22.0)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
+SPEC REPOS:
+ trunk:
+ - onnxruntime-c
+
EXTERNAL SOURCES:
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
@@ -24,16 +33,20 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
:path: Flutter
+ haptic_kit:
+ :path: ".symlinks/plugins/haptic_kit/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
SPEC CHECKSUMS:
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
- chanora_bridge: af821d2c0507cb3199c91be12996bf0eb6b8bf5d
+ chanora_bridge: 0289413733edf8b7c937c50c3c3424b3319b94b5
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+ haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
+ onnxruntime-c: 7f778680e96145956c0a31945f260321eed2611a
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
-PODFILE CHECKSUM: 15f58b0363434f244766f3301e00b9b1cdee096a
+PODFILE CHECKSUM: a3abe93db2fc91b90387b399576e9c42a54226e0
COCOAPODS: 1.16.2
diff --git a/apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj b/apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj
index 8796212..204be31 100644
--- a/apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj
+++ b/apps/chanora_flutter/ios/Runner.xcodeproj/project.pbxproj
@@ -463,7 +463,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.1;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -596,7 +596,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.1;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -647,7 +647,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.1;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
diff --git a/apps/chanora_flutter/ios/Runner/AppDelegate.swift b/apps/chanora_flutter/ios/Runner/AppDelegate.swift
index d6e1820..3865d3d 100644
--- a/apps/chanora_flutter/ios/Runner/AppDelegate.swift
+++ b/apps/chanora_flutter/ios/Runner/AppDelegate.swift
@@ -5,6 +5,7 @@ import AVFoundation
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var iosAudioLifecycleChannel: FlutterMethodChannel?
+ private var iosPlatformChannel: FlutterMethodChannel?
override func application(
_ application: UIApplication,
@@ -134,32 +135,12 @@ import AVFoundation
object: nil
)
- // Request microphone access on first launch rather than waiting
- // for the user's first voice-channel join. The latter is
- // surprising: the user has only tapped "connect to server" and
- // suddenly iOS pops the permission prompt because joining a
- // text channel happens to trigger audio engine startup. Asking
- // up-front matches user expectations for a voice-chat client.
- //
- // The request is asynchronous and non-blocking. If the user
- // denies, voice_join will surface a clearer error later when
- // the audio engine fails to open the input device. The
- // permission state is cached by iOS so subsequent launches
- // skip the prompt.
- //
- // Deferred ~1 s so iOS finishes initialising the keyboard /
- // text-input subsystem before the permission alert appears.
- // Firing the alert too early steals focus from the not-yet-
- // ready text-input layer, with the symptom that the first tap
- // on a TextField does nothing (the second tap works because
- // by then iOS has caught up). DispatchQueue.main.asyncAfter
- // keeps everything on the main thread; the permission API
- // itself must be called there too.
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
- AVAudioSession.sharedInstance().requestRecordPermission { granted in
- NSLog("chanora_flutter: microphone permission granted=\(granted)")
- }
- }
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(handleMediaServicesReset(_:)),
+ name: AVAudioSession.mediaServicesWereResetNotification,
+ object: nil
+ )
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
@@ -182,7 +163,7 @@ import AVFoundation
// wrong rate, causing pitch + timing artifacts).
logAudioSessionState(context: "setActive")
let s = AVAudioSession.sharedInstance()
- let ins = s.currentRoute.inputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",")
+ let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
NSLog(
"chanora_flutter: AVAudioSession actual: " +
"sampleRate=\(s.sampleRate) " +
@@ -221,9 +202,18 @@ import AVFoundation
let outputs = routeDescription.outputs.map { $0.portType.rawValue }.joined(separator: ",")
NSLog("chanora_flutter: route change reason=\(reason.rawValue) outputs=\(outputs)")
- if reason == .oldDeviceUnavailable || reason == .newDeviceAvailable {
- iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: nil)
- }
+ // P1: Send the detailed route class to Rust on every route change,
+ // not just device plug/unplug. This covers:
+ // - .newDeviceAvailable / .oldDeviceUnavailable (headset plug/unplug)
+ // - .override (speaker/earpiece toggle)
+ // - .categoryChange (session category changed)
+ // - .wakeFromSleep (device woke from sleep)
+ // - .routeConfigurationChange (BT HFP connect/disconnect)
+ // The Rust side uses the route class to recompute the processing
+ // policy (route_policy.rs) and reset AEC delay state if needed.
+ let routeClass = classifyAudioRoute(routeDescription)
+ NSLog("chanora_flutter: route class=\(routeClass) reason=\(reason.rawValue)")
+ iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: routeClass)
}
@objc private func handleInterruption(_ notification: Notification) {
@@ -249,11 +239,115 @@ import AVFoundation
}
}
+ @objc private func handleMediaServicesReset(_ notification: Notification) {
+ NSLog("chanora_flutter: media services reset")
+ do {
+ let session = AVAudioSession.sharedInstance()
+ try session.setCategory(
+ .playAndRecord,
+ mode: .default,
+ options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
+ )
+ try session.setPreferredIOBufferDuration(0.02)
+ try session.setPreferredSampleRate(48000.0)
+ try session.setActive(true, options: [])
+ logAudioSessionState(context: "mediaServicesWereReset")
+ } catch {
+ NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
+ }
+ // P1: After rebuilding the session, send the current route class to
+ // Rust so it can recompute the processing policy and reset the
+ // AudioUnit. The Rust side handles this via ios_handle_media_services_reset
+ // which calls ios_restart_voice_unit.
+ let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
+ NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
+ iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
+ }
+
+ override func applicationWillResignActive(_ application: UIApplication) {
+ iosAudioLifecycleChannel?.invokeMethod("handleWillResignActive", arguments: nil)
+ }
+
+ override func applicationDidEnterBackground(_ application: UIApplication) {
+ iosAudioLifecycleChannel?.invokeMethod("handleDidEnterBackground", arguments: nil)
+ }
+
+ override func applicationWillEnterForeground(_ application: UIApplication) {
+ iosAudioLifecycleChannel?.invokeMethod("handleWillEnterForeground", arguments: nil)
+ }
+
+ override func applicationWillTerminate(_ application: UIApplication) {
+ iosAudioLifecycleChannel?.invokeMethod("handleWillTerminate", arguments: nil)
+ }
+
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
iosAudioLifecycleChannel = FlutterMethodChannel(
name: "chanora/ios_audio_lifecycle",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
+ iosPlatformChannel = FlutterMethodChannel(
+ name: "chanora/ios_platform",
+ binaryMessenger: engineBridge.applicationRegistrar.messenger()
+ )
+ iosPlatformChannel?.setMethodCallHandler { call, result in
+ switch call.method {
+ case "getMicrophonePermissionState":
+ result(self.microphonePermissionStateString())
+ case "requestMicrophonePermission":
+ AVAudioSession.sharedInstance().requestRecordPermission { granted in
+ DispatchQueue.main.async {
+ result(granted ? "Granted" : self.microphonePermissionStateString())
+ }
+ }
+ case "openAppSettings":
+ guard let url = URL(string: UIApplication.openSettingsURLString) else {
+ result(false)
+ return
+ }
+ UIApplication.shared.open(url, options: [:]) { opened in
+ result(opened)
+ }
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+ }
+
+ private func classifyAudioRoute(_ route: AVAudioSessionRouteDescription) -> String {
+ for output in route.outputs {
+ switch output.portType {
+ case .builtInReceiver:
+ return "Earpiece"
+ case .builtInSpeaker:
+ return "Speaker"
+ case .headphones, .usbAudio:
+ return "WiredHeadset"
+ case .bluetoothHFP:
+ return "BluetoothHfp"
+ case .bluetoothA2DP:
+ return "BluetoothA2dp"
+ default:
+ break
+ }
+ }
+ return "Unknown"
+ }
+
+ private func microphonePermissionStateString() -> String {
+ switch AVAudioSession.sharedInstance().recordPermission {
+ case .granted:
+ return "Granted"
+ case .denied:
+ return "Denied"
+ case .undetermined:
+ return "NotDetermined"
+ @unknown default:
+ return "Unknown"
+ }
+ }
+
+ deinit {
+ NotificationCenter.default.removeObserver(self)
}
}
diff --git a/apps/chanora_flutter/ios/Runner/Info.plist b/apps/chanora_flutter/ios/Runner/Info.plist
index 885f9d5..4e60740 100644
--- a/apps/chanora_flutter/ios/Runner/Info.plist
+++ b/apps/chanora_flutter/ios/Runner/Info.plist
@@ -4,6 +4,9 @@
CADisableMinimumFrameDurationOnPhone
+
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
@@ -74,11 +77,6 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
-
UIFileSharingEnabled
LSSupportsOpeningDocumentsInPlace
diff --git a/apps/chanora_flutter/ios/chanora_bridge.podspec b/apps/chanora_flutter/ios/chanora_bridge.podspec
index e844627..b7ae0ea 100644
--- a/apps/chanora_flutter/ios/chanora_bridge.podspec
+++ b/apps/chanora_flutter/ios/chanora_bridge.podspec
@@ -40,7 +40,7 @@ Pod::Spec.new do |s|
s.license = { :type => 'Apache-2.0 OR MIT', :text => 'See LICENSE-APACHE / LICENSE-MIT at the repo root' }
s.author = { 'EdisonJwa' => 'me@edison.network' }
s.source = { :path => '.' }
- s.platform = :ios, '13.0'
+ s.platform = :ios, '15.1'
# Build the Rust bridge on `pod install`. The script runs under
# bash; we use `set -e` so any failure (cargo missing, target not
@@ -51,15 +51,59 @@ Pod::Spec.new do |s|
s.prepare_command = <<-SCRIPT
set -e
REPO_ROOT="$(cd ../../.. && pwd)"
+ USER_NAME="$(id -un)"
+ USER_HOME="$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
+ if [ -z "$USER_HOME" ]; then
+ USER_HOME="$(cd ~ && pwd)"
+ fi
BRIDGE="$REPO_ROOT/target/aarch64-apple-ios/release/libchanora_bridge.dylib"
+ find_cargo() {
+ for candidate in \
+ "$USER_HOME/.cargo/bin/cargo" \
+ "/opt/homebrew/opt/rustup/bin/cargo" \
+ "/usr/local/opt/rustup/bin/cargo"
+ do
+ if [ -x "$candidate" ]; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+ command -v cargo
+ }
+ find_rustc() {
+ for candidate in \
+ "$USER_HOME/.cargo/bin/rustc" \
+ "/opt/homebrew/opt/rustup/bin/rustc" \
+ "/usr/local/opt/rustup/bin/rustc"
+ do
+ if [ -x "$candidate" ]; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+ command -v rustc
+ }
+ CARGO_BIN="$(find_cargo)"
+ RUSTC_BIN="$(find_rustc)"
+ ORT_FRAMEWORK="$REPO_ROOT/apps/chanora_flutter/ios/Pods/onnxruntime-c/onnxruntime.xcframework/ios-arm64/onnxruntime.framework"
+ ORT_LINK_DIR="$REPO_ROOT/target/onnxruntime-ios-device"
+ if [ -f "$ORT_FRAMEWORK/onnxruntime" ]; then
+ mkdir -p "$ORT_LINK_DIR"
+ lipo "$ORT_FRAMEWORK/onnxruntime" -thin arm64 -output "$ORT_LINK_DIR/libonnxruntime.a"
+ fi
echo "[chanora_bridge.podspec] cargo build aarch64-apple-ios"
cd "$REPO_ROOT"
- PATH="$HOME/.cargo/bin:$PATH" \\
- IPHONEOS_DEPLOYMENT_TARGET=13.0 \\
+ HOME="$USER_HOME" \\
+ CARGO_HOME="$USER_HOME/.cargo" \\
+ RUSTUP_HOME="$USER_HOME/.rustup" \\
+ RUSTUP_TOOLCHAIN="stable-aarch64-apple-darwin" \\
+ RUSTC="$RUSTC_BIN" \\
+ ORT_LIB_LOCATION="$ORT_LINK_DIR" \\
+ IPHONEOS_DEPLOYMENT_TARGET=15.1 \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
- CMAKE_OSX_DEPLOYMENT_TARGET=13.0 \\
- cargo build --release --target aarch64-apple-ios -p chanora_bridge
+ CMAKE_OSX_DEPLOYMENT_TARGET=15.1 \\
+ "$CARGO_BIN" build --release --target aarch64-apple-ios -p chanora_bridge
if [ ! -f "$BRIDGE" ]; then
echo "ERROR: bridge dylib not found at $BRIDGE" >&2
@@ -86,7 +130,7 @@ Pod::Spec.new do |s|
CFBundleShortVersionString1.0.0
CFBundleVersion1
CFBundleSupportedPlatformsiPhoneOS
- MinimumOSVersion13.0
+ MinimumOSVersion15.1
PLIST
@@ -116,15 +160,70 @@ PLIST
:script => <<-SCRIPT,
set -e
REPO_ROOT="$(cd "${PODS_TARGET_SRCROOT}/../../.." && pwd)"
- BRIDGE="$REPO_ROOT/target/aarch64-apple-ios/release/libchanora_bridge.dylib"
+ USER_NAME="$(id -un)"
+ USER_HOME="$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
+ if [ -z "$USER_HOME" ]; then
+ USER_HOME="$(cd ~ && pwd)"
+ fi
+ find_cargo() {
+ for candidate in \
+ "$USER_HOME/.cargo/bin/cargo" \
+ "/opt/homebrew/opt/rustup/bin/cargo" \
+ "/usr/local/opt/rustup/bin/cargo"
+ do
+ if [ -x "$candidate" ]; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+ command -v cargo
+ }
+ find_rustc() {
+ for candidate in \
+ "$USER_HOME/.cargo/bin/rustc" \
+ "/opt/homebrew/opt/rustup/bin/rustc" \
+ "/usr/local/opt/rustup/bin/rustc"
+ do
+ if [ -x "$candidate" ]; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+ command -v rustc
+ }
+ CARGO_BIN="$(find_cargo)"
+ RUSTC_BIN="$(find_rustc)"
+ if [ "${PLATFORM_NAME:-iphoneos}" = "iphonesimulator" ]; then
+ RUST_TARGET="aarch64-apple-ios-sim"
+ SUPPORTED_PLATFORM="iPhoneSimulator"
+ ORT_SLICE="ios-arm64_x86_64-simulator"
+ else
+ RUST_TARGET="aarch64-apple-ios"
+ SUPPORTED_PLATFORM="iPhoneOS"
+ ORT_SLICE="ios-arm64"
+ fi
+ BRIDGE="$REPO_ROOT/target/$RUST_TARGET/release/libchanora_bridge.dylib"
+ ORT_FRAMEWORK="$REPO_ROOT/apps/chanora_flutter/ios/Pods/onnxruntime-c/onnxruntime.xcframework/$ORT_SLICE/onnxruntime.framework"
+ ORT_LINK_DIR="$REPO_ROOT/target/onnxruntime-$RUST_TARGET"
+ if [ ! -f "$ORT_FRAMEWORK/onnxruntime" ]; then
+ echo "ERROR: ONNX Runtime framework not found at $ORT_FRAMEWORK" >&2
+ exit 1
+ fi
+ mkdir -p "$ORT_LINK_DIR"
+ lipo "$ORT_FRAMEWORK/onnxruntime" -thin arm64 -output "$ORT_LINK_DIR/libonnxruntime.a"
- echo "[chanora_bridge script_phase] cargo build aarch64-apple-ios"
+ echo "[chanora_bridge script_phase] cargo build $RUST_TARGET"
cd "$REPO_ROOT"
- PATH="$HOME/.cargo/bin:$PATH" \\
- IPHONEOS_DEPLOYMENT_TARGET=13.0 \\
+ HOME="$USER_HOME" \\
+ CARGO_HOME="$USER_HOME/.cargo" \\
+ RUSTUP_HOME="$USER_HOME/.rustup" \\
+ RUSTUP_TOOLCHAIN="stable-aarch64-apple-darwin" \\
+ RUSTC="$RUSTC_BIN" \\
+ ORT_LIB_LOCATION="$ORT_LINK_DIR" \\
+ IPHONEOS_DEPLOYMENT_TARGET=15.1 \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
- CMAKE_OSX_DEPLOYMENT_TARGET=13.0 \\
- cargo build --release --target aarch64-apple-ios -p chanora_bridge
+ CMAKE_OSX_DEPLOYMENT_TARGET=15.1 \\
+ "$CARGO_BIN" build --release --target "$RUST_TARGET" -p chanora_bridge
cd "$REPO_ROOT/apps/chanora_flutter/ios"
FW=Frameworks/chanora_bridge.framework
@@ -139,6 +238,22 @@ PLIST
mkdir -p "$FW"
cp "$BRIDGE" "$FW/chanora_bridge"
+ cat > "$FW/Info.plist" <
+
+
+
+ CFBundleExecutablechanora_bridge
+ CFBundleIdentifierapp.chanora.bridge
+ CFBundleNamechanora_bridge
+ CFBundlePackageTypeFMWK
+ CFBundleShortVersionString1.0.0
+ CFBundleVersion1
+ CFBundleSupportedPlatforms$SUPPORTED_PLATFORM
+ MinimumOSVersion15.1
+
+
+PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
"$FW/chanora_bridge"
echo "[chanora_bridge script_phase] framework refreshed"
diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb
index 5c507b5..2afdf98 100644
--- a/apps/chanora_flutter/lib/l10n/app_en.arb
+++ b/apps/chanora_flutter/lib/l10n/app_en.arb
@@ -17,6 +17,11 @@
"disconnectAction": "Disconnect",
"refreshAction": "Refresh",
"diagnosticsAction": "Diagnostics",
+ "diagnosticsSaveAction": "Save export",
+ "diagnosticsSaved": "Diagnostic export saved to {path}",
+ "@diagnosticsSaved": {
+ "placeholders": { "path": { "type": "String" } }
+ },
"aboutAction": "About",
"aboutVersion": "Version {version}",
"@aboutVersion": {
@@ -161,5 +166,16 @@
"networkPermissionOpenSettings": "Open System Settings",
"microphonePermissionTitle": "Microphone Permission Required",
"microphonePermissionBody": "Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.",
+ "microphonePermissionRequiredForVoice": "Microphone permission is required for voice transmission.",
+ "permissionGrantAction": "Grant",
+ "audioRouteSystemDefault": "System default",
+ "audioRouteEarpiece": "Earpiece",
+ "audioRouteUsbHeadset": "USB headset",
+ "audioRouteOtherDevice": "Other device",
+ "audioRouteRefreshDevices": "Refresh audio devices",
+ "audioRouteCannotSelect": "This output cannot be selected.",
+ "audioRouteChangeFailed": "Could not change audio output.",
+ "iosAudioInterrupted": "Audio interrupted by system (phone call)",
+ "iosAudioResuming": "Audio resuming",
"permissionDenied": "Permission Denied"
}
diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb
index 8971980..6c4eaf9 100644
--- a/apps/chanora_flutter/lib/l10n/app_zh.arb
+++ b/apps/chanora_flutter/lib/l10n/app_zh.arb
@@ -16,6 +16,8 @@
"disconnectAction": "断开连接",
"refreshAction": "刷新",
"diagnosticsAction": "诊断信息",
+ "diagnosticsSaveAction": "保存导出",
+ "diagnosticsSaved": "诊断导出已保存到 {path}",
"aboutAction": "关于",
"aboutVersion": "版本 {version}",
"aboutAuthor": "作者: Edison Jwa",
@@ -118,5 +120,16 @@
"networkPermissionOpenSettings": "打开系统设置",
"microphonePermissionTitle": "需要麦克风权限",
"microphonePermissionBody": "Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。",
+ "microphonePermissionRequiredForVoice": "语音发送需要麦克风权限。",
+ "permissionGrantAction": "授权",
+ "audioRouteSystemDefault": "系统默认",
+ "audioRouteEarpiece": "听筒",
+ "audioRouteUsbHeadset": "USB 耳机",
+ "audioRouteOtherDevice": "其他设备",
+ "audioRouteRefreshDevices": "刷新音频设备",
+ "audioRouteCannotSelect": "无法选择此输出设备。",
+ "audioRouteChangeFailed": "无法切换音频输出。",
+ "iosAudioInterrupted": "系统已中断音频(电话通话)",
+ "iosAudioResuming": "音频正在恢复",
"permissionDenied": "权限被拒绝"
}
diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart
index 2b4958f..090fb78 100644
--- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart
+++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart
@@ -169,6 +169,18 @@ abstract class AppL10n {
/// **'Diagnostics'**
String get diagnosticsAction;
+ /// No description provided for @diagnosticsSaveAction.
+ ///
+ /// In en, this message translates to:
+ /// **'Save export'**
+ String get diagnosticsSaveAction;
+
+ /// No description provided for @diagnosticsSaved.
+ ///
+ /// In en, this message translates to:
+ /// **'Diagnostic export saved to {path}'**
+ String diagnosticsSaved(String path);
+
/// No description provided for @aboutAction.
///
/// In en, this message translates to:
@@ -721,6 +733,72 @@ abstract class AppL10n {
/// **'Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.'**
String get microphonePermissionBody;
+ /// No description provided for @microphonePermissionRequiredForVoice.
+ ///
+ /// In en, this message translates to:
+ /// **'Microphone permission is required for voice transmission.'**
+ String get microphonePermissionRequiredForVoice;
+
+ /// No description provided for @permissionGrantAction.
+ ///
+ /// In en, this message translates to:
+ /// **'Grant'**
+ String get permissionGrantAction;
+
+ /// No description provided for @audioRouteSystemDefault.
+ ///
+ /// In en, this message translates to:
+ /// **'System default'**
+ String get audioRouteSystemDefault;
+
+ /// No description provided for @audioRouteEarpiece.
+ ///
+ /// In en, this message translates to:
+ /// **'Earpiece'**
+ String get audioRouteEarpiece;
+
+ /// No description provided for @audioRouteUsbHeadset.
+ ///
+ /// In en, this message translates to:
+ /// **'USB headset'**
+ String get audioRouteUsbHeadset;
+
+ /// No description provided for @audioRouteOtherDevice.
+ ///
+ /// In en, this message translates to:
+ /// **'Other device'**
+ String get audioRouteOtherDevice;
+
+ /// No description provided for @audioRouteRefreshDevices.
+ ///
+ /// In en, this message translates to:
+ /// **'Refresh audio devices'**
+ String get audioRouteRefreshDevices;
+
+ /// No description provided for @audioRouteCannotSelect.
+ ///
+ /// In en, this message translates to:
+ /// **'This output cannot be selected.'**
+ String get audioRouteCannotSelect;
+
+ /// No description provided for @audioRouteChangeFailed.
+ ///
+ /// In en, this message translates to:
+ /// **'Could not change audio output.'**
+ String get audioRouteChangeFailed;
+
+ /// No description provided for @iosAudioInterrupted.
+ ///
+ /// In en, this message translates to:
+ /// **'Audio interrupted by system (phone call)'**
+ String get iosAudioInterrupted;
+
+ /// No description provided for @iosAudioResuming.
+ ///
+ /// In en, this message translates to:
+ /// **'Audio resuming'**
+ String get iosAudioResuming;
+
/// No description provided for @permissionDenied.
///
/// In en, this message translates to:
diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart
index 6219422..63e7644 100644
--- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart
+++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart
@@ -46,6 +46,14 @@ class AppL10nEn extends AppL10n {
@override
String get diagnosticsAction => 'Diagnostics';
+ @override
+ String get diagnosticsSaveAction => 'Save export';
+
+ @override
+ String diagnosticsSaved(String path) {
+ return 'Diagnostic export saved to $path';
+ }
+
@override
String get aboutAction => 'About';
@@ -360,6 +368,40 @@ class AppL10nEn extends AppL10n {
String get microphonePermissionBody =>
'Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.';
+ @override
+ String get microphonePermissionRequiredForVoice =>
+ 'Microphone permission is required for voice transmission.';
+
+ @override
+ String get permissionGrantAction => 'Grant';
+
+ @override
+ String get audioRouteSystemDefault => 'System default';
+
+ @override
+ String get audioRouteEarpiece => 'Earpiece';
+
+ @override
+ String get audioRouteUsbHeadset => 'USB headset';
+
+ @override
+ String get audioRouteOtherDevice => 'Other device';
+
+ @override
+ String get audioRouteRefreshDevices => 'Refresh audio devices';
+
+ @override
+ String get audioRouteCannotSelect => 'This output cannot be selected.';
+
+ @override
+ String get audioRouteChangeFailed => 'Could not change audio output.';
+
+ @override
+ String get iosAudioInterrupted => 'Audio interrupted by system (phone call)';
+
+ @override
+ String get iosAudioResuming => 'Audio resuming';
+
@override
String get permissionDenied => 'Permission Denied';
}
diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart
index f02b82d..5ca9312 100644
--- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart
+++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart
@@ -44,6 +44,14 @@ class AppL10nZh extends AppL10n {
@override
String get diagnosticsAction => '诊断信息';
+ @override
+ String get diagnosticsSaveAction => '保存导出';
+
+ @override
+ String diagnosticsSaved(String path) {
+ return '诊断导出已保存到 $path';
+ }
+
@override
String get aboutAction => '关于';
@@ -352,6 +360,39 @@ class AppL10nZh extends AppL10n {
String get microphonePermissionBody =>
'Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。';
+ @override
+ String get microphonePermissionRequiredForVoice => '语音发送需要麦克风权限。';
+
+ @override
+ String get permissionGrantAction => '授权';
+
+ @override
+ String get audioRouteSystemDefault => '系统默认';
+
+ @override
+ String get audioRouteEarpiece => '听筒';
+
+ @override
+ String get audioRouteUsbHeadset => 'USB 耳机';
+
+ @override
+ String get audioRouteOtherDevice => '其他设备';
+
+ @override
+ String get audioRouteRefreshDevices => '刷新音频设备';
+
+ @override
+ String get audioRouteCannotSelect => '无法选择此输出设备。';
+
+ @override
+ String get audioRouteChangeFailed => '无法切换音频输出。';
+
+ @override
+ String get iosAudioInterrupted => '系统已中断音频(电话通话)';
+
+ @override
+ String get iosAudioResuming => '音频正在恢复';
+
@override
String get permissionDenied => '权限被拒绝';
}
diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart
index 32cbf79..0781deb 100644
--- a/apps/chanora_flutter/lib/main.dart
+++ b/apps/chanora_flutter/lib/main.dart
@@ -9,9 +9,9 @@
// (all carried over from v0.3.0-beta.1)
import 'dart:async';
+import 'dart:io' show File, Platform, Process;
import 'package:connectivity_plus/connectivity_plus.dart';
-import 'dart:io' show Platform, Process;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -21,18 +21,54 @@ import 'package:path_provider/path_provider.dart';
import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart';
+import 'services/ios_permissions_service.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/lib.dart' as rust_err;
import 'src/rust/frb_generated.dart';
import 'widgets/permission_state_banner.dart';
+import 'widgets/voice_platform.dart';
import 'widgets/voice_bar.dart';
import 'widgets/voice_compact.dart';
import 'widgets/voice_settings.dart';
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
+const MethodChannel _iosPlatformChannel = MethodChannel('chanora/ios_platform');
+
const Color _appSurfaceColor = Color(0xFFFFFBFE);
+const String _sileroVadAsset = 'assets/models/silero_vad.onnx';
+const String _tenVadAsset = 'assets/models/ten_vad.onnx';
+
+Future _copyBundledAssetToDocuments({
+ required String assetPath,
+ required String fileName,
+}) async {
+ final dir = await getApplicationDocumentsDirectory();
+ final file = File('${dir.path}/$fileName');
+ final data = await rootBundle.load(assetPath);
+ final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
+
+ if (await file.exists() && await file.length() == bytes.length) {
+ return file;
+ }
+
+ await file.writeAsBytes(bytes, flush: true);
+ return file;
+}
+
+Future _configureBundledVadModels() async {
+ final silero = await _copyBundledAssetToDocuments(
+ assetPath: _sileroVadAsset,
+ fileName: 'silero_vad.onnx',
+ );
+ await _copyBundledAssetToDocuments(
+ assetPath: _tenVadAsset,
+ fileName: 'ten_vad.onnx',
+ );
+ await rust.setVadModelPath(path: silero.path);
+}
+
/// Top padding for macOS to clear traffic-light buttons.
const double _macOSTrafficLightPad = 56.0;
@@ -78,15 +114,6 @@ String? _pttDisplayLabelForKey(LogicalKeyboardKey k) {
return fallback;
}
-/// True when the host is a touch-only mobile platform without a
-/// hardware keyboard. Mirrors the helpers in widgets/voice_bar.dart
-/// and widgets/voice_settings.dart so the AppBar + narrow-mode
-/// layout in main.dart can branch consistently.
-bool get _isTouchOnlyPttHost {
- if (kIsWeb) return false;
- return Platform.isIOS || Platform.isAndroid;
-}
-
/// Public version string shown in the About dialog. Resolved at
/// app init by combining a hardcoded semver baseline (kept in sync
/// with the git tag and pubspec.yaml's `version:` field) with the
@@ -124,6 +151,23 @@ Future main() async {
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
///
+rust.BridgeAudioRoute _parseBridgeAudioRoute(String s) {
+ switch (s) {
+ case 'Earpiece':
+ return rust.BridgeAudioRoute.earpiece;
+ case 'Speaker':
+ return rust.BridgeAudioRoute.speaker;
+ case 'WiredHeadset':
+ return rust.BridgeAudioRoute.wiredHeadset;
+ case 'BluetoothHfp':
+ return rust.BridgeAudioRoute.bluetoothHfp;
+ case 'BluetoothA2dp':
+ return rust.BridgeAudioRoute.bluetoothA2Dp;
+ default:
+ return rust.BridgeAudioRoute.unknown;
+ }
+}
+
/// Swift side (AppDelegate) posts route-change and interruption
/// events through `FlutterMethodChannel` named
/// `"chanora/ios_audio_lifecycle"`. This handler dispatches them to
@@ -134,7 +178,12 @@ void _wireIosAudioLifecycle() {
try {
switch (call.method) {
case 'handleRouteChange':
- rust.handleRouteChange();
+ final routeStr = call.arguments as String? ?? 'Unknown';
+ final route = _parseBridgeAudioRoute(routeStr);
+ rust.handleRouteChange(route: route);
+ break;
+ case 'handleMediaServicesReset':
+ rust.handleMediaServicesReset();
break;
case 'handleInterruptionBegan':
rust.handleInterruptionBegan();
@@ -144,6 +193,16 @@ void _wireIosAudioLifecycle() {
final shouldResume = call.arguments as bool? ?? false;
rust.handleInterruptionEnded(shouldResume: shouldResume);
break;
+ case 'handleWillResignActive':
+ case 'handleDidEnterBackground':
+ rust.handleInterruptionBegan();
+ break;
+ case 'handleWillEnterForeground':
+ rust.handleInterruptionEnded(shouldResume: true);
+ break;
+ case 'handleWillTerminate':
+ rust.handleInterruptionBegan();
+ break;
default:
// Unknown method — ignore gracefully rather than crashing.
break;
@@ -315,6 +374,7 @@ class _BetaHomeState extends State<_BetaHome> {
// (see AndroidPermissionsService for the platform branch).
final AndroidPermissionsService _androidPermissions =
AndroidPermissionsService();
+ final IosPermissionsService _iosPermissions = IosPermissionsService();
@override
void initState() {
@@ -325,9 +385,13 @@ class _BetaHomeState extends State<_BetaHome> {
// events as early as possible so the listen-only banner reflects
// the system state on first frame.
_androidPermissions.start();
+ unawaited(_iosPermissions.start());
_androidPermissions.recordAudioState.addListener(
_onRecordAudioPermissionChanged,
);
+ _iosPermissions.recordAudioState.addListener(
+ _onRecordAudioPermissionChanged,
+ );
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_requestRecordAudioOnStartup());
});
@@ -337,15 +401,34 @@ class _BetaHomeState extends State<_BetaHome> {
Future _requestRecordAudioOnStartup() async {
try {
- await _androidPermissions.ensureRecordAudio();
+ if (Platform.isAndroid) {
+ await _androidPermissions.ensureRecordAudio();
+ }
} catch (_) {
// Best-effort startup prompt only. The join path still gates on
// ensureRecordAudio() and applies the listen-only hard-mute policy.
}
}
+ ValueListenable
+ get _activeRecordAudioState => Platform.isIOS
+ ? _iosPermissions.recordAudioState
+ : _androidPermissions.recordAudioState;
+
+ Future _ensureActiveRecordAudio() {
+ return Platform.isIOS
+ ? _iosPermissions.ensureRecordAudio()
+ : _androidPermissions.ensureRecordAudio();
+ }
+
+ Future _openActivePermissionSettings() {
+ return Platform.isIOS
+ ? _iosPermissions.openAppSettings()
+ : _androidPermissions.openAppSettings();
+ }
+
void _onRecordAudioPermissionChanged() {
- if (_androidPermissions.recordAudioState.value ==
+ if (_activeRecordAudioState.value ==
AndroidRecordAudioPermissionState.granted) {
unawaited(_clearPermissionHardMute());
}
@@ -528,16 +611,16 @@ class _BetaHomeState extends State<_BetaHome> {
final messenger = ScaffoldMessenger.of(context);
if (began) {
messenger.showSnackBar(
- const SnackBar(
- content: Text('Audio interrupted by system (phone call)'),
+ SnackBar(
+ content: Text(AppL10n.of(context).iosAudioInterrupted),
duration: Duration(seconds: 3),
backgroundColor: Colors.orange,
),
);
} else if (shouldResume) {
messenger.showSnackBar(
- const SnackBar(
- content: Text('Audio resuming'),
+ SnackBar(
+ content: Text(AppL10n.of(context).iosAudioResuming),
duration: Duration(seconds: 2),
backgroundColor: Colors.green,
),
@@ -612,10 +695,14 @@ class _BetaHomeState extends State<_BetaHome> {
_androidPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged,
);
+ _iosPermissions.recordAudioState.removeListener(
+ _onRecordAudioPermissionChanged,
+ );
// SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
// late invokeMethod from the platform side cannot land on this
// disposed state.
_androidPermissions.stop();
+ _iosPermissions.stop();
super.dispose();
}
@@ -670,6 +757,14 @@ class _BetaHomeState extends State<_BetaHome> {
: l10n.microphonePermissionBody,
),
actions: [
+ if (Platform.isIOS && !isNetwork)
+ TextButton(
+ onPressed: () {
+ Navigator.pop(ctx);
+ unawaited(_openIosAppSettings());
+ },
+ child: Text(l10n.networkPermissionOpenSettings),
+ ),
if (Platform.isMacOS)
TextButton(
onPressed: () {
@@ -691,6 +786,15 @@ class _BetaHomeState extends State<_BetaHome> {
);
}
+ Future _openIosAppSettings() async {
+ try {
+ await _iosPlatformChannel.invokeMethod('openAppSettings');
+ } catch (_) {
+ // Best-effort affordance only; if iOS refuses the URL, the
+ // dialog still explained the missing microphone permission.
+ }
+ }
+
// ignore: unused_element
Future _setPtt(bool active, {bool reportError = true}) async {
try {
@@ -718,13 +822,9 @@ class _BetaHomeState extends State<_BetaHome> {
final next = !_outputMuted;
try {
await rust.setOutputMuted(muted: next);
- await rust.setHardMute(
- muted: next || _inputMuted || _hardMuteByPermission,
- );
if (!mounted) return;
setState(() {
_outputMuted = next;
- _hardMute = next || _inputMuted || _hardMuteByPermission;
});
} catch (e) {
if (!mounted) return;
@@ -770,7 +870,11 @@ class _BetaHomeState extends State<_BetaHome> {
// Trace: SDD-106 §1 (request timing), §2 (listen-only on denial),
// §3 (path to settings on permanent denial), §6
// (TransmitModeSelector clamp); SRS-209.
- final permState = await _androidPermissions.ensureRecordAudio();
+ final permState = Platform.isAndroid
+ ? await _androidPermissions.ensureRecordAudio()
+ : Platform.isIOS
+ ? await _iosPermissions.ensureRecordAudio()
+ : AndroidRecordAudioPermissionState.granted;
if (permState != AndroidRecordAudioPermissionState.granted) {
// Listen-only: clamp hard-mute. The permission_state_banner
// surfaces the path-to-grant; the user can re-attempt at any
@@ -800,6 +904,7 @@ class _BetaHomeState extends State<_BetaHome> {
});
}
}
+ await _configureBundledVadModels();
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
if (!mounted) return;
unawaited(_onRefresh());
@@ -930,19 +1035,42 @@ class _BetaHomeState extends State<_BetaHome> {
/// Narrow-mode voice controls modal sheet (Plan E status chip
/// trigger). On mobile this is the **single** voice-controls
/// surface: route picker + inline mode radio + inline release-tail
- /// slider + level meter + stats + (desktop-only) capability badge.
- /// Zero navigation depth \u2014 no nested dialog.
+ /// slider + level meter + stats + audio processing + (desktop-only)
+ /// capability badge. Zero navigation depth — no nested dialog.
Future _onOpenVoiceDetailsSheet() async {
+ // Load current audio processing config for the sheet.
+ rust.BridgeAudioProcessingConfig audioConfig;
+ try {
+ audioConfig = await rust.getAudioProcessingConfig();
+ } catch (_) {
+ audioConfig = const rust.BridgeAudioProcessingConfig(
+ route: rust.BridgeAudioRoute.unknown,
+ iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
+ processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
+ vadBackend: rust.BridgeVadBackend.sileroOnnx,
+ aec: rust.BridgeEffectOwner.platform,
+ ns: rust.BridgeEffectOwner.platform,
+ agc: rust.BridgeEffectOwner.platform,
+ hpfEnabled: true,
+ limiterEnabled: true,
+ vadHangoverMs: 500,
+ vadPreRollMs: 160,
+ vadMinTxMs: 200,
+ debugWavDumpEnabled: false,
+ );
+ }
+ if (!mounted) return;
+
await showVoiceDetailsSheet(
context,
- audioStats: _audioStats,
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
- isTouchOnly: _isTouchOnlyPttHost,
+ isTouchOnly: isTouchOnlyPttHost,
+ initialAudioConfig: audioConfig,
onModeChanged: (mode) async {
try {
await rust.setTransmitMode(mode: mode);
@@ -963,21 +1091,55 @@ class _BetaHomeState extends State<_BetaHome> {
setState(() => _error = e.toString());
}
},
+ onAudioConfigChanged: (config) async {
+ try {
+ await rust.setAudioProcessingConfig(config: config);
+ } catch (e) {
+ if (!mounted) return;
+ setState(() => _error = e.toString());
+ }
+ },
);
}
Future _onOpenVoiceSettings() async {
+ // Load the current audio processing config before opening the dialog.
+ rust.BridgeAudioProcessingConfig audioConfig;
+ try {
+ audioConfig = await rust.getAudioProcessingConfig();
+ } catch (_) {
+ // If not connected yet, use a sensible default.
+ audioConfig = const rust.BridgeAudioProcessingConfig(
+ route: rust.BridgeAudioRoute.unknown,
+ iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
+ processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
+ vadBackend: rust.BridgeVadBackend.sileroOnnx,
+ aec: rust.BridgeEffectOwner.platform,
+ ns: rust.BridgeEffectOwner.platform,
+ agc: rust.BridgeEffectOwner.platform,
+ hpfEnabled: true,
+ limiterEnabled: true,
+ vadHangoverMs: 500,
+ vadPreRollMs: 160,
+ vadMinTxMs: 200,
+ debugWavDumpEnabled: false,
+ );
+ }
+ if (!mounted) return;
+
final result = await showDialog(
context: context,
builder: (ctx) => VoiceSettingsDialog(
initialMode: _transmitMode,
initialReleaseTailMs: _releaseTailMs,
+ initialAudioConfig: audioConfig,
),
);
if (result == null) return;
try {
await rust.setTransmitMode(mode: result.mode);
await rust.setReleaseTailMs(ms: result.releaseTailMs);
+ await rust.setAudioProcessingConfig(config: result.audioConfig);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
@@ -1083,6 +1245,27 @@ class _BetaHomeState extends State<_BetaHome> {
),
),
actions: [
+ TextButton(
+ onPressed: () async {
+ try {
+ final path = await _writeDiagnosticExport(text);
+ if (!ctx.mounted) return;
+ Navigator.of(ctx).pop();
+ if (!mounted) return;
+ ScaffoldMessenger.of(this.context).showSnackBar(
+ SnackBar(content: Text(l10n.diagnosticsSaved(path))),
+ );
+ } catch (e) {
+ if (!ctx.mounted) return;
+ Navigator.of(ctx).pop();
+ if (!mounted) return;
+ ScaffoldMessenger.of(this.context).showSnackBar(
+ SnackBar(content: Text(l10n.statusError(e.toString()))),
+ );
+ }
+ },
+ child: Text(l10n.diagnosticsSaveAction),
+ ),
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: text));
@@ -1100,6 +1283,18 @@ class _BetaHomeState extends State<_BetaHome> {
);
}
+ Future _writeDiagnosticExport(String text) async {
+ final dir = await getApplicationDocumentsDirectory();
+ final stamp = DateTime.now()
+ .toUtc()
+ .toIso8601String()
+ .replaceAll(':', '-')
+ .replaceAll('.', '-');
+ final file = File('${dir.path}/chanora-diagnostics-$stamp.txt');
+ await file.writeAsString(text, flush: true);
+ return file.path;
+ }
+
Future _onConfigurePtt(BuildContext context) async {
// On the Linux GNOME-Wayland portal backend, the portal hosts
// its own system-managed binding dialog (gen2 v0.9.3 / Q3a).
@@ -1399,26 +1594,33 @@ class _BetaHomeState extends State<_BetaHome> {
const SizedBox(height: 12),
if (_phase == _Phase.idle) ...[
Expanded(
- child: SingleChildScrollView(
- keyboardDismissBehavior:
- ScrollViewKeyboardDismissBehavior.onDrag,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- _ConnectForm(
- hostCtl: _hostCtl,
- nickCtl: _nickCtl,
- passwordCtl: _passwordCtl,
- onConnect: () => _onConnect(),
- onAddBookmark: _onAddCurrentBookmark,
- ),
- const SizedBox(height: 16),
- _BookmarkList(
- bookmarks: _bookmarks,
- onConnect: _onUseBookmark,
- onDelete: _onDeleteBookmark,
- ),
- ],
+ child: AnimatedPadding(
+ duration: const Duration(milliseconds: 180),
+ curve: Curves.easeOut,
+ padding: EdgeInsets.only(
+ bottom: MediaQuery.viewInsetsOf(ctx).bottom,
+ ),
+ child: SingleChildScrollView(
+ keyboardDismissBehavior:
+ ScrollViewKeyboardDismissBehavior.onDrag,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _ConnectForm(
+ hostCtl: _hostCtl,
+ nickCtl: _nickCtl,
+ passwordCtl: _passwordCtl,
+ onConnect: () => _onConnect(),
+ onAddBookmark: _onAddCurrentBookmark,
+ ),
+ const SizedBox(height: 16),
+ _BookmarkList(
+ bookmarks: _bookmarks,
+ onConnect: _onUseBookmark,
+ onDelete: _onDeleteBookmark,
+ ),
+ ],
+ ),
),
),
),
@@ -1450,11 +1652,14 @@ class _BetaHomeState extends State<_BetaHome> {
onConfigure: _onOpenVoiceSettings,
onPttHeldChanged: _onOnscreenPttHeldChanged,
);
- // SDD-106 §2/§3 + SRS-209: listen-only banner.
- // Self-hides on granted / unknown / non-Android.
- final permissionBanner = PermissionStateBanner(
- service: _androidPermissions,
- );
+ // SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
+ // banner. Self-hides on granted / unknown.
+ final permissionBanner =
+ PermissionStateBanner.fromCallbacks(
+ recordAudioState: _activeRecordAudioState,
+ ensureRecordAudio: _ensureActiveRecordAudio,
+ openAppSettings: _openActivePermissionSettings,
+ );
final snapshotView = _SnapshotView(
snapshot: _snapshot!,
audioStats: _audioStats,
@@ -1501,7 +1706,7 @@ class _BetaHomeState extends State<_BetaHome> {
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
audioStats: _audioStats,
- isTouchOnly: _isTouchOnlyPttHost,
+ isTouchOnly: isTouchOnlyPttHost,
onTap: () => _onOpenVoiceDetailsSheet(),
),
if (_inChannel &&
@@ -1552,7 +1757,10 @@ class _BetaHomeState extends State<_BetaHome> {
return Scaffold(
appBar: AppBar(title: headerTitle, actions: headerActions),
- body: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
+ body: SafeArea(
+ top: false,
+ child: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
+ ),
);
}
}
@@ -1961,146 +2169,6 @@ class _BookmarkList extends StatelessWidget {
/// Driven by the `BridgeEvent::PttCapability` stream published by
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
/// updates the props on each transition.
-class PttCapabilityBadge extends StatelessWidget {
- /// Construct a badge.
- const PttCapabilityBadge({
- super.key,
- required this.level,
- required this.backendId,
- required this.boundInputClass,
- });
-
- /// Resolved capability level as the bridge emits it
- /// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
- /// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
- final String level;
-
- /// Stable backend identifier (`focused`, `windows-raw-input`, …).
- final String backendId;
-
- /// Privacy-safe input class (`keyboard`, `mouse-side-button`,
- /// or empty when no binding is set).
- final String boundInputClass;
-
- bool get _isFocused => level == 'L0Focused';
-
- String _explainBodyForPlatform(AppL10n l10n) {
- // Use `defaultTargetPlatform` rather than `Theme.of(context).platform`
- // because the latter is influenced by debug platform overrides
- // that callers may toggle in dev mode. We want the badge's
- // explanation to match the actual host OS.
- switch (defaultTargetPlatform) {
- case TargetPlatform.windows:
- return l10n.pttCapabilityExplainGoGlobalWindows;
- case TargetPlatform.macOS:
- return l10n.pttCapabilityExplainGoGlobalMacos;
- case TargetPlatform.linux:
- return l10n.pttCapabilityExplainGoGlobalLinux;
- case TargetPlatform.iOS:
- return l10n.pttCapabilityExplainGoGlobalIos;
- default:
- return l10n.pttCapabilityExplainGoGlobalGeneric;
- }
- }
-
- void _openExplanationSheet(BuildContext context) {
- final l10n = AppL10n.of(context);
- showModalBottomSheet(
- context: context,
- showDragHandle: true,
- builder: (sheetContext) {
- final theme = Theme.of(sheetContext);
- return SafeArea(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- l10n.pttCapabilityExplainTitle,
- style: theme.textTheme.titleMedium,
- ),
- const SizedBox(height: 12),
- Text(
- l10n.pttCapabilityExplainFocusedHeading,
- style: theme.textTheme.titleSmall,
- ),
- const SizedBox(height: 4),
- Text(
- l10n.pttCapabilityExplainFocusedBody,
- style: theme.textTheme.bodyMedium,
- ),
- const SizedBox(height: 16),
- Text(
- _explainBodyForPlatform(l10n),
- style: theme.textTheme.bodyMedium,
- ),
- const SizedBox(height: 16),
- Align(
- alignment: AlignmentDirectional.centerEnd,
- child: TextButton(
- onPressed: () => Navigator.of(sheetContext).pop(),
- child: Text(l10n.closeAction),
- ),
- ),
- ],
- ),
- ),
- );
- },
- );
- }
-
- @override
- Widget build(BuildContext context) {
- final l10n = AppL10n.of(context);
- final theme = Theme.of(context);
- final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
- final tooltipMessage = boundInputClass.isEmpty
- ? badgeLabel
- : '$badgeLabel\n($boundInputClass)';
- return Padding(
- padding: const EdgeInsets.only(bottom: 6),
- child: Tooltip(
- message: tooltipMessage,
- child: Row(
- children: [
- Icon(
- _isFocused ? Icons.crop_free : Icons.public,
- size: 14,
- color: theme.colorScheme.onSurfaceVariant,
- ),
- const SizedBox(width: 4),
- Expanded(
- child: Text(
- badgeLabel,
- style: theme.textTheme.bodySmall?.copyWith(
- color: theme.colorScheme.onSurfaceVariant,
- ),
- ),
- ),
- // Info icon only for L0Focused — the explanation sheet
- // tells the user why their PTT may not work outside the
- // app window and how to grant the permission. There is
- // intentionally NO 'Configure' button here: the single
- // configuration entry point is the Voice Bar's
- // settings gear (onConfigure on `VoiceBar`). Having two
- // identical bind-key entry points just confuses users.
- if (_isFocused)
- IconButton(
- icon: const Icon(Icons.info_outline, size: 16),
- tooltip: l10n.pttCapabilityExplainTitle,
- visualDensity: VisualDensity.compact,
- onPressed: () => _openExplanationSheet(context),
- ),
- ],
- ),
- ),
- );
- }
-}
-
class _SnapshotView extends StatelessWidget {
const _SnapshotView({
required this.snapshot,
diff --git a/apps/chanora_flutter/lib/services/ios_permissions_service.dart b/apps/chanora_flutter/lib/services/ios_permissions_service.dart
new file mode 100644
index 0000000..555191a
--- /dev/null
+++ b/apps/chanora_flutter/lib/services/ios_permissions_service.dart
@@ -0,0 +1,114 @@
+/// iOS microphone permission integration for AVAudioSession.
+///
+/// Trace:
+/// - SRS-164 (iOS system permission presentation and settings path).
+/// - SRS-114 / SRS-138 (iOS platform-service behaviour for audio).
+library;
+
+import 'dart:async';
+import 'dart:developer' as developer;
+import 'dart:io' show Platform;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+import 'android_permissions_service.dart';
+
+@visibleForTesting
+const String iosPlatformChannelName = 'chanora/ios_platform';
+
+@visibleForTesting
+const String methodGetMicrophonePermissionState =
+ 'getMicrophonePermissionState';
+
+@visibleForTesting
+const String methodRequestMicrophonePermission = 'requestMicrophonePermission';
+
+@visibleForTesting
+const String methodIosOpenAppSettings = 'openAppSettings';
+
+/// Dart-side integration for iOS microphone permission state.
+class IosPermissionsService {
+ IosPermissionsService({MethodChannel? channel})
+ : _channel =
+ channel ??
+ (_isIOS ? const MethodChannel(iosPlatformChannelName) : null);
+
+ static bool get _isIOS {
+ if (kIsWeb) return false;
+ return Platform.isIOS;
+ }
+
+ final MethodChannel? _channel;
+
+ final ValueNotifier _state =
+ ValueNotifier(
+ _isIOS
+ ? AndroidRecordAudioPermissionState.unknown
+ : AndroidRecordAudioPermissionState.granted,
+ );
+
+ ValueListenable get recordAudioState =>
+ _state;
+
+ Future start() async {
+ final ch = _channel;
+ if (ch == null) return;
+ try {
+ _state.value = _parseState(
+ await ch.invokeMethod(methodGetMicrophonePermissionState),
+ );
+ } catch (_) {
+ _state.value = AndroidRecordAudioPermissionState.unknown;
+ }
+ }
+
+ void stop() {}
+
+ Future ensureRecordAudio() async {
+ final ch = _channel;
+ if (ch == null) return AndroidRecordAudioPermissionState.granted;
+ try {
+ final state = _parseState(
+ await ch.invokeMethod(methodRequestMicrophonePermission),
+ );
+ _state.value = state;
+ return state;
+ } catch (_) {
+ return _state.value;
+ }
+ }
+
+ Future openAppSettings() async {
+ final ch = _channel;
+ if (ch == null) return;
+ try {
+ await ch.invokeMethod(methodIosOpenAppSettings);
+ } catch (e, st) {
+ developer.log(
+ 'openAppSettings failed',
+ name: 'IosPermissionsService',
+ error: e,
+ stackTrace: st,
+ );
+ }
+ }
+
+ @visibleForTesting
+ void dispose() {
+ _state.dispose();
+ }
+}
+
+AndroidRecordAudioPermissionState _parseState(String? raw) {
+ switch (raw) {
+ case 'Granted':
+ return AndroidRecordAudioPermissionState.granted;
+ case 'Denied':
+ return AndroidRecordAudioPermissionState.permanentlyDenied;
+ case 'NotDetermined':
+ return AndroidRecordAudioPermissionState.denied;
+ default:
+ return AndroidRecordAudioPermissionState.unknown;
+ }
+}
diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart
index 10241ce..cde73e3 100644
--- a/apps/chanora_flutter/lib/src/rust/api.dart
+++ b/apps/chanora_flutter/lib/src/rust/api.dart
@@ -9,8 +9,8 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
part 'api.freezed.dart';
-// These functions are ignored because they are not marked as `pub`: `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `transmit_mode_from_u8`
-// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
+// These 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 functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
/// Return the platform-conventional log-file path as a string, or
@@ -43,7 +43,12 @@ Future disconnect() => RustLib.instance.api.crateApiDisconnect();
Future isConnected() => RustLib.instance.api.crateApiIsConnected();
/// Handle iOS AVAudioSession route changes (SDD-100).
-void handleRouteChange() => RustLib.instance.api.crateApiHandleRouteChange();
+void handleRouteChange({required BridgeAudioRoute route}) =>
+ RustLib.instance.api.crateApiHandleRouteChange(route: route);
+
+/// Handle iOS AVAudioSession media-services reset.
+void handleMediaServicesReset() =>
+ RustLib.instance.api.crateApiHandleMediaServicesReset();
/// Handle iOS AVAudioSession interruption begin (SDD-101).
void handleInterruptionBegan() =>
@@ -221,6 +226,330 @@ Stream eventsStream() =>
Future audioStats() =>
RustLib.instance.api.crateApiAudioStats();
+/// Apply the P1 audio-processing config.
+Future setAudioProcessingConfig({
+ required BridgeAudioProcessingConfig config,
+}) async {
+ _lastAppliedAudioConfig = config;
+ return RustLib.instance.api.crateApiSetAudioProcessingConfig(config: config);
+}
+
+/// 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);
+
+/// Enable or disable audio debug WAV dumping.
+Future enableAudioDebugWavDump({required bool enabled}) =>
+ RustLib.instance.api.crateApiEnableAudioDebugWavDump(enabled: enabled);
+
+/// Select the iOS voice-processing mode.
+Future setIosVoiceProcessingMode({
+ required BridgeIosVoiceProcessingMode mode,
+}) => RustLib.instance.api.crateApiSetIosVoiceProcessingMode(mode: mode);
+
+/// Bridge processing backend.
+enum BridgeAudioBackend {
+ /// Platform voice processing.
+ platformVoiceProcessing,
+
+ /// Sonora backend.
+ sonora,
+
+ /// WebRTC APM backend.
+ webrtcApm,
+
+ /// No-op backend.
+ noop,
+}
+
+/// P1 audio-processing configuration DTO.
+class BridgeAudioProcessingConfig {
+ /// Route class.
+ final BridgeAudioRoute route;
+
+ /// iOS voice-processing mode.
+ final BridgeIosVoiceProcessingMode iosMode;
+
+ /// Processing backend.
+ final BridgeAudioBackend processingBackend;
+
+ /// VAD backend.
+ final BridgeVadBackend vadBackend;
+
+ /// AEC owner.
+ final BridgeEffectOwner aec;
+
+ /// Noise suppression owner.
+ final BridgeEffectOwner ns;
+
+ /// AGC owner.
+ final BridgeEffectOwner agc;
+
+ /// High-pass filter enabled.
+ final bool hpfEnabled;
+
+ /// Limiter enabled.
+ final bool limiterEnabled;
+
+ /// VAD hangover in ms.
+ final int vadHangoverMs;
+
+ /// VAD pre-roll in ms.
+ final int vadPreRollMs;
+
+ /// Minimum transmit duration in ms.
+ final int vadMinTxMs;
+
+ /// Debug WAV dump enabled.
+ final bool debugWavDumpEnabled;
+
+ const BridgeAudioProcessingConfig({
+ required this.route,
+ required this.iosMode,
+ required this.processingBackend,
+ required this.vadBackend,
+ required this.aec,
+ required this.ns,
+ required this.agc,
+ required this.hpfEnabled,
+ required this.limiterEnabled,
+ required this.vadHangoverMs,
+ required this.vadPreRollMs,
+ required this.vadMinTxMs,
+ required this.debugWavDumpEnabled,
+ });
+
+ @override
+ int get hashCode =>
+ route.hashCode ^
+ iosMode.hashCode ^
+ processingBackend.hashCode ^
+ vadBackend.hashCode ^
+ aec.hashCode ^
+ ns.hashCode ^
+ agc.hashCode ^
+ hpfEnabled.hashCode ^
+ limiterEnabled.hashCode ^
+ vadHangoverMs.hashCode ^
+ vadPreRollMs.hashCode ^
+ vadMinTxMs.hashCode ^
+ debugWavDumpEnabled.hashCode;
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is BridgeAudioProcessingConfig &&
+ runtimeType == other.runtimeType &&
+ route == other.route &&
+ iosMode == other.iosMode &&
+ processingBackend == other.processingBackend &&
+ vadBackend == other.vadBackend &&
+ aec == other.aec &&
+ ns == other.ns &&
+ agc == other.agc &&
+ hpfEnabled == other.hpfEnabled &&
+ limiterEnabled == other.limiterEnabled &&
+ vadHangoverMs == other.vadHangoverMs &&
+ vadPreRollMs == other.vadPreRollMs &&
+ vadMinTxMs == other.vadMinTxMs &&
+ debugWavDumpEnabled == other.debugWavDumpEnabled;
+}
+
+/// P1 audio-processing stats DTO.
+class BridgeAudioProcessingStats {
+ /// Input dBFS.
+ final double inputDbfs;
+
+ /// Render dBFS.
+ final double renderDbfs;
+
+ /// Processed capture dBFS.
+ final double processedDbfs;
+
+ /// Latest VAD probability.
+ final double vadProbability;
+
+ /// VAD active.
+ final bool vadActive;
+
+ /// Currently transmitting.
+ final bool transmitting;
+
+ /// VAD backend.
+ final BridgeVadBackend vadBackend;
+
+ /// Fallback VAD active.
+ final bool vadFallbackActive;
+
+ /// Processing backend.
+ final BridgeAudioBackend processingBackend;
+
+ /// iOS voice-processing mode.
+ final BridgeIosVoiceProcessingMode iosVoiceProcessingMode;
+
+ /// Audio route.
+ final BridgeAudioRoute audioRoute;
+
+ /// Actual sample rate.
+ final int actualSampleRateHz;
+
+ /// Actual IO buffer frames.
+ final int actualIoBufferFrames;
+
+ /// Input overruns.
+ final BigInt inputOverruns;
+
+ /// Output underruns.
+ final BigInt outputUnderruns;
+
+ /// Callback xruns.
+ final BigInt callbackXruns;
+
+ /// Clipped samples.
+ final BigInt clippedSamples;
+
+ /// Sonora enabled.
+ final bool sonoraEnabled;
+
+ /// Platform voice processing enabled.
+ final bool platformVoiceProcessingEnabled;
+
+ const BridgeAudioProcessingStats({
+ required this.inputDbfs,
+ required this.renderDbfs,
+ required this.processedDbfs,
+ required this.vadProbability,
+ required this.vadActive,
+ required this.transmitting,
+ required this.vadBackend,
+ required this.vadFallbackActive,
+ required this.processingBackend,
+ required this.iosVoiceProcessingMode,
+ required this.audioRoute,
+ required this.actualSampleRateHz,
+ required this.actualIoBufferFrames,
+ required this.inputOverruns,
+ required this.outputUnderruns,
+ required this.callbackXruns,
+ required this.clippedSamples,
+ required this.sonoraEnabled,
+ required this.platformVoiceProcessingEnabled,
+ });
+
+ @override
+ int get hashCode =>
+ inputDbfs.hashCode ^
+ renderDbfs.hashCode ^
+ processedDbfs.hashCode ^
+ vadProbability.hashCode ^
+ vadActive.hashCode ^
+ transmitting.hashCode ^
+ vadBackend.hashCode ^
+ vadFallbackActive.hashCode ^
+ processingBackend.hashCode ^
+ iosVoiceProcessingMode.hashCode ^
+ audioRoute.hashCode ^
+ actualSampleRateHz.hashCode ^
+ actualIoBufferFrames.hashCode ^
+ inputOverruns.hashCode ^
+ outputUnderruns.hashCode ^
+ callbackXruns.hashCode ^
+ clippedSamples.hashCode ^
+ sonoraEnabled.hashCode ^
+ platformVoiceProcessingEnabled.hashCode;
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is BridgeAudioProcessingStats &&
+ runtimeType == other.runtimeType &&
+ inputDbfs == other.inputDbfs &&
+ renderDbfs == other.renderDbfs &&
+ processedDbfs == other.processedDbfs &&
+ vadProbability == other.vadProbability &&
+ vadActive == other.vadActive &&
+ transmitting == other.transmitting &&
+ vadBackend == other.vadBackend &&
+ vadFallbackActive == other.vadFallbackActive &&
+ processingBackend == other.processingBackend &&
+ iosVoiceProcessingMode == other.iosVoiceProcessingMode &&
+ audioRoute == other.audioRoute &&
+ actualSampleRateHz == other.actualSampleRateHz &&
+ actualIoBufferFrames == other.actualIoBufferFrames &&
+ inputOverruns == other.inputOverruns &&
+ outputUnderruns == other.outputUnderruns &&
+ callbackXruns == other.callbackXruns &&
+ clippedSamples == other.clippedSamples &&
+ sonoraEnabled == other.sonoraEnabled &&
+ platformVoiceProcessingEnabled ==
+ other.platformVoiceProcessingEnabled;
+}
+
+/// Bridge route class for P1 audio-processing policy.
+enum BridgeAudioRoute {
+ /// Built-in speakerphone.
+ speaker,
+
+ /// Built-in receiver/earpiece.
+ earpiece,
+
+ /// Wired or USB headset.
+ wiredHeadset,
+
+ /// Bluetooth HFP duplex route.
+ bluetoothHfp,
+
+ /// Bluetooth A2DP output-only route.
+ bluetoothA2Dp,
+
+ /// Unknown route.
+ unknown,
+}
+
/// Statistics from the audio engine.
class BridgeAudioStats {
/// Number of Opus frames sent since audio started.
@@ -402,6 +731,24 @@ class BridgeClient {
isServerQuery == other.isServerQuery;
}
+/// Bridge effect owner for AEC/NS/AGC.
+enum BridgeEffectOwner {
+ /// Platform-owned effect.
+ platform,
+
+ /// Sonora-owned effect.
+ sonora,
+
+ /// WebRTC APM-owned effect.
+ webrtcApm,
+
+ /// Conservative route-managed setting.
+ conservative,
+
+ /// Disabled.
+ off,
+}
+
@freezed
sealed class BridgeEvent with _$BridgeEvent {
const BridgeEvent._();
@@ -536,6 +883,15 @@ sealed class BridgeEvent with _$BridgeEvent {
}) = BridgeEvent_PermissionState;
}
+/// Bridge iOS voice-processing mode.
+enum BridgeIosVoiceProcessingMode {
+ /// Shipping VPIO path.
+ platformVoiceProcessing,
+
+ /// Experimental Sonora path.
+ sonoraExperimental,
+}
+
/// Coarse OS-reported network state. Mirrors
/// [`chanora_core::NetworkState`] across the bridge.
enum BridgeNetworkState {
@@ -635,25 +991,69 @@ enum BridgeTransmitMode {
voiceActivity,
}
+/// Bridge VAD backend.
+enum BridgeVadBackend {
+ /// Silero ONNX VAD.
+ sileroOnnx,
+
+ /// TEN VAD.
+ tenVad,
+
+ /// WebRTC fallback VAD.
+ webrtcVad,
+
+ /// Debug energy VAD.
+ energyDebug,
+
+ /// VAD disabled.
+ disabled,
+}
+
/// Bridge mirror of stable join error/status codes.
enum BridgeVoiceJoinErrorCode {
+ /// Duplicate same-target join intent was coalesced.
duplicateSameTargetCoalesced,
+
+ /// A different target was requested while one is already pending.
joinAlreadyPendingDifferentTarget,
+
+ /// Join denied by server policy/permission.
joinDenied,
+
+ /// Join failed due to protocol-level error.
joinProtocolFailure,
+
+ /// Join failed due to transport/network error.
joinNetworkFailure,
+
+ /// Join timed out awaiting confirmation.
joinTimeout,
+
+ /// Pending join was superseded by user leave.
joinSupersededByLeave,
+
+ /// Stale join outcome was ignored.
joinStaleOutcomeIgnored,
+
+ /// Authoritative membership reconciled to different channel.
joinReconciledDifferentChannel,
+
+ /// Join command was rejected before send acceptance.
joinCommandRejectedBeforeSend,
+
+ /// Join intent rejected while reducer synchronizing.
joinCannotStartWhileSynchronizing,
}
/// Bridge mirror of core join projection sync state.
enum BridgeVoiceJoinSyncState {
+ /// Reducer is ready to accept channel actions.
ready,
+
+ /// Reducer is waiting on initial snapshot reconciliation.
synchronizingInitialSnapshot,
+
+ /// Reducer is waiting on reconnect snapshot reconciliation.
synchronizingReconnect,
}
diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart
index 7ad100b..9959c52 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 => 1322894465;
+ int get rustContentHash => -1835973251;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -81,6 +81,8 @@ class RustLib extends BaseEntrypoint {
abstract class RustLibApi extends BaseApi {
Future crateApiAddBookmark({required BridgeBookmark b});
+ Future crateApiAudioProcessingStats();
+
Future crateApiAudioStats();
Future crateApiBridgeInit();
@@ -95,6 +97,8 @@ abstract class RustLibApi extends BaseApi {
Future crateApiDisconnect();
+ Future crateApiEnableAudioDebugWavDump({required bool enabled});
+
Stream crateApiEventsStream();
String crateApiExportDiagnostics();
@@ -109,7 +113,9 @@ abstract class RustLibApi extends BaseApi {
void crateApiHandleInterruptionEnded({required bool shouldResume});
- void crateApiHandleRouteChange();
+ void crateApiHandleMediaServicesReset();
+
+ void crateApiHandleRouteChange({required BridgeAudioRoute route});
Future crateApiInitStorage({required String dir});
@@ -126,10 +132,18 @@ abstract class RustLibApi extends BaseApi {
Future<(String, String, String)> crateApiPttDescriptor();
+ Future crateApiSetAudioProcessingConfig({
+ required BridgeAudioProcessingConfig config,
+ });
+
Future crateApiSetHardMute({required bool muted});
Future crateApiSetInputMuted({required bool muted});
+ Future crateApiSetIosVoiceProcessingMode({
+ required BridgeIosVoiceProcessingMode mode,
+ });
+
void crateApiSetNetworkState({required BridgeNetworkState state});
Future crateApiSetOutputGain({required double gain});
@@ -147,6 +161,8 @@ abstract class RustLibApi extends BaseApi {
Future crateApiSetTransmitMode({required BridgeTransmitMode mode});
+ Future crateApiSetVadModelPath({required String path});
+
Future crateApiSnapshot();
Future crateApiUpdateBookmark({required BridgeBookmark b});
@@ -196,7 +212,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
const TaskConstMeta(debugName: "add_bookmark", argNames: ["b"]);
@override
- Future crateApiAudioStats() {
+ Future crateApiAudioProcessingStats() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
@@ -208,6 +224,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
port: port_,
);
},
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_bridge_audio_processing_stats,
+ decodeErrorData: sse_decode_bridge_error,
+ ),
+ constMeta: kCrateApiAudioProcessingStatsConstMeta,
+ argValues: [],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateApiAudioProcessingStatsConstMeta =>
+ const TaskConstMeta(debugName: "audio_processing_stats", argNames: []);
+
+ @override
+ Future crateApiAudioStats() {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 3,
+ port: port_,
+ );
+ },
codec: SseCodec(
decodeSuccessData: sse_decode_bridge_audio_stats,
decodeErrorData: sse_decode_bridge_error,
@@ -231,7 +274,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 3,
+ funcId: 4,
port: port_,
);
},
@@ -265,7 +308,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 4,
+ funcId: 5,
port: port_,
);
},
@@ -295,7 +338,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 5,
+ funcId: 6,
port: port_,
);
},
@@ -322,7 +365,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 6,
+ funcId: 7,
port: port_,
);
},
@@ -340,6 +383,37 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiDisconnectConstMeta =>
const TaskConstMeta(debugName: "disconnect", argNames: []);
+ @override
+ Future crateApiEnableAudioDebugWavDump({required bool enabled}) {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ sse_encode_bool(enabled, serializer);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 8,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_bridge_error,
+ ),
+ constMeta: kCrateApiEnableAudioDebugWavDumpConstMeta,
+ argValues: [enabled],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateApiEnableAudioDebugWavDumpConstMeta =>
+ const TaskConstMeta(
+ debugName: "enable_audio_debug_wav_dump",
+ argNames: ["enabled"],
+ );
+
@override
Stream crateApiEventsStream() {
final sink = RustStreamSink();
@@ -352,7 +426,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 7,
+ funcId: 9,
port: port_,
);
},
@@ -378,7 +452,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
- return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 8)!;
+ return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 10)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -403,7 +477,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 9,
+ funcId: 11,
port: port_,
);
},
@@ -430,7 +504,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 10,
+ funcId: 12,
port: port_,
);
},
@@ -457,7 +531,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 11,
+ funcId: 13,
port: port_,
);
},
@@ -481,7 +555,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
- return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12)!;
+ return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -504,7 +578,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bool(shouldResume, serializer);
- return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13)!;
+ return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -524,26 +598,54 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
@override
- void crateApiHandleRouteChange() {
+ void crateApiHandleMediaServicesReset() {
return handler.executeSync(
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
- return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
+ return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: null,
),
- constMeta: kCrateApiHandleRouteChangeConstMeta,
+ constMeta: kCrateApiHandleMediaServicesResetConstMeta,
argValues: [],
apiImpl: this,
),
);
}
- TaskConstMeta get kCrateApiHandleRouteChangeConstMeta =>
- const TaskConstMeta(debugName: "handle_route_change", argNames: []);
+ TaskConstMeta get kCrateApiHandleMediaServicesResetConstMeta =>
+ const TaskConstMeta(
+ debugName: "handle_media_services_reset",
+ argNames: [],
+ );
+
+ @override
+ void crateApiHandleRouteChange({required BridgeAudioRoute route}) {
+ return handler.executeSync(
+ SyncTask(
+ callFfi: () {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ sse_encode_bridge_audio_route(route, serializer);
+ return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: null,
+ ),
+ constMeta: kCrateApiHandleRouteChangeConstMeta,
+ argValues: [route],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateApiHandleRouteChangeConstMeta => const TaskConstMeta(
+ debugName: "handle_route_change",
+ argNames: ["route"],
+ );
@override
Future crateApiInitStorage({required String dir}) {
@@ -555,7 +657,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 15,
+ funcId: 18,
port: port_,
);
},
@@ -582,7 +684,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 16,
+ funcId: 19,
port: port_,
);
},
@@ -609,7 +711,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 17,
+ funcId: 20,
port: port_,
);
},
@@ -633,7 +735,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
- return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
+ return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -663,7 +765,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 19,
+ funcId: 22,
port: port_,
);
},
@@ -692,7 +794,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 20,
+ funcId: 23,
port: port_,
);
},
@@ -710,6 +812,42 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiPttDescriptorConstMeta =>
const TaskConstMeta(debugName: "ptt_descriptor", argNames: []);
+ @override
+ Future crateApiSetAudioProcessingConfig({
+ required BridgeAudioProcessingConfig config,
+ }) {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ sse_encode_box_autoadd_bridge_audio_processing_config(
+ config,
+ serializer,
+ );
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 24,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_bridge_error,
+ ),
+ constMeta: kCrateApiSetAudioProcessingConfigConstMeta,
+ argValues: [config],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateApiSetAudioProcessingConfigConstMeta =>
+ const TaskConstMeta(
+ debugName: "set_audio_processing_config",
+ argNames: ["config"],
+ );
+
@override
Future crateApiSetHardMute({required bool muted}) {
return handler.executeNormal(
@@ -720,7 +858,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 21,
+ funcId: 25,
port: port_,
);
},
@@ -748,7 +886,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 22,
+ funcId: 26,
port: port_,
);
},
@@ -766,6 +904,39 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiSetInputMutedConstMeta =>
const TaskConstMeta(debugName: "set_input_muted", argNames: ["muted"]);
+ @override
+ Future crateApiSetIosVoiceProcessingMode({
+ required BridgeIosVoiceProcessingMode mode,
+ }) {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ sse_encode_bridge_ios_voice_processing_mode(mode, serializer);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 27,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_bridge_error,
+ ),
+ constMeta: kCrateApiSetIosVoiceProcessingModeConstMeta,
+ argValues: [mode],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateApiSetIosVoiceProcessingModeConstMeta =>
+ const TaskConstMeta(
+ debugName: "set_ios_voice_processing_mode",
+ argNames: ["mode"],
+ );
+
@override
void crateApiSetNetworkState({required BridgeNetworkState state}) {
return handler.executeSync(
@@ -773,7 +944,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_network_state(state, serializer);
- return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
+ return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -799,7 +970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 24,
+ funcId: 29,
port: port_,
);
},
@@ -827,7 +998,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 25,
+ funcId: 30,
port: port_,
);
},
@@ -855,7 +1026,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 26,
+ funcId: 31,
port: port_,
);
},
@@ -887,7 +1058,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 27,
+ funcId: 32,
port: port_,
);
},
@@ -917,7 +1088,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 28,
+ funcId: 33,
port: port_,
);
},
@@ -945,7 +1116,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 29,
+ funcId: 34,
port: port_,
);
},
@@ -963,6 +1134,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiSetTransmitModeConstMeta =>
const TaskConstMeta(debugName: "set_transmit_mode", argNames: ["mode"]);
+ @override
+ Future crateApiSetVadModelPath({required String path}) {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ sse_encode_String(path, serializer);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 35,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_bridge_error,
+ ),
+ constMeta: kCrateApiSetVadModelPathConstMeta,
+ argValues: [path],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateApiSetVadModelPathConstMeta =>
+ const TaskConstMeta(debugName: "set_vad_model_path", argNames: ["path"]);
+
@override
Future crateApiSnapshot() {
return handler.executeNormal(
@@ -972,7 +1171,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 30,
+ funcId: 36,
port: port_,
);
},
@@ -1000,7 +1199,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 31,
+ funcId: 37,
port: port_,
);
},
@@ -1032,7 +1231,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 32,
+ funcId: 38,
port: port_,
);
},
@@ -1061,7 +1260,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 33,
+ funcId: 39,
port: port_,
);
},
@@ -1105,6 +1304,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return raw as bool;
}
+ @protected
+ BridgeAudioProcessingConfig
+ dco_decode_box_autoadd_bridge_audio_processing_config(dynamic raw) {
+ // Codec=Dco (DartCObject based), see doc to use other codecs
+ return dco_decode_bridge_audio_processing_config(raw);
+ }
+
@protected
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -1125,6 +1331,76 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return dco_decode_u_64(raw);
}
+ @protected
+ BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw) {
+ // Codec=Dco (DartCObject based), see doc to use other codecs
+ return BridgeAudioBackend.values[raw as int];
+ }
+
+ @protected
+ BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
+ dynamic raw,
+ ) {
+ // Codec=Dco (DartCObject based), see doc to use other codecs
+ final arr = raw as List;
+ if (arr.length != 13)
+ throw Exception('unexpected arr length: expect 13 but see ${arr.length}');
+ return BridgeAudioProcessingConfig(
+ route: dco_decode_bridge_audio_route(arr[0]),
+ iosMode: dco_decode_bridge_ios_voice_processing_mode(arr[1]),
+ processingBackend: dco_decode_bridge_audio_backend(arr[2]),
+ vadBackend: dco_decode_bridge_vad_backend(arr[3]),
+ aec: dco_decode_bridge_effect_owner(arr[4]),
+ ns: dco_decode_bridge_effect_owner(arr[5]),
+ agc: dco_decode_bridge_effect_owner(arr[6]),
+ hpfEnabled: dco_decode_bool(arr[7]),
+ limiterEnabled: dco_decode_bool(arr[8]),
+ vadHangoverMs: dco_decode_u_32(arr[9]),
+ vadPreRollMs: dco_decode_u_32(arr[10]),
+ vadMinTxMs: dco_decode_u_32(arr[11]),
+ debugWavDumpEnabled: dco_decode_bool(arr[12]),
+ );
+ }
+
+ @protected
+ BridgeAudioProcessingStats dco_decode_bridge_audio_processing_stats(
+ dynamic raw,
+ ) {
+ // Codec=Dco (DartCObject based), see doc to use other codecs
+ final arr = raw as List;
+ if (arr.length != 19)
+ throw Exception('unexpected arr length: expect 19 but see ${arr.length}');
+ return BridgeAudioProcessingStats(
+ inputDbfs: dco_decode_f_32(arr[0]),
+ renderDbfs: dco_decode_f_32(arr[1]),
+ processedDbfs: dco_decode_f_32(arr[2]),
+ vadProbability: dco_decode_f_32(arr[3]),
+ vadActive: dco_decode_bool(arr[4]),
+ transmitting: dco_decode_bool(arr[5]),
+ vadBackend: dco_decode_bridge_vad_backend(arr[6]),
+ vadFallbackActive: dco_decode_bool(arr[7]),
+ processingBackend: dco_decode_bridge_audio_backend(arr[8]),
+ iosVoiceProcessingMode: dco_decode_bridge_ios_voice_processing_mode(
+ arr[9],
+ ),
+ audioRoute: dco_decode_bridge_audio_route(arr[10]),
+ actualSampleRateHz: dco_decode_u_32(arr[11]),
+ actualIoBufferFrames: dco_decode_u_32(arr[12]),
+ inputOverruns: dco_decode_u_64(arr[13]),
+ outputUnderruns: dco_decode_u_64(arr[14]),
+ callbackXruns: dco_decode_u_64(arr[15]),
+ clippedSamples: dco_decode_u_64(arr[16]),
+ sonoraEnabled: dco_decode_bool(arr[17]),
+ platformVoiceProcessingEnabled: dco_decode_bool(arr[18]),
+ );
+ }
+
+ @protected
+ BridgeAudioRoute dco_decode_bridge_audio_route(dynamic raw) {
+ // Codec=Dco (DartCObject based), see doc to use other codecs
+ return BridgeAudioRoute.values[raw as int];
+ }
+
@protected
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -1185,6 +1461,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
+ @protected
+ BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw) {
+ // Codec=Dco (DartCObject based), see doc to use other codecs
+ return BridgeEffectOwner.values[raw as int];
+ }
+
@protected
BridgeError dco_decode_bridge_error(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -1273,6 +1555,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
+ @protected
+ BridgeIosVoiceProcessingMode dco_decode_bridge_ios_voice_processing_mode(
+ dynamic raw,
+ ) {
+ // Codec=Dco (DartCObject based), see doc to use other codecs
+ return BridgeIosVoiceProcessingMode.values[raw as int];
+ }
+
@protected
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -1308,6 +1598,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return BridgeTransmitMode.values[raw as int];
}
+ @protected
+ BridgeVadBackend dco_decode_bridge_vad_backend(dynamic raw) {
+ // Codec=Dco (DartCObject based), see doc to use other codecs
+ return BridgeVadBackend.values[raw as int];
+ }
+
@protected
BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(
dynamic raw,
@@ -1463,6 +1759,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return deserializer.buffer.getUint8() != 0;
}
+ @protected
+ BridgeAudioProcessingConfig
+ sse_decode_box_autoadd_bridge_audio_processing_config(
+ SseDeserializer deserializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ return (sse_decode_bridge_audio_processing_config(deserializer));
+ }
+
@protected
BridgeBookmark sse_decode_box_autoadd_bridge_bookmark(
SseDeserializer deserializer,
@@ -1485,6 +1790,105 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return (sse_decode_u_64(deserializer));
}
+ @protected
+ BridgeAudioBackend sse_decode_bridge_audio_backend(
+ SseDeserializer deserializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ var inner = sse_decode_i_32(deserializer);
+ return BridgeAudioBackend.values[inner];
+ }
+
+ @protected
+ BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
+ SseDeserializer deserializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ var var_route = sse_decode_bridge_audio_route(deserializer);
+ var var_iosMode = sse_decode_bridge_ios_voice_processing_mode(deserializer);
+ var var_processingBackend = sse_decode_bridge_audio_backend(deserializer);
+ var var_vadBackend = sse_decode_bridge_vad_backend(deserializer);
+ var var_aec = sse_decode_bridge_effect_owner(deserializer);
+ var var_ns = sse_decode_bridge_effect_owner(deserializer);
+ var var_agc = sse_decode_bridge_effect_owner(deserializer);
+ var var_hpfEnabled = sse_decode_bool(deserializer);
+ var var_limiterEnabled = sse_decode_bool(deserializer);
+ var var_vadHangoverMs = sse_decode_u_32(deserializer);
+ var var_vadPreRollMs = sse_decode_u_32(deserializer);
+ var var_vadMinTxMs = sse_decode_u_32(deserializer);
+ var var_debugWavDumpEnabled = sse_decode_bool(deserializer);
+ return BridgeAudioProcessingConfig(
+ route: var_route,
+ iosMode: var_iosMode,
+ processingBackend: var_processingBackend,
+ vadBackend: var_vadBackend,
+ aec: var_aec,
+ ns: var_ns,
+ agc: var_agc,
+ hpfEnabled: var_hpfEnabled,
+ limiterEnabled: var_limiterEnabled,
+ vadHangoverMs: var_vadHangoverMs,
+ vadPreRollMs: var_vadPreRollMs,
+ vadMinTxMs: var_vadMinTxMs,
+ debugWavDumpEnabled: var_debugWavDumpEnabled,
+ );
+ }
+
+ @protected
+ BridgeAudioProcessingStats sse_decode_bridge_audio_processing_stats(
+ SseDeserializer deserializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ var var_inputDbfs = sse_decode_f_32(deserializer);
+ var var_renderDbfs = sse_decode_f_32(deserializer);
+ var var_processedDbfs = sse_decode_f_32(deserializer);
+ var var_vadProbability = sse_decode_f_32(deserializer);
+ var var_vadActive = sse_decode_bool(deserializer);
+ var var_transmitting = sse_decode_bool(deserializer);
+ var var_vadBackend = sse_decode_bridge_vad_backend(deserializer);
+ var var_vadFallbackActive = sse_decode_bool(deserializer);
+ var var_processingBackend = sse_decode_bridge_audio_backend(deserializer);
+ var var_iosVoiceProcessingMode =
+ sse_decode_bridge_ios_voice_processing_mode(deserializer);
+ var var_audioRoute = sse_decode_bridge_audio_route(deserializer);
+ var var_actualSampleRateHz = sse_decode_u_32(deserializer);
+ var var_actualIoBufferFrames = sse_decode_u_32(deserializer);
+ var var_inputOverruns = sse_decode_u_64(deserializer);
+ var var_outputUnderruns = sse_decode_u_64(deserializer);
+ var var_callbackXruns = sse_decode_u_64(deserializer);
+ var var_clippedSamples = sse_decode_u_64(deserializer);
+ var var_sonoraEnabled = sse_decode_bool(deserializer);
+ var var_platformVoiceProcessingEnabled = sse_decode_bool(deserializer);
+ return BridgeAudioProcessingStats(
+ inputDbfs: var_inputDbfs,
+ renderDbfs: var_renderDbfs,
+ processedDbfs: var_processedDbfs,
+ vadProbability: var_vadProbability,
+ vadActive: var_vadActive,
+ transmitting: var_transmitting,
+ vadBackend: var_vadBackend,
+ vadFallbackActive: var_vadFallbackActive,
+ processingBackend: var_processingBackend,
+ iosVoiceProcessingMode: var_iosVoiceProcessingMode,
+ audioRoute: var_audioRoute,
+ actualSampleRateHz: var_actualSampleRateHz,
+ actualIoBufferFrames: var_actualIoBufferFrames,
+ inputOverruns: var_inputOverruns,
+ outputUnderruns: var_outputUnderruns,
+ callbackXruns: var_callbackXruns,
+ clippedSamples: var_clippedSamples,
+ sonoraEnabled: var_sonoraEnabled,
+ platformVoiceProcessingEnabled: var_platformVoiceProcessingEnabled,
+ );
+ }
+
+ @protected
+ BridgeAudioRoute sse_decode_bridge_audio_route(SseDeserializer deserializer) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ var inner = sse_decode_i_32(deserializer);
+ return BridgeAudioRoute.values[inner];
+ }
+
@protected
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -1553,6 +1957,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
+ @protected
+ BridgeEffectOwner sse_decode_bridge_effect_owner(
+ SseDeserializer deserializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ var inner = sse_decode_i_32(deserializer);
+ return BridgeEffectOwner.values[inner];
+ }
+
@protected
BridgeError sse_decode_bridge_error(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -1678,6 +2091,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
+ @protected
+ BridgeIosVoiceProcessingMode sse_decode_bridge_ios_voice_processing_mode(
+ SseDeserializer deserializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ var inner = sse_decode_i_32(deserializer);
+ return BridgeIosVoiceProcessingMode.values[inner];
+ }
+
@protected
BridgeNetworkState sse_decode_bridge_network_state(
SseDeserializer deserializer,
@@ -1726,6 +2148,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return BridgeTransmitMode.values[inner];
}
+ @protected
+ BridgeVadBackend sse_decode_bridge_vad_backend(SseDeserializer deserializer) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ var inner = sse_decode_i_32(deserializer);
+ return BridgeVadBackend.values[inner];
+ }
+
@protected
BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code(
SseDeserializer deserializer,
@@ -1929,6 +2358,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
serializer.buffer.putUint8(self ? 1 : 0);
}
+ @protected
+ void sse_encode_box_autoadd_bridge_audio_processing_config(
+ BridgeAudioProcessingConfig self,
+ SseSerializer serializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ sse_encode_bridge_audio_processing_config(self, serializer);
+ }
+
@protected
void sse_encode_box_autoadd_bridge_bookmark(
BridgeBookmark self,
@@ -1953,6 +2391,75 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_u_64(self, serializer);
}
+ @protected
+ void sse_encode_bridge_audio_backend(
+ BridgeAudioBackend self,
+ SseSerializer serializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ sse_encode_i_32(self.index, serializer);
+ }
+
+ @protected
+ void sse_encode_bridge_audio_processing_config(
+ BridgeAudioProcessingConfig self,
+ SseSerializer serializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ sse_encode_bridge_audio_route(self.route, serializer);
+ sse_encode_bridge_ios_voice_processing_mode(self.iosMode, serializer);
+ sse_encode_bridge_audio_backend(self.processingBackend, serializer);
+ sse_encode_bridge_vad_backend(self.vadBackend, serializer);
+ sse_encode_bridge_effect_owner(self.aec, serializer);
+ sse_encode_bridge_effect_owner(self.ns, serializer);
+ sse_encode_bridge_effect_owner(self.agc, serializer);
+ sse_encode_bool(self.hpfEnabled, serializer);
+ sse_encode_bool(self.limiterEnabled, serializer);
+ sse_encode_u_32(self.vadHangoverMs, serializer);
+ sse_encode_u_32(self.vadPreRollMs, serializer);
+ sse_encode_u_32(self.vadMinTxMs, serializer);
+ sse_encode_bool(self.debugWavDumpEnabled, serializer);
+ }
+
+ @protected
+ void sse_encode_bridge_audio_processing_stats(
+ BridgeAudioProcessingStats self,
+ SseSerializer serializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ sse_encode_f_32(self.inputDbfs, serializer);
+ sse_encode_f_32(self.renderDbfs, serializer);
+ sse_encode_f_32(self.processedDbfs, serializer);
+ sse_encode_f_32(self.vadProbability, serializer);
+ sse_encode_bool(self.vadActive, serializer);
+ sse_encode_bool(self.transmitting, serializer);
+ sse_encode_bridge_vad_backend(self.vadBackend, serializer);
+ sse_encode_bool(self.vadFallbackActive, serializer);
+ sse_encode_bridge_audio_backend(self.processingBackend, serializer);
+ sse_encode_bridge_ios_voice_processing_mode(
+ self.iosVoiceProcessingMode,
+ serializer,
+ );
+ sse_encode_bridge_audio_route(self.audioRoute, serializer);
+ sse_encode_u_32(self.actualSampleRateHz, serializer);
+ sse_encode_u_32(self.actualIoBufferFrames, serializer);
+ sse_encode_u_64(self.inputOverruns, serializer);
+ sse_encode_u_64(self.outputUnderruns, serializer);
+ sse_encode_u_64(self.callbackXruns, serializer);
+ sse_encode_u_64(self.clippedSamples, serializer);
+ sse_encode_bool(self.sonoraEnabled, serializer);
+ sse_encode_bool(self.platformVoiceProcessingEnabled, serializer);
+ }
+
+ @protected
+ void sse_encode_bridge_audio_route(
+ BridgeAudioRoute self,
+ SseSerializer serializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ sse_encode_i_32(self.index, serializer);
+ }
+
@protected
void sse_encode_bridge_audio_stats(
BridgeAudioStats self,
@@ -1984,6 +2491,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_u_64(self.parent, serializer);
sse_encode_String(self.name, serializer);
sse_encode_i_64(self.order, serializer);
+ sse_encode_bool(self.hasPassword, serializer);
}
@protected
@@ -1992,9 +2500,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_u_64(self.id, serializer);
sse_encode_u_64(self.channel, serializer);
sse_encode_String(self.name, serializer);
+ sse_encode_bool(self.inputMuted, serializer);
+ sse_encode_bool(self.outputMuted, serializer);
+ sse_encode_bool(self.isSpeaking, serializer);
sse_encode_bool(self.isServerQuery, serializer);
}
+ @protected
+ void sse_encode_bridge_effect_owner(
+ BridgeEffectOwner self,
+ SseSerializer serializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ sse_encode_i_32(self.index, serializer);
+ }
+
@protected
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -2106,6 +2626,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
+ @protected
+ void sse_encode_bridge_ios_voice_processing_mode(
+ BridgeIosVoiceProcessingMode self,
+ SseSerializer serializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ sse_encode_i_32(self.index, serializer);
+ }
+
@protected
void sse_encode_bridge_network_state(
BridgeNetworkState self,
@@ -2148,6 +2677,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_i_32(self.index, serializer);
}
+ @protected
+ void sse_encode_bridge_vad_backend(
+ BridgeVadBackend self,
+ SseSerializer serializer,
+ ) {
+ // Codec=Sse (Serialization based), see doc to use other codecs
+ sse_encode_i_32(self.index, serializer);
+ }
+
@protected
void sse_encode_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart
index dc9f82d..a444e1b 100644
--- a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart
+++ b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart
@@ -33,6 +33,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
bool dco_decode_bool(dynamic raw);
+ @protected
+ BridgeAudioProcessingConfig
+ dco_decode_box_autoadd_bridge_audio_processing_config(dynamic raw);
+
@protected
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw);
@@ -44,6 +48,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
+ @protected
+ BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw);
+
+ @protected
+ BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
+ dynamic raw,
+ );
+
+ @protected
+ BridgeAudioProcessingStats dco_decode_bridge_audio_processing_stats(
+ dynamic raw,
+ );
+
+ @protected
+ BridgeAudioRoute dco_decode_bridge_audio_route(dynamic raw);
+
@protected
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw);
@@ -56,12 +76,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
BridgeClient dco_decode_bridge_client(dynamic raw);
+ @protected
+ BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw);
+
@protected
BridgeError dco_decode_bridge_error(dynamic raw);
@protected
BridgeEvent dco_decode_bridge_event(dynamic raw);
+ @protected
+ BridgeIosVoiceProcessingMode dco_decode_bridge_ios_voice_processing_mode(
+ dynamic raw,
+ );
+
@protected
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
@@ -74,6 +102,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw);
+ @protected
+ BridgeVadBackend dco_decode_bridge_vad_backend(dynamic raw);
+
@protected
BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(dynamic raw);
@@ -143,6 +174,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
bool sse_decode_bool(SseDeserializer deserializer);
+ @protected
+ BridgeAudioProcessingConfig
+ sse_decode_box_autoadd_bridge_audio_processing_config(
+ SseDeserializer deserializer,
+ );
+
@protected
BridgeBookmark sse_decode_box_autoadd_bridge_bookmark(
SseDeserializer deserializer,
@@ -156,6 +193,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
+ @protected
+ BridgeAudioBackend sse_decode_bridge_audio_backend(
+ SseDeserializer deserializer,
+ );
+
+ @protected
+ BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
+ SseDeserializer deserializer,
+ );
+
+ @protected
+ BridgeAudioProcessingStats sse_decode_bridge_audio_processing_stats(
+ SseDeserializer deserializer,
+ );
+
+ @protected
+ BridgeAudioRoute sse_decode_bridge_audio_route(SseDeserializer deserializer);
+
@protected
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer);
@@ -168,12 +223,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
+ @protected
+ BridgeEffectOwner sse_decode_bridge_effect_owner(
+ SseDeserializer deserializer,
+ );
+
@protected
BridgeError sse_decode_bridge_error(SseDeserializer deserializer);
@protected
BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer);
+ @protected
+ BridgeIosVoiceProcessingMode sse_decode_bridge_ios_voice_processing_mode(
+ SseDeserializer deserializer,
+ );
+
@protected
BridgeNetworkState sse_decode_bridge_network_state(
SseDeserializer deserializer,
@@ -192,6 +257,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
SseDeserializer deserializer,
);
+ @protected
+ BridgeVadBackend sse_decode_bridge_vad_backend(SseDeserializer deserializer);
+
@protected
BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code(
SseDeserializer deserializer,
@@ -283,6 +351,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
+ @protected
+ void sse_encode_box_autoadd_bridge_audio_processing_config(
+ BridgeAudioProcessingConfig self,
+ SseSerializer serializer,
+ );
+
@protected
void sse_encode_box_autoadd_bridge_bookmark(
BridgeBookmark self,
@@ -298,6 +372,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
+ @protected
+ void sse_encode_bridge_audio_backend(
+ BridgeAudioBackend self,
+ SseSerializer serializer,
+ );
+
+ @protected
+ void sse_encode_bridge_audio_processing_config(
+ BridgeAudioProcessingConfig self,
+ SseSerializer serializer,
+ );
+
+ @protected
+ void sse_encode_bridge_audio_processing_stats(
+ BridgeAudioProcessingStats self,
+ SseSerializer serializer,
+ );
+
+ @protected
+ void sse_encode_bridge_audio_route(
+ BridgeAudioRoute self,
+ SseSerializer serializer,
+ );
+
@protected
void sse_encode_bridge_audio_stats(
BridgeAudioStats self,
@@ -316,12 +414,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
+ @protected
+ void sse_encode_bridge_effect_owner(
+ BridgeEffectOwner self,
+ SseSerializer serializer,
+ );
+
@protected
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer);
@protected
void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer);
+ @protected
+ void sse_encode_bridge_ios_voice_processing_mode(
+ BridgeIosVoiceProcessingMode self,
+ SseSerializer serializer,
+ );
+
@protected
void sse_encode_bridge_network_state(
BridgeNetworkState self,
@@ -346,6 +456,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
SseSerializer serializer,
);
+ @protected
+ void sse_encode_bridge_vad_backend(
+ BridgeVadBackend self,
+ SseSerializer serializer,
+ );
+
@protected
void sse_encode_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart
index e6d3d14..7c32ea5 100644
--- a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart
+++ b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart
@@ -35,6 +35,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
bool dco_decode_bool(dynamic raw);
+ @protected
+ BridgeAudioProcessingConfig
+ dco_decode_box_autoadd_bridge_audio_processing_config(dynamic raw);
+
@protected
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw);
@@ -46,6 +50,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
+ @protected
+ BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw);
+
+ @protected
+ BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
+ dynamic raw,
+ );
+
+ @protected
+ BridgeAudioProcessingStats dco_decode_bridge_audio_processing_stats(
+ dynamic raw,
+ );
+
+ @protected
+ BridgeAudioRoute dco_decode_bridge_audio_route(dynamic raw);
+
@protected
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw);
@@ -58,12 +78,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
BridgeClient dco_decode_bridge_client(dynamic raw);
+ @protected
+ BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw);
+
@protected
BridgeError dco_decode_bridge_error(dynamic raw);
@protected
BridgeEvent dco_decode_bridge_event(dynamic raw);
+ @protected
+ BridgeIosVoiceProcessingMode dco_decode_bridge_ios_voice_processing_mode(
+ dynamic raw,
+ );
+
@protected
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
@@ -76,6 +104,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw);
+ @protected
+ BridgeVadBackend dco_decode_bridge_vad_backend(dynamic raw);
+
@protected
BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(dynamic raw);
@@ -145,6 +176,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
bool sse_decode_bool(SseDeserializer deserializer);
+ @protected
+ BridgeAudioProcessingConfig
+ sse_decode_box_autoadd_bridge_audio_processing_config(
+ SseDeserializer deserializer,
+ );
+
@protected
BridgeBookmark sse_decode_box_autoadd_bridge_bookmark(
SseDeserializer deserializer,
@@ -158,6 +195,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
+ @protected
+ BridgeAudioBackend sse_decode_bridge_audio_backend(
+ SseDeserializer deserializer,
+ );
+
+ @protected
+ BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
+ SseDeserializer deserializer,
+ );
+
+ @protected
+ BridgeAudioProcessingStats sse_decode_bridge_audio_processing_stats(
+ SseDeserializer deserializer,
+ );
+
+ @protected
+ BridgeAudioRoute sse_decode_bridge_audio_route(SseDeserializer deserializer);
+
@protected
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer);
@@ -170,12 +225,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
+ @protected
+ BridgeEffectOwner sse_decode_bridge_effect_owner(
+ SseDeserializer deserializer,
+ );
+
@protected
BridgeError sse_decode_bridge_error(SseDeserializer deserializer);
@protected
BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer);
+ @protected
+ BridgeIosVoiceProcessingMode sse_decode_bridge_ios_voice_processing_mode(
+ SseDeserializer deserializer,
+ );
+
@protected
BridgeNetworkState sse_decode_bridge_network_state(
SseDeserializer deserializer,
@@ -194,6 +259,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
SseDeserializer deserializer,
);
+ @protected
+ BridgeVadBackend sse_decode_bridge_vad_backend(SseDeserializer deserializer);
+
@protected
BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code(
SseDeserializer deserializer,
@@ -285,6 +353,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
+ @protected
+ void sse_encode_box_autoadd_bridge_audio_processing_config(
+ BridgeAudioProcessingConfig self,
+ SseSerializer serializer,
+ );
+
@protected
void sse_encode_box_autoadd_bridge_bookmark(
BridgeBookmark self,
@@ -300,6 +374,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
+ @protected
+ void sse_encode_bridge_audio_backend(
+ BridgeAudioBackend self,
+ SseSerializer serializer,
+ );
+
+ @protected
+ void sse_encode_bridge_audio_processing_config(
+ BridgeAudioProcessingConfig self,
+ SseSerializer serializer,
+ );
+
+ @protected
+ void sse_encode_bridge_audio_processing_stats(
+ BridgeAudioProcessingStats self,
+ SseSerializer serializer,
+ );
+
+ @protected
+ void sse_encode_bridge_audio_route(
+ BridgeAudioRoute self,
+ SseSerializer serializer,
+ );
+
@protected
void sse_encode_bridge_audio_stats(
BridgeAudioStats self,
@@ -318,12 +416,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@protected
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
+ @protected
+ void sse_encode_bridge_effect_owner(
+ BridgeEffectOwner self,
+ SseSerializer serializer,
+ );
+
@protected
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer);
@protected
void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer);
+ @protected
+ void sse_encode_bridge_ios_voice_processing_mode(
+ BridgeIosVoiceProcessingMode self,
+ SseSerializer serializer,
+ );
+
@protected
void sse_encode_bridge_network_state(
BridgeNetworkState self,
@@ -348,6 +458,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
SseSerializer serializer,
);
+ @protected
+ void sse_encode_bridge_vad_backend(
+ BridgeVadBackend self,
+ SseSerializer serializer,
+ );
+
@protected
void sse_encode_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
diff --git a/apps/chanora_flutter/lib/widgets/audio_debug_stats_panel.dart b/apps/chanora_flutter/lib/widgets/audio_debug_stats_panel.dart
new file mode 100644
index 0000000..a0ebc7b
--- /dev/null
+++ b/apps/chanora_flutter/lib/widgets/audio_debug_stats_panel.dart
@@ -0,0 +1,209 @@
+// P1 debug stats overlay widget.
+//
+// Shows a compact, auto-refreshing panel with the key audio processing
+// metrics from [BridgeAudioProcessingStats]. Intended for internal
+// debug builds only — wrap with a kDebugMode guard at the call site.
+//
+// Usage:
+// if (kDebugMode) const AudioDebugStatsPanel(),
+
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+
+import '../src/rust/api.dart';
+
+/// Compact debug panel that polls [audioProcessingStats] every 500 ms
+/// and renders the key metrics in a monospace overlay.
+///
+/// Designed to be placed in a [Stack] over the main UI during
+/// development. It is transparent to hit-testing so it does not
+/// interfere with taps.
+class AudioDebugStatsPanel extends StatefulWidget {
+ const AudioDebugStatsPanel({super.key});
+
+ @override
+ State createState() => _AudioDebugStatsPanelState();
+}
+
+class _AudioDebugStatsPanelState extends State {
+ BridgeAudioProcessingStats? _stats;
+ Timer? _timer;
+ String? _error;
+
+ @override
+ void initState() {
+ super.initState();
+ _poll();
+ _timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _poll());
+ }
+
+ @override
+ void dispose() {
+ _timer?.cancel();
+ super.dispose();
+ }
+
+ Future _poll() async {
+ try {
+ final stats = await audioProcessingStats();
+ if (mounted) {
+ setState(() {
+ _stats = stats;
+ _error = null;
+ });
+ }
+ } catch (e) {
+ if (mounted) {
+ setState(() => _error = e.toString());
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return IgnorePointer(
+ child: Align(
+ alignment: Alignment.topRight,
+ child: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: _buildPanel(),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildPanel() {
+ if (_error != null) {
+ return _PanelBox(
+ child: Text(
+ 'audio stats error:\n$_error',
+ style: _monoStyle(Colors.red),
+ ),
+ );
+ }
+
+ final s = _stats;
+ if (s == null) {
+ return _PanelBox(
+ child: Text('audio stats: loading…', style: _monoStyle(Colors.grey)),
+ );
+ }
+
+ final vadColor = s.vadActive ? Colors.greenAccent : Colors.grey;
+ final txColor = s.transmitting ? Colors.redAccent : Colors.grey;
+ final xruns = s.callbackXruns + s.inputOverruns + s.outputUnderruns;
+
+ return _PanelBox(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _row('route', _routeLabel(s.audioRoute), Colors.white),
+ _row('backend', _backendLabel(s.processingBackend), Colors.white),
+ _row(
+ 'vpio',
+ s.platformVoiceProcessingEnabled ? 'on' : 'off',
+ Colors.white,
+ ),
+ _row('sonora', s.sonoraEnabled ? 'on' : 'off', Colors.white),
+ const SizedBox(height: 4),
+ _row(
+ 'mic in',
+ '${s.inputDbfs.toStringAsFixed(1)} dBFS',
+ Colors.white,
+ ),
+ _row(
+ 'mic out',
+ '${s.processedDbfs.toStringAsFixed(1)} dBFS',
+ Colors.white,
+ ),
+ _row(
+ 'render',
+ '${s.renderDbfs.toStringAsFixed(1)} dBFS',
+ Colors.white,
+ ),
+ const SizedBox(height: 4),
+ _row(
+ 'vad',
+ '${(s.vadProbability * 100).toStringAsFixed(0)}% '
+ '${s.vadActive ? "OPEN" : "closed"}',
+ vadColor,
+ ),
+ _row('vad backend', _vadBackendLabel(s.vadBackend), Colors.white),
+ if (s.vadFallbackActive)
+ _row('vad fallback', 'ACTIVE', Colors.orange),
+ const SizedBox(height: 4),
+ _row('tx', s.transmitting ? 'TRANSMITTING' : 'idle', txColor),
+ _row('sr', '${s.actualSampleRateHz} Hz', Colors.white),
+ _row('buf', '${s.actualIoBufferFrames} frames', Colors.white),
+ if (xruns > BigInt.zero)
+ _row('xruns', xruns.toString(), Colors.orange),
+ if (s.clippedSamples > BigInt.zero)
+ _row('clipped', s.clippedSamples.toString(), Colors.orange),
+ ],
+ ),
+ );
+ }
+
+ Widget _row(String label, String value, Color valueColor) {
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text('$label: ', style: _monoStyle(Colors.grey.shade400)),
+ Text(value, style: _monoStyle(valueColor)),
+ ],
+ );
+ }
+
+ TextStyle _monoStyle(Color color) => TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 10,
+ color: color,
+ height: 1.4,
+ );
+
+ String _routeLabel(BridgeAudioRoute route) => switch (route) {
+ BridgeAudioRoute.speaker => 'speaker',
+ BridgeAudioRoute.earpiece => 'earpiece',
+ BridgeAudioRoute.wiredHeadset => 'wired',
+ BridgeAudioRoute.bluetoothHfp => 'bt-hfp',
+ BridgeAudioRoute.bluetoothA2Dp => 'bt-a2dp',
+ BridgeAudioRoute.unknown => 'unknown',
+ };
+
+ String _backendLabel(BridgeAudioBackend backend) => switch (backend) {
+ BridgeAudioBackend.platformVoiceProcessing => 'vpio',
+ BridgeAudioBackend.sonora => 'sonora',
+ BridgeAudioBackend.noop => 'noop',
+ BridgeAudioBackend.webrtcApm => 'webrtc-apm',
+ };
+
+ String _vadBackendLabel(BridgeVadBackend backend) => switch (backend) {
+ BridgeVadBackend.webrtcVad => 'webrtc',
+ BridgeVadBackend.sileroOnnx => 'silero',
+ BridgeVadBackend.tenVad => 'ten',
+ BridgeVadBackend.energyDebug => 'energy',
+ BridgeVadBackend.disabled => 'off',
+ };
+}
+
+/// Semi-transparent dark box for the debug panel.
+class _PanelBox extends StatelessWidget {
+ const _PanelBox({required this.child});
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
+ decoration: BoxDecoration(
+ color: Colors.black.withValues(alpha: 0.72),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: child,
+ );
+ }
+}
diff --git a/apps/chanora_flutter/lib/widgets/permission_state_banner.dart b/apps/chanora_flutter/lib/widgets/permission_state_banner.dart
index d1bd6e1..f8d2202 100644
--- a/apps/chanora_flutter/lib/widgets/permission_state_banner.dart
+++ b/apps/chanora_flutter/lib/widgets/permission_state_banner.dart
@@ -1,4 +1,4 @@
-/// SRS-209 listen-only banner for Android RECORD_AUDIO permission.
+/// SRS-209 listen-only banner for mobile microphone permission.
///
/// Trace:
/// - SDD-106 §2 (denial UX — non-blocking affordance "Enable
@@ -6,17 +6,19 @@
/// - SRS-209 (path to grant; listen-only fallback).
///
/// Behaviour:
-/// * Watches [AndroidPermissionsService.recordAudioState].
+/// * Watches the injected microphone permission state listenable.
/// * On `denied`: renders a non-modal banner with a "Grant" action.
/// * On `permanentlyDenied`: action text becomes "Open Settings" and
/// invokes [AndroidPermissionsService.openAppSettings].
/// * On `granted` / `unknown`: builds an empty [SizedBox.shrink].
-/// * On non-Android hosts the service stays at `granted`, so this
-/// widget is effectively invisible without any extra branching.
+/// * On platforms whose service stays at `granted`, this widget is
+/// effectively invisible without any extra branching.
library;
import 'package:flutter/material.dart';
+import 'package:flutter/foundation.dart';
+import '../l10n/generated/app_localizations.dart';
import '../services/android_permissions_service.dart';
/// Listen-only banner widget. Drop this above the `VoiceBar` in the
@@ -24,43 +26,69 @@ import '../services/android_permissions_service.dart';
///
/// Trace: SDD-106 §2, §3; SRS-209.
class PermissionStateBanner extends StatelessWidget {
- const PermissionStateBanner({super.key, required this.service});
+ PermissionStateBanner({super.key, required AndroidPermissionsService service})
+ : recordAudioState = service.recordAudioState,
+ ensureRecordAudio = service.ensureRecordAudio,
+ openAppSettings = service.openAppSettings;
- /// Permissions service whose [AndroidPermissionsService.recordAudioState]
- /// drives the banner.
- final AndroidPermissionsService service;
+ const PermissionStateBanner.fromCallbacks({
+ super.key,
+ required this.recordAudioState,
+ required this.ensureRecordAudio,
+ required this.openAppSettings,
+ });
+
+ final ValueListenable recordAudioState;
+ final Future Function() ensureRecordAudio;
+ final Future Function() openAppSettings;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
- valueListenable: service.recordAudioState,
+ valueListenable: recordAudioState,
builder: (ctx, state, _) {
- switch (state) {
- case AndroidRecordAudioPermissionState.granted:
- case AndroidRecordAudioPermissionState.unknown:
- return const SizedBox.shrink();
- case AndroidRecordAudioPermissionState.denied:
- return _BannerBody(
- // TODO(localization): route through AppL10n once an arb
- // entry exists. SRS-209 requires the message; the
- // English literal is a placeholder.
- message:
- 'Microphone permission required for voice transmission.',
- actionLabel: 'Grant',
- onPressed: () => service.ensureRecordAudio(),
- );
- case AndroidRecordAudioPermissionState.permanentlyDenied:
- return _BannerBody(
- // TODO(localization): see above.
- message:
- 'Microphone permission required for voice transmission.',
- actionLabel: 'Open Settings',
- onPressed: () => service.openAppSettings(),
- );
+ final l10n = AppL10n.of(ctx);
+ final action = _actionFor(state, l10n);
+ if (action == null) {
+ return const SizedBox.shrink();
}
+
+ return _BannerBody(
+ message: l10n.microphonePermissionRequiredForVoice,
+ actionLabel: action.label,
+ onPressed: action.onPressed,
+ );
},
);
}
+
+ _BannerAction? _actionFor(
+ AndroidRecordAudioPermissionState state,
+ AppL10n l10n,
+ ) {
+ switch (state) {
+ case AndroidRecordAudioPermissionState.granted:
+ case AndroidRecordAudioPermissionState.unknown:
+ return null;
+ case AndroidRecordAudioPermissionState.denied:
+ return _BannerAction(
+ label: l10n.permissionGrantAction,
+ onPressed: () => ensureRecordAudio(),
+ );
+ case AndroidRecordAudioPermissionState.permanentlyDenied:
+ return _BannerAction(
+ label: l10n.networkPermissionOpenSettings,
+ onPressed: () => openAppSettings(),
+ );
+ }
+ }
+}
+
+class _BannerAction {
+ const _BannerAction({required this.label, required this.onPressed});
+
+ final String label;
+ final VoidCallback onPressed;
}
class _BannerBody extends StatelessWidget {
diff --git a/apps/chanora_flutter/lib/widgets/ptt_capability_badge.dart b/apps/chanora_flutter/lib/widgets/ptt_capability_badge.dart
new file mode 100644
index 0000000..a6a801b
--- /dev/null
+++ b/apps/chanora_flutter/lib/widgets/ptt_capability_badge.dart
@@ -0,0 +1,141 @@
+import 'package:flutter/foundation.dart'
+ show TargetPlatform, defaultTargetPlatform;
+import 'package:flutter/material.dart';
+
+import '../l10n/generated/app_localizations.dart';
+
+/// PTT capability badge (gen2 v0.9.3 / SDD-091).
+///
+/// Renders the active PTT level + backend in the Voice Bar so the
+/// user understands which input path is in effect. When the
+/// resolved capability is `L0Focused` an info icon appears that
+/// opens a per-platform explanation sheet describing why Global
+/// PTT is not active and what the user can do to engage it.
+class PttCapabilityBadge extends StatelessWidget {
+ /// Construct a badge.
+ const PttCapabilityBadge({
+ super.key,
+ required this.level,
+ required this.backendId,
+ required this.boundInputClass,
+ });
+
+ /// Resolved capability level as the bridge emits it
+ /// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
+ /// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
+ final String level;
+
+ /// Stable backend identifier (`focused`, `windows-raw-input`, …).
+ final String backendId;
+
+ /// Privacy-safe input class (`keyboard`, `mouse-side-button`,
+ /// or empty when no binding is set).
+ final String boundInputClass;
+
+ bool get _isFocused => level == 'L0Focused';
+
+ String _explainBodyForPlatform(AppL10n l10n) {
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.windows:
+ return l10n.pttCapabilityExplainGoGlobalWindows;
+ case TargetPlatform.macOS:
+ return l10n.pttCapabilityExplainGoGlobalMacos;
+ case TargetPlatform.linux:
+ return l10n.pttCapabilityExplainGoGlobalLinux;
+ case TargetPlatform.iOS:
+ return l10n.pttCapabilityExplainGoGlobalIos;
+ default:
+ return l10n.pttCapabilityExplainGoGlobalGeneric;
+ }
+ }
+
+ void _openExplanationSheet(BuildContext context) {
+ final l10n = AppL10n.of(context);
+ showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ builder: (sheetContext) {
+ final theme = Theme.of(sheetContext);
+ return SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ l10n.pttCapabilityExplainTitle,
+ style: theme.textTheme.titleMedium,
+ ),
+ const SizedBox(height: 12),
+ Text(
+ l10n.pttCapabilityExplainFocusedHeading,
+ style: theme.textTheme.titleSmall,
+ ),
+ const SizedBox(height: 4),
+ Text(
+ l10n.pttCapabilityExplainFocusedBody,
+ style: theme.textTheme.bodyMedium,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ _explainBodyForPlatform(l10n),
+ style: theme.textTheme.bodyMedium,
+ ),
+ const SizedBox(height: 16),
+ Align(
+ alignment: AlignmentDirectional.centerEnd,
+ child: TextButton(
+ onPressed: () => Navigator.of(sheetContext).pop(),
+ child: Text(l10n.closeAction),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final l10n = AppL10n.of(context);
+ final theme = Theme.of(context);
+ final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
+ final tooltipMessage = boundInputClass.isEmpty
+ ? badgeLabel
+ : '$badgeLabel\n($boundInputClass)';
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 6),
+ child: Tooltip(
+ message: tooltipMessage,
+ child: Row(
+ children: [
+ Icon(
+ _isFocused ? Icons.crop_free : Icons.public,
+ size: 14,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(width: 4),
+ Expanded(
+ child: Text(
+ badgeLabel,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ if (_isFocused)
+ IconButton(
+ icon: const Icon(Icons.info_outline, size: 16),
+ tooltip: l10n.pttCapabilityExplainTitle,
+ visualDensity: VisualDensity.compact,
+ onPressed: () => _openExplanationSheet(context),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/apps/chanora_flutter/lib/widgets/voice_bar.dart b/apps/chanora_flutter/lib/widgets/voice_bar.dart
index 8f134f1..09d608c 100644
--- a/apps/chanora_flutter/lib/widgets/voice_bar.dart
+++ b/apps/chanora_flutter/lib/widgets/voice_bar.dart
@@ -3,24 +3,16 @@
// `BridgeEvent::VoiceState` stream the bridge publishes from the
// core's transmit-mode selector + release-tail timer.
-import 'dart:io' show Platform;
+import 'dart:async' show unawaited;
-import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
+import 'package:haptic_kit/haptic_kit.dart';
import '../l10n/generated/app_localizations.dart';
-import '../main.dart' show PttCapabilityBadge;
+import 'ptt_capability_badge.dart';
+import 'voice_platform.dart';
import '../src/rust/api.dart' as rust;
-/// True when the host is a mobile platform without a hardware
-/// keyboard the user would bind a PTT key on. iOS / iPadOS /
-/// Android fall here. macOS / Linux / Windows / Web fall on the
-/// hardware-key path.
-bool get _isTouchOnlyPttHost {
- if (kIsWeb) return false;
- return Platform.isIOS || Platform.isAndroid;
-}
-
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
/// toggle, level meter, and a leave-channel affordance.
class VoiceBar extends StatelessWidget {
@@ -113,7 +105,7 @@ class VoiceBar extends StatelessWidget {
case rust.BridgeTransmitMode.continuous:
return l10n.voiceModeContinuous;
case rust.BridgeTransmitMode.voiceActivity:
- return '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})';
+ return l10n.voiceModeVoiceActivity;
}
}
@@ -237,7 +229,7 @@ class VoiceBar extends StatelessWidget {
// pinned to the bottom of a narrow-layout screen. The
// release-tail value is folded into the small print
// under the button rather than shown here.
- if (isPtt && !_isTouchOnlyPttHost)
+ if (isPtt && !isTouchOnlyPttHost)
Padding(
padding: const EdgeInsets.only(left: 22, top: 2),
child: Text(
@@ -288,7 +280,7 @@ class VoiceBar extends StatelessWidget {
// the bottom of a narrow-layout screen. The release-
// tail value sits above the button so the user sees
// how long their voice continues after they let go.
- if (isPtt && _isTouchOnlyPttHost) ...[
+ if (isPtt && isTouchOnlyPttHost) ...[
const SizedBox(height: 4),
Center(
child: Text(
@@ -380,10 +372,24 @@ class _PttHoldButton extends StatefulWidget {
class _PttHoldButtonState extends State<_PttHoldButton> {
bool _pressed = false;
+ @override
+ void initState() {
+ super.initState();
+ unawaited(Haptics.prepare().catchError((_) => false));
+ }
+
void _setHeld(bool held) {
if (_pressed == held) return;
setState(() => _pressed = held);
widget.onHeldChanged(held);
+ _playPressHaptic(held);
+ }
+
+ void _playPressHaptic(bool held) {
+ final haptic = held
+ ? Haptics.impact(HapticImpactStyle.medium)
+ : Haptics.selection();
+ unawaited(haptic.catchError((_) {}));
}
@override
@@ -392,54 +398,62 @@ class _PttHoldButtonState extends State<_PttHoldButton> {
final activeNow = _pressed || widget.active;
final l10n = AppL10n.of(context);
- return GestureDetector(
- behavior: HitTestBehavior.opaque,
- onTapDown: (_) => _setHeld(true),
- onTapUp: (_) => _setHeld(false),
- onTapCancel: () => _setHeld(false),
- onPanDown: (_) => _setHeld(true),
- onPanEnd: (_) => _setHeld(false),
- onPanCancel: () => _setHeld(false),
- child: AnimatedContainer(
- duration: const Duration(milliseconds: 80),
- height: 64,
- decoration: BoxDecoration(
- color: activeNow
- ? theme.colorScheme.primary
- : theme.colorScheme.primaryContainer,
- borderRadius: BorderRadius.circular(12),
- boxShadow: activeNow
- ? [
- BoxShadow(
- color: theme.colorScheme.primary.withAlpha(100),
- blurRadius: 12,
- offset: const Offset(0, 2),
+ return Semantics(
+ button: true,
+ liveRegion: true,
+ label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
+ hint: l10n.pttHoldToTalkSemanticsHint,
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTapDown: (_) => _setHeld(true),
+ onTapUp: (_) => _setHeld(false),
+ onTapCancel: () => _setHeld(false),
+ onPanDown: (_) => _setHeld(true),
+ onPanEnd: (_) => _setHeld(false),
+ onPanCancel: () => _setHeld(false),
+ child: ExcludeSemantics(
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 80),
+ height: 64,
+ decoration: BoxDecoration(
+ color: activeNow
+ ? theme.colorScheme.primary
+ : theme.colorScheme.primaryContainer,
+ borderRadius: BorderRadius.circular(12),
+ boxShadow: activeNow
+ ? [
+ BoxShadow(
+ color: theme.colorScheme.primary.withAlpha(100),
+ blurRadius: 12,
+ offset: const Offset(0, 2),
+ ),
+ ]
+ : null,
+ ),
+ child: Center(
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ activeNow ? Icons.mic : Icons.mic_none,
+ color: activeNow
+ ? theme.colorScheme.onPrimary
+ : theme.colorScheme.onPrimaryContainer,
+ size: 24,
),
- ]
- : null,
- ),
- child: Center(
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- activeNow ? Icons.mic : Icons.mic_none,
- color: activeNow
- ? theme.colorScheme.onPrimary
- : theme.colorScheme.onPrimaryContainer,
- size: 24,
+ const SizedBox(width: 10),
+ Text(
+ activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: activeNow
+ ? theme.colorScheme.onPrimary
+ : theme.colorScheme.onPrimaryContainer,
+ ),
+ ),
+ ],
),
- const SizedBox(width: 10),
- Text(
- activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
- style: theme.textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.w600,
- color: activeNow
- ? theme.colorScheme.onPrimary
- : theme.colorScheme.onPrimaryContainer,
- ),
- ),
- ],
+ ),
),
),
),
diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart
index cea48bd..20877e5 100644
--- a/apps/chanora_flutter/lib/widgets/voice_compact.dart
+++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart
@@ -15,16 +15,17 @@
// wrong UI: that only lists AirPlay output destinations, not the
// speaker/receiver/Bluetooth choices we want.
-import 'dart:async' show StreamSubscription;
+import 'dart:async' show StreamSubscription, Timer, unawaited;
import 'dart:io' show Platform;
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
+import 'package:haptic_kit/haptic_kit.dart';
import '../l10n/generated/app_localizations.dart';
-import '../main.dart' show PttCapabilityBadge;
+import 'ptt_capability_badge.dart';
import '../src/rust/api.dart' as rust;
/// Two-line status chip that summarises the current voice state.
@@ -65,13 +66,18 @@ class VoiceStatusChip extends StatelessWidget {
final l10n = AppL10n.of(context);
final stats = audioStats;
- final micOn = stats?.pttActive ?? false;
+ // For PTT: pttActive = button held.
+ // For Continuous: pttActive = always true (always transmitting).
+ // For VoiceActivity: pttActive = VAD gate open (speech detected).
+ final micOn = switch (transmitMode) {
+ rust.BridgeTransmitMode.continuous => true,
+ _ => stats?.pttActive ?? false,
+ };
final modeLabel = switch (transmitMode) {
rust.BridgeTransmitMode.ptt => l10n.voiceModePtt,
rust.BridgeTransmitMode.continuous => l10n.voiceModeContinuous,
- rust.BridgeTransmitMode.voiceActivity =>
- '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})',
+ rust.BridgeTransmitMode.voiceActivity => l10n.voiceModeVoiceActivity,
};
String line1;
@@ -92,64 +98,71 @@ class VoiceStatusChip extends StatelessWidget {
final micText = micOn ? l10n.voiceMicOn : l10n.voiceMicOff;
final line2 = tailText == null ? micText : '$tailText \u00b7 $micText';
- return Material(
- type: MaterialType.transparency,
- child: InkWell(
- onTap: onTap,
- borderRadius: BorderRadius.circular(12),
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- decoration: BoxDecoration(
- color: theme.colorScheme.surfaceContainerHigh,
- borderRadius: BorderRadius.circular(12),
- border: Border.all(
- color: theme.colorScheme.outlineVariant,
- width: 0.5,
- ),
- ),
- child: Row(
- children: [
- Icon(
- micOn
- ? Icons.fiber_manual_record
- : Icons.fiber_manual_record_outlined,
- size: 12,
- color: micOn
- ? theme.colorScheme.primary
- : theme.colorScheme.outline,
- ),
- const SizedBox(width: 8),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- line1,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.bodyMedium?.copyWith(
- fontWeight: FontWeight.w500,
- ),
- ),
- Text(
- line2,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.bodySmall?.copyWith(
- color: theme.colorScheme.onSurfaceVariant,
- ),
- ),
- ],
+ return Semantics(
+ button: true,
+ label: '${l10n.voiceSheetTitle}: $line1, $line2',
+ hint: l10n.voiceSettingsTitle,
+ child: Material(
+ type: MaterialType.transparency,
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(12),
+ child: ExcludeSemantics(
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerHigh,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: theme.colorScheme.outlineVariant,
+ width: 0.5,
),
),
- const SizedBox(width: 8),
- Icon(
- Icons.expand_less,
- size: 18,
- color: theme.colorScheme.onSurfaceVariant,
+ child: Row(
+ children: [
+ Icon(
+ micOn
+ ? Icons.fiber_manual_record
+ : Icons.fiber_manual_record_outlined,
+ size: 12,
+ color: micOn
+ ? theme.colorScheme.primary
+ : theme.colorScheme.outline,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ line1,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ Text(
+ line2,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ Icon(
+ Icons.expand_less,
+ size: 18,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ],
),
- ],
+ ),
),
),
),
@@ -179,10 +192,24 @@ class VoicePttButton extends StatefulWidget {
class _VoicePttButtonState extends State {
bool _pressed = false;
+ @override
+ void initState() {
+ super.initState();
+ unawaited(Haptics.prepare().catchError((_) => false));
+ }
+
void _setHeld(bool held) {
if (_pressed == held) return;
setState(() => _pressed = held);
widget.onHeldChanged(held);
+ _playPressHaptic(held);
+ }
+
+ void _playPressHaptic(bool held) {
+ final haptic = held
+ ? Haptics.impact(HapticImpactStyle.medium)
+ : Haptics.selection();
+ unawaited(haptic.catchError((_) {}));
}
@override
@@ -191,56 +218,61 @@ class _VoicePttButtonState extends State {
final l10n = AppL10n.of(context);
final activeNow = _pressed || widget.active;
- return GestureDetector(
- behavior: HitTestBehavior.opaque,
- onTapDown: (_) => _setHeld(true),
- onTapUp: (_) => _setHeld(false),
- onTapCancel: () => _setHeld(false),
- onPanDown: (_) => _setHeld(true),
- onPanEnd: (_) => _setHeld(false),
- onPanCancel: () => _setHeld(false),
- child: AnimatedContainer(
- duration: const Duration(milliseconds: 80),
- height: 56,
- decoration: BoxDecoration(
- color: activeNow
- ? theme.colorScheme.primary
- : theme.colorScheme.primaryContainer,
- borderRadius: BorderRadius.circular(16),
- boxShadow: activeNow
- ? [
- BoxShadow(
- color: theme.colorScheme.primary.withAlpha(100),
- blurRadius: 16,
- spreadRadius: 2,
- offset: const Offset(0, 2),
+ return Semantics(
+ button: true,
+ liveRegion: true,
+ label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
+ hint: l10n.pttHoldToTalkSemanticsHint,
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTapDown: (_) => _setHeld(true),
+ onTapUp: (_) => _setHeld(false),
+ onTapCancel: () => _setHeld(false),
+ child: ExcludeSemantics(
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 80),
+ height: 56,
+ decoration: BoxDecoration(
+ color: activeNow
+ ? theme.colorScheme.primary
+ : theme.colorScheme.primaryContainer,
+ borderRadius: BorderRadius.circular(16),
+ boxShadow: activeNow
+ ? [
+ BoxShadow(
+ color: theme.colorScheme.primary.withAlpha(100),
+ blurRadius: 16,
+ spreadRadius: 2,
+ offset: const Offset(0, 2),
+ ),
+ ]
+ : null,
+ ),
+ child: Center(
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ activeNow ? Icons.mic : Icons.mic_none,
+ color: activeNow
+ ? theme.colorScheme.onPrimary
+ : theme.colorScheme.onPrimaryContainer,
+ size: 28,
),
- ]
- : null,
- ),
- child: Center(
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- activeNow ? Icons.mic : Icons.mic_none,
- color: activeNow
- ? theme.colorScheme.onPrimary
- : theme.colorScheme.onPrimaryContainer,
- size: 28,
+ const SizedBox(width: 12),
+ Text(
+ activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ letterSpacing: 0.4,
+ color: activeNow
+ ? theme.colorScheme.onPrimary
+ : theme.colorScheme.onPrimaryContainer,
+ ),
+ ),
+ ],
),
- const SizedBox(width: 12),
- Text(
- activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
- style: theme.textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.w600,
- letterSpacing: 0.4,
- color: activeNow
- ? theme.colorScheme.onPrimary
- : theme.colorScheme.onPrimaryContainer,
- ),
- ),
- ],
+ ),
),
),
),
@@ -252,11 +284,10 @@ class _VoicePttButtonState extends State {
/// surface on mobile. Tiles:
/// 1. Audio output route picker (iOS native AVRoutePickerView /
/// Android Material 3 list). Mobile only.
-/// 2. Mode radio buttons (PTT / Continuous; VoiceActivity disabled
-/// coming-soon).
+/// 2. Mode radio buttons (PTT / Continuous / Voice Activity).
/// 3. Release-tail slider (PTT-only).
-/// 4. Mic level meter.
-/// 5. TX / RX frame counts.
+/// 4. Mic level meter + frame counts.
+/// 5. Audio processing (NS · AEC · AGC · HPF · VAD) — all modes.
/// 6. PTT capability badge.
///
/// Mode + release-tail are inlined directly here instead of being
@@ -266,7 +297,6 @@ class _VoicePttButtonState extends State {
/// drag the slider freely.
Future showVoiceDetailsSheet(
BuildContext context, {
- required rust.BridgeAudioStats? audioStats,
required rust.BridgeTransmitMode transmitMode,
required int releaseTailMs,
required String pttBoundKeyLabel,
@@ -274,25 +304,38 @@ Future showVoiceDetailsSheet(
required String pttBackendId,
required String pttBoundInputClass,
required bool isTouchOnly,
+ required rust.BridgeAudioProcessingConfig initialAudioConfig,
required ValueChanged onModeChanged,
required ValueChanged onReleaseTailChanged,
+ required ValueChanged onAudioConfigChanged,
}) async {
await showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: true,
+ useSafeArea: true,
builder: (ctx) {
- return _VoiceSheetBody(
- audioStats: audioStats,
- initialMode: transmitMode,
- initialReleaseTailMs: releaseTailMs,
- pttBoundKeyLabel: pttBoundKeyLabel,
- pttLevel: pttLevel,
- pttBackendId: pttBackendId,
- pttBoundInputClass: pttBoundInputClass,
- isTouchOnly: isTouchOnly,
- onModeChanged: onModeChanged,
- onReleaseTailChanged: onReleaseTailChanged,
+ return DraggableScrollableSheet(
+ initialChildSize: 0.6,
+ minChildSize: 0.3,
+ maxChildSize: 0.95,
+ expand: false,
+ snap: true,
+ snapSizes: const [0.3, 0.6, 0.95],
+ builder: (ctx, scrollController) => _VoiceSheetBody(
+ scrollController: scrollController,
+ initialMode: transmitMode,
+ initialReleaseTailMs: releaseTailMs,
+ pttBoundKeyLabel: pttBoundKeyLabel,
+ pttLevel: pttLevel,
+ pttBackendId: pttBackendId,
+ pttBoundInputClass: pttBoundInputClass,
+ isTouchOnly: isTouchOnly,
+ initialAudioConfig: initialAudioConfig,
+ onModeChanged: onModeChanged,
+ onReleaseTailChanged: onReleaseTailChanged,
+ onAudioConfigChanged: onAudioConfigChanged,
+ ),
);
},
);
@@ -300,7 +343,7 @@ Future showVoiceDetailsSheet(
class _VoiceSheetBody extends StatefulWidget {
const _VoiceSheetBody({
- required this.audioStats,
+ required this.scrollController,
required this.initialMode,
required this.initialReleaseTailMs,
required this.pttBoundKeyLabel,
@@ -308,11 +351,13 @@ class _VoiceSheetBody extends StatefulWidget {
required this.pttBackendId,
required this.pttBoundInputClass,
required this.isTouchOnly,
+ required this.initialAudioConfig,
required this.onModeChanged,
required this.onReleaseTailChanged,
+ required this.onAudioConfigChanged,
});
- final rust.BridgeAudioStats? audioStats;
+ final ScrollController scrollController;
final rust.BridgeTransmitMode initialMode;
final int initialReleaseTailMs;
final String pttBoundKeyLabel;
@@ -320,8 +365,10 @@ class _VoiceSheetBody extends StatefulWidget {
final String pttBackendId;
final String pttBoundInputClass;
final bool isTouchOnly;
+ final rust.BridgeAudioProcessingConfig initialAudioConfig;
final ValueChanged onModeChanged;
final ValueChanged onReleaseTailChanged;
+ final ValueChanged onAudioConfigChanged;
@override
State<_VoiceSheetBody> createState() => _VoiceSheetBodyState();
@@ -331,12 +378,116 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
late rust.BridgeTransmitMode _mode = widget.initialMode;
late int _tail = widget.initialReleaseTailMs;
+ // Live stats — polled by this widget's own timer so TX/RX update
+ // in real time while the sheet is open, independent of the parent.
+ rust.BridgeAudioStats? _stats;
+ Timer? _statsTimer;
+ // Previous snapshot for computing per-second rates.
+ int _prevSent = 0;
+ int _prevReceived = 0;
+ int _txRate = 0; // frames/s
+ int _rxRate = 0; // frames/s
+ int _rateTickCount = 0;
+
+ // Audio processing state.
+ late bool _nsEnabled;
+ late bool _aecEnabled;
+ late bool _agcEnabled;
+ late bool _hpfEnabled;
+ late rust.BridgeVadBackend _vadBackend;
+
+ @override
+ void initState() {
+ super.initState();
+ final c = widget.initialAudioConfig;
+ _nsEnabled = c.ns != rust.BridgeEffectOwner.off;
+ _aecEnabled = c.aec != rust.BridgeEffectOwner.off;
+ _agcEnabled = c.agc != rust.BridgeEffectOwner.off;
+ _hpfEnabled = c.hpfEnabled;
+ _vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
+ ? rust.BridgeVadBackend.webrtcVad
+ : c.vadBackend;
+
+ // Poll audio stats at 80 ms so TX/RX counters and the level meter
+ // update in real time while the sheet is open, independent of the parent.
+ _statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async {
+ try {
+ final s = await rust.audioStats();
+ if (!mounted) return;
+ setState(() {
+ _stats = s;
+ _rateTickCount++;
+ // Compute rates every ~960 ms (12 × 80 ms).
+ if (_rateTickCount >= 12) {
+ _txRate = s.framesSent - _prevSent;
+ _rxRate = s.framesReceived - _prevReceived;
+ _prevSent = s.framesSent;
+ _prevReceived = s.framesReceived;
+ _rateTickCount = 0;
+ }
+ });
+ } catch (_) {}
+ });
+ }
+
+ @override
+ void dispose() {
+ _statsTimer?.cancel();
+ super.dispose();
+ }
+
+ rust.BridgeAudioProcessingConfig _buildConfig() {
+ final c = widget.initialAudioConfig;
+ final isSonora =
+ c.iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
+ // VPIO owns enabled effects on the default path. Sonora owns them only in
+ // the experimental raw path.
+ final aecOwner = isSonora
+ ? (_aecEnabled
+ ? rust.BridgeEffectOwner.sonora
+ : rust.BridgeEffectOwner.off)
+ : rust.BridgeEffectOwner.platform;
+ final nsOwner = isSonora
+ ? (_nsEnabled
+ ? rust.BridgeEffectOwner.sonora
+ : rust.BridgeEffectOwner.off)
+ : (_nsEnabled
+ ? rust.BridgeEffectOwner.platform
+ : rust.BridgeEffectOwner.off);
+ final agcOwner = isSonora
+ ? (_agcEnabled
+ ? rust.BridgeEffectOwner.sonora
+ : rust.BridgeEffectOwner.off)
+ : (_agcEnabled
+ ? rust.BridgeEffectOwner.platform
+ : rust.BridgeEffectOwner.off);
+ return rust.BridgeAudioProcessingConfig(
+ route: c.route,
+ iosMode: c.iosMode,
+ processingBackend: c.processingBackend,
+ vadBackend: _vadBackend == rust.BridgeVadBackend.disabled
+ ? rust.BridgeVadBackend.webrtcVad
+ : _vadBackend,
+ aec: aecOwner,
+ ns: nsOwner,
+ agc: agcOwner,
+ hpfEnabled: _hpfEnabled,
+ limiterEnabled: c.limiterEnabled,
+ vadHangoverMs: c.vadHangoverMs,
+ vadPreRollMs: c.vadPreRollMs,
+ vadMinTxMs: c.vadMinTxMs,
+ debugWavDumpEnabled: c.debugWavDumpEnabled,
+ );
+ }
+
+ void _notifyAudioConfig() {
+ widget.onAudioConfigChanged(_buildConfig());
+ }
+
void _setMode(rust.BridgeTransmitMode m) {
- if (m == rust.BridgeTransmitMode.voiceActivity) {
- // Coming-soon \u2014 disabled in UI; defensive guard.
- return;
- }
+ if (m == _mode) return;
setState(() => _mode = m);
+ unawaited(Haptics.selection().catchError((_) {}));
widget.onModeChanged(m);
}
@@ -350,8 +501,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
- final stats = widget.audioStats;
- final levelActive = stats?.pttActive ?? false;
+ final stats = _stats;
+ // Level meter active = transmitting (any mode).
+ final levelActive = switch (_mode) {
+ rust.BridgeTransmitMode.continuous => true,
+ _ => stats?.pttActive ?? false,
+ };
final isPtt = _mode == rust.BridgeTransmitMode.ptt;
// Route picker is mobile-only. iOS uses AVAudioSession below;
@@ -360,6 +515,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
return SafeArea(
child: SingleChildScrollView(
+ controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -397,11 +553,10 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
onTap: () => _setMode(rust.BridgeTransmitMode.continuous),
),
_ModeRow(
- label:
- '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})',
+ label: l10n.voiceModeVoiceActivity,
icon: Icons.graphic_eq,
- selected: false,
- onTap: null,
+ selected: _mode == rust.BridgeTransmitMode.voiceActivity,
+ onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
),
// 3) Release-tail slider (PTT only).
@@ -454,20 +609,96 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
Divider(height: 1, color: theme.colorScheme.outlineVariant),
const SizedBox(height: 12),
- // 4) Level meter.
+ // 4) Level meter + live TX/RX stats.
_LevelMeter(active: levelActive),
const SizedBox(height: 6),
- if (stats != null)
- Text(
- l10n.audioStatsLine(
- stats.framesSent,
- stats.framesReceived,
- stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
- ),
- style: theme.textTheme.bodySmall,
- ),
+ _StatsRow(
+ txRate: _txRate,
+ rxRate: _rxRate,
+ totalSent: stats?.framesSent ?? 0,
+ totalReceived: stats?.framesReceived ?? 0,
+ transmitting: levelActive,
+ ),
- // 5) PTT capability badge. On iOS this must remain
+ // 5) Audio processing — always visible, all modes.
+ const SizedBox(height: 12),
+ Divider(height: 1, color: theme.colorScheme.outlineVariant),
+ const SizedBox(height: 8),
+ Text(
+ 'Audio processing',
+ style: theme.textTheme.labelLarge?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 4),
+ _AudioToggleRow(
+ label: 'Noise suppression',
+ subtitle: 'Wiener filter',
+ value: _nsEnabled,
+ onChanged: (v) {
+ setState(() => _nsEnabled = v);
+ _notifyAudioConfig();
+ },
+ ),
+ _AudioToggleRow(
+ label: 'Echo cancellation',
+ subtitle:
+ widget.initialAudioConfig.iosMode ==
+ rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
+ ? 'Always on · managed by platform VPIO'
+ : 'AEC3 adaptive filter · 80 ms tail',
+ value:
+ widget.initialAudioConfig.iosMode ==
+ rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
+ ? true // always on in VPIO
+ : _aecEnabled,
+ // AEC is always on in VPIO — disable the toggle.
+ onChanged:
+ widget.initialAudioConfig.iosMode ==
+ rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
+ ? null
+ : (v) {
+ setState(() => _aecEnabled = v);
+ _notifyAudioConfig();
+ },
+ ),
+ _AudioToggleRow(
+ label: 'Auto gain control',
+ subtitle: 'AGC2 · −18 dBFS target',
+ value: _agcEnabled,
+ onChanged: (v) {
+ setState(() => _agcEnabled = v);
+ _notifyAudioConfig();
+ },
+ ),
+ _AudioToggleRow(
+ label: 'High-pass filter',
+ subtitle: '80 Hz · DC removal',
+ value: _hpfEnabled,
+ onChanged: (v) {
+ setState(() => _hpfEnabled = v);
+ _notifyAudioConfig();
+ },
+ ),
+
+ // VAD backend selector.
+ const SizedBox(height: 8),
+ Text(
+ 'Voice activity detection',
+ style: theme.textTheme.labelLarge?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 2),
+ _VadBackendRow(
+ value: _vadBackend,
+ onChanged: (v) {
+ setState(() => _vadBackend = v);
+ _notifyAudioConfig();
+ },
+ ),
+
+ // 6) PTT capability badge. On iOS this must remain
// visible even though the resolved level is always
// `L0Focused`, because the P0 acceptance flow requires
// honest capability advertising with an explanation of
@@ -487,6 +718,151 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
}
}
+// ── Live TX/RX stats row ──────────────────────────────────────────────────
+
+class _StatsRow extends StatelessWidget {
+ const _StatsRow({
+ required this.txRate,
+ required this.rxRate,
+ required this.totalSent,
+ required this.totalReceived,
+ required this.transmitting,
+ });
+
+ final int txRate;
+ final int rxRate;
+ final int totalSent;
+ final int totalReceived;
+ final bool transmitting;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final txColor = transmitting
+ ? theme.colorScheme.primary
+ : theme.colorScheme.onSurfaceVariant;
+ return Row(
+ children: [
+ // TX
+ Icon(Icons.upload, size: 12, color: txColor),
+ const SizedBox(width: 3),
+ Text(
+ 'TX $txRate/s · $totalSent',
+ style: theme.textTheme.bodySmall?.copyWith(color: txColor),
+ ),
+ const SizedBox(width: 12),
+ // RX
+ Icon(
+ Icons.download,
+ size: 12,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(width: 3),
+ Text(
+ 'RX $rxRate/s · $totalReceived',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+// ── Audio processing helper widgets ──────────────────────────────────────
+
+/// Compact toggle row for a single DSP stage.
+class _AudioToggleRow extends StatelessWidget {
+ const _AudioToggleRow({
+ required this.label,
+ required this.subtitle,
+ required this.value,
+ required this.onChanged,
+ });
+
+ final String label;
+ final String subtitle;
+ final bool value;
+ final ValueChanged? onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final disabled = onChanged == null;
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 2),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: disabled
+ ? theme.colorScheme.onSurfaceVariant.withAlpha(120)
+ : null,
+ ),
+ ),
+ Text(
+ subtitle,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant.withAlpha(
+ disabled ? 80 : 160,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ Switch(value: value, onChanged: onChanged),
+ ],
+ ),
+ );
+ }
+}
+
+/// Segmented VAD backend selector.
+class _VadBackendRow extends StatelessWidget {
+ const _VadBackendRow({required this.value, required this.onChanged});
+
+ final rust.BridgeVadBackend value;
+ final ValueChanged onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return SegmentedButton(
+ style: SegmentedButton.styleFrom(
+ textStyle: theme.textTheme.labelSmall,
+ visualDensity: VisualDensity.compact,
+ ),
+ segments: const [
+ ButtonSegment(
+ value: rust.BridgeVadBackend.webrtcVad,
+ label: Text('WebRTC'),
+ icon: Icon(Icons.speed, size: 14),
+ ),
+ ButtonSegment(
+ value: rust.BridgeVadBackend.sileroOnnx,
+ label: Text('Silero'),
+ icon: Icon(Icons.psychology, size: 14),
+ ),
+ ButtonSegment(
+ value: rust.BridgeVadBackend.tenVad,
+ label: Text('TEN'),
+ icon: Icon(Icons.graphic_eq, size: 14),
+ ),
+ ],
+ selected: {value},
+ onSelectionChanged: (s) => onChanged(s.first),
+ );
+ }
+}
+
+// ── Mode row ──────────────────────────────────────────────────────────────
+
class _ModeRow extends StatelessWidget {
const _ModeRow({
required this.label,
@@ -509,30 +885,38 @@ class _ModeRow extends StatelessWidget {
: selected
? theme.colorScheme.primary
: theme.colorScheme.onSurface;
- return InkWell(
- onTap: onTap,
- borderRadius: BorderRadius.circular(8),
- child: Padding(
- padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
- child: Row(
- children: [
- Icon(
- selected
- ? Icons.radio_button_checked
- : Icons.radio_button_unchecked,
- size: 20,
- color: color,
+ return Semantics(
+ button: true,
+ selected: selected,
+ enabled: !disabled,
+ label: label,
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(8),
+ child: ExcludeSemantics(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
+ child: Row(
+ children: [
+ Icon(
+ selected
+ ? Icons.radio_button_checked
+ : Icons.radio_button_unchecked,
+ size: 20,
+ color: color,
+ ),
+ const SizedBox(width: 12),
+ Icon(icon, size: 18, color: color),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ label,
+ style: theme.textTheme.bodyLarge?.copyWith(color: color),
+ ),
+ ),
+ ],
),
- const SizedBox(width: 12),
- Icon(icon, size: 18, color: color),
- const SizedBox(width: 8),
- Expanded(
- child: Text(
- label,
- style: theme.textTheme.bodyLarge?.copyWith(color: color),
- ),
- ),
- ],
+ ),
),
),
);
@@ -725,7 +1109,9 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
);
if (changed != true && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('This output cannot be selected.')),
+ SnackBar(
+ content: Text(AppL10n.of(context).audioRouteCannotSelect),
+ ),
);
}
}
@@ -733,7 +1119,7 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Could not change audio output.')),
+ SnackBar(content: Text(AppL10n.of(context).audioRouteChangeFailed)),
);
}
return;
@@ -758,7 +1144,7 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
if (!kIsWeb && Platform.isAndroid) {
final selected = _androidDevices.where((d) => d.isSelected).firstOrNull;
final label = selected == null
- ? 'System default'
+ ? l10n.audioRouteSystemDefault
: _androidDeviceLabel(selected.type, selected.name, l10n);
return InkWell(
onTap: _openPicker,
@@ -847,14 +1233,14 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
AppL10n l10n,
) => switch (type) {
'speaker' => l10n.audioRouteSpeaker,
- 'earpiece' => 'Earpiece',
+ 'earpiece' => l10n.audioRouteEarpiece,
'wiredHeadset' || 'wiredHeadphones' => l10n.audioRouteWiredHeadset,
'bluetoothA2dp' ||
'bluetoothSco' ||
'bluetoothLe' => l10n.audioRouteBluetooth,
- 'usbHeadset' => fallback.isEmpty ? 'USB Headset' : fallback,
+ 'usbHeadset' => fallback.isEmpty ? l10n.audioRouteUsbHeadset : fallback,
'hdmi' => l10n.audioRouteCarAudio,
- _ => fallback.isEmpty ? 'Other Device' : fallback,
+ _ => fallback.isEmpty ? l10n.audioRouteOtherDevice : fallback,
};
static IconData _androidDeviceIcon(String? type) => switch (type) {
@@ -893,7 +1279,7 @@ class _AndroidAudioOutputPickerSheet extends StatelessWidget {
const SizedBox(height: 16),
_PickerRow(
icon: Icons.speaker,
- label: 'System default',
+ label: l10n.audioRouteSystemDefault,
selected: !devices.any((d) => d.isSelected),
onTap: () => Navigator.of(context).pop('auto'),
),
@@ -906,7 +1292,7 @@ class _AndroidAudioOutputPickerSheet extends StatelessWidget {
TextButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
- label: const Text('Refresh audio devices'),
+ label: Text(l10n.audioRouteRefreshDevices),
)
else
for (final device in devices)
@@ -1029,6 +1415,7 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.speaker,
);
+ unawaited(Haptics.selection().catchError((_) {}));
debugPrint('chanora: audio output -> speakerphone (override applied)');
} catch (e, st) {
debugPrint('chanora: _selectSpeaker FAILED: $e\n$st');
@@ -1047,6 +1434,7 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.none,
);
+ unawaited(Haptics.selection().catchError((_) {}));
debugPrint('chanora: audio output -> receiver (override cleared)');
} catch (e, st) {
debugPrint('chanora: _selectReceiver FAILED: $e\n$st');
@@ -1066,9 +1454,8 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
AVAudioSessionPortOverride.none,
);
await AVAudioSession().setPreferredInput(port);
- debugPrint(
- 'chanora: audio output -> ${port.portName} (${port.portType})',
- );
+ unawaited(Haptics.selection().catchError((_) {}));
+ debugPrint('chanora: audio output -> ${port.portType}');
} catch (e, st) {
debugPrint('chanora: _selectInput FAILED: $e\n$st');
}
@@ -1171,23 +1558,32 @@ class _PickerRow extends StatelessWidget {
: enabled
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant.withAlpha(130);
- return InkWell(
- onTap: onTap,
- borderRadius: BorderRadius.circular(8),
- child: Padding(
- padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4),
- child: Row(
- children: [
- Icon(icon, color: color),
- const SizedBox(width: 16),
- Expanded(
- child: Text(
- label,
- style: theme.textTheme.bodyLarge?.copyWith(color: color),
- ),
+ return Semantics(
+ button: true,
+ selected: selected,
+ enabled: enabled,
+ label: label,
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(8),
+ child: ExcludeSemantics(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4),
+ child: Row(
+ children: [
+ Icon(icon, color: color),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Text(
+ label,
+ style: theme.textTheme.bodyLarge?.copyWith(color: color),
+ ),
+ ),
+ if (selected)
+ Icon(Icons.check, color: theme.colorScheme.primary),
+ ],
),
- if (selected) Icon(Icons.check, color: theme.colorScheme.primary),
- ],
+ ),
),
),
);
diff --git a/apps/chanora_flutter/lib/widgets/voice_platform.dart b/apps/chanora_flutter/lib/widgets/voice_platform.dart
new file mode 100644
index 0000000..799185d
--- /dev/null
+++ b/apps/chanora_flutter/lib/widgets/voice_platform.dart
@@ -0,0 +1,10 @@
+import 'dart:io' show Platform;
+
+import 'package:flutter/foundation.dart' show kIsWeb;
+
+/// True when the host is a touch-only mobile platform without a
+/// hardware keyboard the user would bind a PTT key on.
+bool get isTouchOnlyPttHost {
+ if (kIsWeb) return false;
+ return Platform.isIOS || Platform.isAndroid;
+}
diff --git a/apps/chanora_flutter/lib/widgets/voice_settings.dart b/apps/chanora_flutter/lib/widgets/voice_settings.dart
index 9cb7c2a..00a63af 100644
--- a/apps/chanora_flutter/lib/widgets/voice_settings.dart
+++ b/apps/chanora_flutter/lib/widgets/voice_settings.dart
@@ -1,5 +1,13 @@
-// Voice settings dialog (SDD-097). Surfaces a TransmitMode radio
-// group, a bind-key button, and a release-tail slider.
+// Voice settings dialog (SDD-097). Surfaces transmit mode, release
+// tail, and the full P1 audio processing configuration:
+// - Noise suppression (NS)
+// - Echo cancellation (AEC3)
+// - Automatic gain control (AGC2)
+// - High-pass filter (HPF)
+// - VAD backend
+// - iOS voice processing mode
+
+// ignore_for_file: deprecated_member_use
import 'dart:io' show Platform;
@@ -7,52 +15,41 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
+import 'voice_platform.dart';
import '../src/rust/api.dart' as rust;
-/// True when the host is a touch-only mobile platform without a
-/// hardware keyboard the user would bind a PTT key on. Mirrors the
-/// helper in `voice_bar.dart`.
-bool get _isTouchOnlyPttHost {
+bool get _isIos {
if (kIsWeb) return false;
- return Platform.isIOS || Platform.isAndroid;
+ return Platform.isIOS;
}
-/// Result returned by [`VoiceSettingsDialog`]. `null` indicates a
-/// cancelled dialog.
+/// Result returned by [VoiceSettingsDialog].
class VoiceSettingsResult {
- /// Construct a result snapshot.
const VoiceSettingsResult({
required this.mode,
required this.releaseTailMs,
required this.bindKeyRequested,
+ required this.audioConfig,
});
- /// Selected transmit mode.
final rust.BridgeTransmitMode mode;
-
- /// Chosen release-tail in milliseconds (0..=500, step 25).
final int releaseTailMs;
-
- /// True when the user tapped the "bind key" button. The caller
- /// is expected to open the focus-scoped capture dialog
- /// afterwards.
final bool bindKeyRequested;
+ final rust.BridgeAudioProcessingConfig audioConfig;
}
-/// Voice settings dialog widget.
+/// Voice + audio processing settings dialog.
class VoiceSettingsDialog extends StatefulWidget {
- /// Construct a dialog seeded with the current settings.
const VoiceSettingsDialog({
super.key,
required this.initialMode,
required this.initialReleaseTailMs,
+ required this.initialAudioConfig,
});
- /// Currently active transmit mode.
final rust.BridgeTransmitMode initialMode;
-
- /// Currently configured release tail in milliseconds.
final int initialReleaseTailMs;
+ final rust.BridgeAudioProcessingConfig initialAudioConfig;
@override
State createState() => _VoiceSettingsDialogState();
@@ -62,115 +59,273 @@ class _VoiceSettingsDialogState extends State {
late rust.BridgeTransmitMode _mode;
late double _releaseTail;
+ // Audio processing state — mirrors BridgeAudioProcessingConfig fields.
+ late bool _nsEnabled;
+ late bool _aecEnabled;
+ late bool _agcEnabled;
+ late bool _hpfEnabled;
+ late bool _limiterEnabled;
+ late rust.BridgeVadBackend _vadBackend;
+ late rust.BridgeIosVoiceProcessingMode _iosMode;
+ late bool _debugWavDump;
+
@override
void initState() {
super.initState();
_mode = widget.initialMode;
_releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble();
+
+ final c = widget.initialAudioConfig;
+ _nsEnabled = c.ns != rust.BridgeEffectOwner.off;
+ _aecEnabled = c.aec != rust.BridgeEffectOwner.off;
+ _agcEnabled = c.agc != rust.BridgeEffectOwner.off;
+ _hpfEnabled = c.hpfEnabled;
+ _limiterEnabled = c.limiterEnabled;
+ _vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
+ ? rust.BridgeVadBackend.webrtcVad
+ : c.vadBackend;
+ _iosMode = c.iosMode;
+ _debugWavDump = c.debugWavDumpEnabled;
+ }
+
+ rust.BridgeAudioProcessingConfig _buildConfig() {
+ final c = widget.initialAudioConfig;
+ final isSonora =
+ _iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
+ // In VPIO mode, enabled effects are platform-owned. Sonora ownership is
+ // reserved for the experimental raw path so config validation stays honest.
+ final aecOwner = isSonora
+ ? (_aecEnabled
+ ? rust.BridgeEffectOwner.sonora
+ : rust.BridgeEffectOwner.off)
+ : rust.BridgeEffectOwner.platform; // VPIO always owns AEC
+ final nsOwner = isSonora
+ ? (_nsEnabled
+ ? rust.BridgeEffectOwner.sonora
+ : rust.BridgeEffectOwner.off)
+ : (_nsEnabled
+ ? rust.BridgeEffectOwner.platform
+ : rust.BridgeEffectOwner.off);
+ final agcOwner = isSonora
+ ? (_agcEnabled
+ ? rust.BridgeEffectOwner.sonora
+ : rust.BridgeEffectOwner.off)
+ : (_agcEnabled
+ ? rust.BridgeEffectOwner.platform
+ : rust.BridgeEffectOwner.off);
+ final vadBackend = _vadBackend == rust.BridgeVadBackend.disabled
+ ? rust.BridgeVadBackend.webrtcVad
+ : _vadBackend;
+ return rust.BridgeAudioProcessingConfig(
+ route: c.route,
+ iosMode: _iosMode,
+ processingBackend: isSonora
+ ? rust.BridgeAudioBackend.sonora
+ : rust.BridgeAudioBackend.platformVoiceProcessing,
+ vadBackend: vadBackend,
+ aec: aecOwner,
+ ns: nsOwner,
+ agc: agcOwner,
+ hpfEnabled: _hpfEnabled,
+ limiterEnabled: _limiterEnabled,
+ vadHangoverMs: c.vadHangoverMs,
+ vadPreRollMs: c.vadPreRollMs,
+ vadMinTxMs: c.vadMinTxMs,
+ debugWavDumpEnabled: _debugWavDump,
+ );
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
+ final platformVpio =
+ _iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
return AlertDialog(
title: Text(l10n.voiceSettingsTitle),
+ contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
content: SizedBox(
- width: 360,
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- l10n.voiceModeLabel,
- style: theme.textTheme.titleSmall,
- ),
- const SizedBox(height: 4),
- RadioListTile(
- dense: true,
- value: rust.BridgeTransmitMode.ptt,
- groupValue: _mode,
- title: Text(l10n.voiceModePtt),
- onChanged: (v) => setState(() => _mode = v!),
- ),
- RadioListTile(
- dense: true,
- value: rust.BridgeTransmitMode.continuous,
- groupValue: _mode,
- title: Text(l10n.voiceModeContinuous),
- onChanged: (v) => setState(() => _mode = v!),
- ),
- RadioListTile(
- dense: true,
- value: rust.BridgeTransmitMode.voiceActivity,
- groupValue: _mode,
- title: Text(l10n.voiceModeVoiceActivity),
- secondary: Text(
- l10n.voiceModeComingSoon,
- style: theme.textTheme.bodySmall,
+ width: 400,
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // ── Transmit mode ──────────────────────────────────────
+ _sectionHeader(theme, l10n.voiceModeLabel),
+ _radioTile(
+ value: rust.BridgeTransmitMode.ptt,
+ groupValue: _mode,
+ title: Text(l10n.voiceModePtt),
+ onSelected: (v) => _mode = v,
),
- // VoiceActivity is reserved per DEC-030 — keep the
- // tile visible but disabled per SDD-095.
- onChanged: null,
- ),
- const Divider(),
- // Bind-key + release-tail are PTT-only concepts. Hide
- // them entirely when the user has switched to a
- // non-PTT mode so the dialog stays focused on what's
- // actually configurable for that mode.
- //
- // Additionally on touch-only mobile hosts (iOS / iPadOS
- // / Android) there is no hardware keyboard to bind a
- // key on — the VoiceBar renders an on-screen Push to
- // Talk button instead. Hide the Bind Key affordance
- // there but keep the release-tail slider since it
- // still applies to the on-screen button's behaviour.
- if (_mode == rust.BridgeTransmitMode.ptt) ...[
- if (!_isTouchOnlyPttHost) ...[
- OutlinedButton.icon(
- icon: const Icon(Icons.keyboard),
- label: Text(l10n.voiceBindKeyAction),
- onPressed: () {
- Navigator.of(context).pop(
+ _radioTile(
+ value: rust.BridgeTransmitMode.continuous,
+ groupValue: _mode,
+ title: Text(l10n.voiceModeContinuous),
+ onSelected: (v) => _mode = v,
+ ),
+ _radioTile(
+ value: rust.BridgeTransmitMode.voiceActivity,
+ groupValue: _mode,
+ title: Text(l10n.voiceModeVoiceActivity),
+ onSelected: (v) => _mode = v,
+ ),
+
+ // ── PTT options ────────────────────────────────────────
+ if (_mode == rust.BridgeTransmitMode.ptt) ...[
+ const Divider(height: 24),
+ if (!isTouchOnlyPttHost) ...[
+ OutlinedButton.icon(
+ icon: const Icon(Icons.keyboard),
+ label: Text(l10n.voiceBindKeyAction),
+ onPressed: () => Navigator.of(context).pop(
VoiceSettingsResult(
mode: _mode,
releaseTailMs: _releaseTail.round(),
bindKeyRequested: true,
+ audioConfig: _buildConfig(),
),
- );
- },
- ),
- const SizedBox(height: 8),
- ],
- Text(
- l10n.voiceReleaseTailLabel,
- style: theme.textTheme.titleSmall,
- ),
- Row(
- children: [
- Expanded(
- child: Slider(
- value: _releaseTail,
- min: 0,
- max: 500,
- divisions: 20, // step 25 ms
- label:
- '${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
- onChanged: (v) => setState(() => _releaseTail = v),
- ),
- ),
- SizedBox(
- width: 64,
- child: Text(
- '${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
- style: theme.textTheme.bodySmall,
- textAlign: TextAlign.end,
),
),
+ const SizedBox(height: 8),
],
+ Text(
+ l10n.voiceReleaseTailLabel,
+ style: theme.textTheme.titleSmall,
+ ),
+ Row(
+ children: [
+ Expanded(
+ child: Slider(
+ value: _releaseTail,
+ min: 0,
+ max: 500,
+ divisions: 20,
+ label:
+ '${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
+ onChanged: (v) => setState(() => _releaseTail = v),
+ ),
+ ),
+ SizedBox(
+ width: 64,
+ child: Text(
+ '${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
+ style: theme.textTheme.bodySmall,
+ textAlign: TextAlign.end,
+ ),
+ ),
+ ],
+ ),
+ ],
+
+ // ── Audio processing ───────────────────────────────────
+ const Divider(height: 24),
+ _sectionHeader(theme, 'Audio processing'),
+
+ // iOS mode selector (iOS only)
+ if (_isIos) ...[
+ _subHeader(theme, 'Processing backend'),
+ _radioTile(
+ value:
+ rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
+ groupValue: _iosMode,
+ title: const Text('Platform (VPIO)'),
+ subtitle: _tileSubtitle('Apple AEC · NS · AGC'),
+ onSelected: (v) => _iosMode = v,
+ ),
+ _radioTile(
+ value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
+ groupValue: _iosMode,
+ title: const Text('Sonora (experimental)'),
+ subtitle: _tileSubtitle('Rust AEC3 · NS · AGC2'),
+ onSelected: (v) => _iosMode = v,
+ ),
+ const SizedBox(height: 4),
+ ],
+
+ // DSP toggles
+ _subHeader(theme, 'DSP stages'),
+ _switchTile(
+ title: 'Noise suppression (NS)',
+ subtitle: 'Wiener filter · stationary noise',
+ value: _nsEnabled,
+ onSelected: (v) => _nsEnabled = v,
),
+ _switchTile(
+ title: 'Echo cancellation (AEC3)',
+ subtitle: platformVpio
+ ? 'Managed by platform VPIO'
+ : 'Adaptive NLMS · 80 ms tail',
+ value: _aecEnabled,
+ // AEC is always on in VPIO mode — disable the toggle.
+ onSelected: platformVpio ? null : (v) => _aecEnabled = v,
+ ),
+ _switchTile(
+ title: 'Auto gain control (AGC2)',
+ subtitle: 'RNN VAD-gated · −18 dBFS target',
+ value: _agcEnabled,
+ onSelected: (v) => _agcEnabled = v,
+ ),
+ _switchTile(
+ title: 'High-pass filter (HPF)',
+ subtitle: '80 Hz Butterworth · DC removal',
+ value: _hpfEnabled,
+ onSelected: (v) => _hpfEnabled = v,
+ ),
+ _switchTile(
+ title: 'Peak limiter',
+ subtitle: '−1 dBFS soft-knee · 2 ms look-ahead',
+ value: _limiterEnabled,
+ onSelected: (v) => _limiterEnabled = v,
+ ),
+
+ // ── VAD ────────────────────────────────────────────────
+ const Divider(height: 24),
+ _sectionHeader(theme, 'Voice activity detection (VAD)'),
+
+ _subHeader(theme, 'Backend'),
+ _radioTile(
+ value: rust.BridgeVadBackend.webrtcVad,
+ groupValue: _vadBackend,
+ title: const Text('WebRTC VAD'),
+ subtitle: _tileSubtitle(
+ 'Fast · energy-based · always available',
+ ),
+ onSelected: (v) => _vadBackend = v,
+ ),
+ _radioTile(
+ value: rust.BridgeVadBackend.sileroOnnx,
+ groupValue: _vadBackend,
+ title: const Text('Silero v6 (ONNX)'),
+ subtitle: _tileSubtitle(
+ 'Neural · 32 ms frames · requires model file',
+ ),
+ onSelected: (v) => _vadBackend = v,
+ ),
+ _radioTile(
+ value: rust.BridgeVadBackend.tenVad,
+ groupValue: _vadBackend,
+ title: const Text('TEN VAD'),
+ subtitle: _tileSubtitle(
+ 'Neural · 16 kHz · native runtime optional',
+ ),
+ onSelected: (v) => _vadBackend = v,
+ ),
+ const SizedBox(height: 8),
+
+ // ── Debug ──────────────────────────────────────────────
+ const Divider(height: 24),
+ _sectionHeader(theme, 'Debug'),
+ _switchTile(
+ title: 'WAV dump',
+ subtitle: 'Record raw/processed mic to temp dir',
+ value: _debugWavDump,
+ onSelected: (v) => _debugWavDump = v,
+ ),
+ const SizedBox(height: 8),
],
- ],
+ ),
),
),
actions: [
@@ -184,6 +339,7 @@ class _VoiceSettingsDialogState extends State {
mode: _mode,
releaseTailMs: _releaseTail.round(),
bindKeyRequested: false,
+ audioConfig: _buildConfig(),
),
),
child: Text(l10n.pttConfigureSaveAction),
@@ -191,4 +347,54 @@ class _VoiceSettingsDialogState extends State {
],
);
}
+
+ Widget _radioTile({
+ required T value,
+ required T groupValue,
+ required Widget title,
+ Widget? subtitle,
+ required ValueChanged onSelected,
+ }) => RadioListTile(
+ dense: true,
+ value: value,
+ groupValue: groupValue,
+ title: title,
+ subtitle: subtitle,
+ onChanged: (v) {
+ if (v == null) return;
+ setState(() => onSelected(v));
+ },
+ );
+
+ Widget _switchTile({
+ required String title,
+ required String subtitle,
+ required bool value,
+ required ValueChanged? onSelected,
+ }) => SwitchListTile(
+ dense: true,
+ title: Text(title),
+ subtitle: _tileSubtitle(subtitle),
+ value: value,
+ onChanged: onSelected == null ? null : (v) => setState(() => onSelected(v)),
+ );
+
+ Widget _tileSubtitle(String text) =>
+ Text(text, style: const TextStyle(fontSize: 11));
+
+ Widget _sectionHeader(ThemeData theme, String text) => Padding(
+ padding: const EdgeInsets.only(bottom: 4),
+ child: Text(text, style: theme.textTheme.titleSmall),
+ );
+
+ Widget _subHeader(ThemeData theme, String text) => Padding(
+ padding: const EdgeInsets.only(top: 8, bottom: 2),
+ child: Text(
+ text,
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: theme.colorScheme.primary,
+ letterSpacing: 0.5,
+ ),
+ ),
+ );
}
diff --git a/apps/chanora_flutter/pubspec.lock b/apps/chanora_flutter/pubspec.lock
index 5243724..81ec45f 100644
--- a/apps/chanora_flutter/pubspec.lock
+++ b/apps/chanora_flutter/pubspec.lock
@@ -309,6 +309,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
+ haptic_kit:
+ dependency: "direct main"
+ description:
+ name: haptic_kit
+ sha256: "39efffa513c9f8ce3cdded8a4423797f69d71c9281779b83727337f3ee1ed9b8"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.0"
hooks:
dependency: transitive
description:
diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml
index 931db66..fe22e18 100644
--- a/apps/chanora_flutter/pubspec.yaml
+++ b/apps/chanora_flutter/pubspec.yaml
@@ -74,6 +74,9 @@ dependencies:
# more "am I testing the right build?" question during the
# iOS audio test cycle.
package_info_plus: ^10.1.0
+ # Touch-only PTT feedback for mobile voice UX (P0 voice basics,
+ # DEC-003 iOS 13 floor; haptic_kit supports iOS 12+).
+ haptic_kit: ^1.0.0
dev_dependencies:
flutter_test:
@@ -105,10 +108,9 @@ flutter:
# the material Icons class.
uses-material-design: true
- # To add assets to your application, add an assets section, like this:
- # assets:
- # - images/a_dot_burr.jpeg
- # - images/a_dot_ham.jpeg
+ assets:
+ - assets/models/silero_vad.onnx
+ - assets/models/ten_vad.onnx
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
diff --git a/apps/chanora_flutter/test/alpha_e2e_test.dart b/apps/chanora_flutter/test/alpha_e2e_test.dart
index b420f58..b08bc73 100644
--- a/apps/chanora_flutter/test/alpha_e2e_test.dart
+++ b/apps/chanora_flutter/test/alpha_e2e_test.dart
@@ -22,39 +22,50 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/src/rust/frb_generated.dart';
+const _runE2e = bool.fromEnvironment('CHANORA_RUN_E2E');
+const _skipReason =
+ 'Set --dart-define=CHANORA_RUN_E2E=true with a built native bridge to run '
+ 'network end-to-end acceptance.';
+
void main() {
setUpAll(() async {
+ if (!_runE2e) return;
await RustLib.init();
});
- test('connect/snapshot/disconnect against cn.teamspeak.app', () async {
- // Defensive: in case a previous test left a connection open.
- try {
+ test(
+ 'connect/snapshot/disconnect against cn.teamspeak.app',
+ () async {
+ // Defensive: in case a previous test left a connection open.
+ try {
+ await rust.disconnect();
+ } catch (_) {}
+
+ final snap = await rust.connect(
+ host: 'cn.teamspeak.app',
+ nickname: 'ChanoraAlphaTest',
+ password: '',
+ );
+ expect(snap.serverName, isNotEmpty);
+ expect(snap.channels, isNotEmpty);
+ // Welcome message is allowed to be empty on some servers; just
+ // assert it's a String type (which it always is — this is more a
+ // smoke than a real assertion).
+ expect(snap.welcomeMessage, isA());
+
+ // Re-fetch the snapshot; should still succeed.
+ final snap2 = await rust.snapshot();
+ expect(snap2.serverName, snap.serverName);
+
+ final connectedBefore = await rust.isConnected();
+ expect(connectedBefore, isTrue);
+
await rust.disconnect();
- } catch (_) {}
- final snap = await rust.connect(
- host: 'cn.teamspeak.app',
- nickname: 'ChanoraAlphaTest',
- password: '',
- );
- expect(snap.serverName, isNotEmpty);
- expect(snap.channels, isNotEmpty);
- // Welcome message is allowed to be empty on some servers; just
- // assert it's a String type (which it always is — this is more a
- // smoke than a real assertion).
- expect(snap.welcomeMessage, isA());
-
- // Re-fetch the snapshot; should still succeed.
- final snap2 = await rust.snapshot();
- expect(snap2.serverName, snap.serverName);
-
- final connectedBefore = await rust.isConnected();
- expect(connectedBefore, isTrue);
-
- await rust.disconnect();
-
- final connectedAfter = await rust.isConnected();
- expect(connectedAfter, isFalse);
- }, timeout: const Timeout(Duration(seconds: 30)));
+ final connectedAfter = await rust.isConnected();
+ expect(connectedAfter, isFalse);
+ },
+ timeout: const Timeout(Duration(seconds: 30)),
+ skip: _runE2e ? false : _skipReason,
+ );
}
diff --git a/apps/chanora_flutter/test/beta_e2e_test.dart b/apps/chanora_flutter/test/beta_e2e_test.dart
index d36dc50..295dd26 100644
--- a/apps/chanora_flutter/test/beta_e2e_test.dart
+++ b/apps/chanora_flutter/test/beta_e2e_test.dart
@@ -23,60 +23,73 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/src/rust/frb_generated.dart';
+const _runE2e = bool.fromEnvironment('CHANORA_RUN_E2E');
+const _skipReason =
+ 'Set --dart-define=CHANORA_RUN_E2E=true with a built native bridge to run '
+ 'network end-to-end acceptance.';
+
void main() {
setUpAll(() async {
+ if (!_runE2e) return;
await RustLib.init();
});
- test('connect → start_audio → PTT cycle → disconnect', () async {
- // Defensive cleanup in case a previous test left state.
- try {
+ test(
+ 'connect → start_audio → PTT cycle → disconnect',
+ () async {
+ // Defensive cleanup in case a previous test left state.
+ try {
+ await rust.disconnect();
+ } catch (_) {}
+
+ final snap = await rust.connect(
+ host: 'cn.teamspeak.app',
+ nickname: 'ChanoraBetaTest',
+ password: '',
+ );
+ expect(snap.serverName, isNotEmpty);
+ expect(snap.channels, isNotEmpty);
+
+ await rust.voiceJoin(channelId: snap.channels.first.id, password: '');
+
+ // Zero out the release tail so set_ptt(false) takes effect
+ // synchronously — the default 200 ms tail (SDD-096) would
+ // otherwise delay the assertion below.
+ await rust.setReleaseTailMs(ms: 0);
+
+ // Initial stats: PTT off, no frames sent yet.
+ final s0 = await rust.audioStats();
+ expect(s0.pttActive, isFalse);
+ expect(s0.framesSent, 0);
+
+ // Press PTT, wait ~250 ms, then read stats. If the host has a
+ // real microphone the encoder will emit ~10-12 frames. If the
+ // host has only a null source (typical headless), capture will
+ // have logged a warning at startAudio time and run in
+ // playback-only mode; framesSent stays at 0. Either outcome is
+ // a successful test of the wiring — what we actually verify
+ // here is that the PTT flag changes and no exception is thrown.
+ await rust.setPtt(active: true);
+ await Future.delayed(const Duration(milliseconds: 250));
+ final s1 = await rust.audioStats();
+ expect(s1.pttActive, isTrue);
+
+ await rust.setPtt(active: false);
+ // Give the release-tail (set to 0 above) one tick to settle.
+ await Future.delayed(const Duration(milliseconds: 50));
+ final s2 = await rust.audioStats();
+ expect(s2.pttActive, isFalse);
+
await rust.disconnect();
- } catch (_) {}
+ final connectedAfter = await rust.isConnected();
+ expect(connectedAfter, isFalse);
- final snap = await rust.connect(
- host: 'cn.teamspeak.app',
- nickname: 'ChanoraBetaTest',
- password: '',
- );
- expect(snap.serverName, isNotEmpty);
- expect(snap.channels, isNotEmpty);
-
- await rust.voiceJoin(channelId: snap.channels.first.id, password: '');
-
- // Zero out the release tail so set_ptt(false) takes effect
- // synchronously — the default 200 ms tail (SDD-096) would
- // otherwise delay the assertion below.
- await rust.setReleaseTailMs(ms: 0);
-
- // Initial stats: PTT off, no frames sent yet.
- final s0 = await rust.audioStats();
- expect(s0.pttActive, isFalse);
- expect(s0.framesSent, 0);
-
- // Press PTT, wait ~250 ms, then read stats. If the host has a
- // real microphone the encoder will emit ~10-12 frames. If the
- // host has only a null source (typical headless), capture will
- // have logged a warning at startAudio time and run in
- // playback-only mode; framesSent stays at 0. Either outcome is
- // a successful test of the wiring — what we actually verify
- // here is that the PTT flag changes and no exception is thrown.
- await rust.setPtt(active: true);
- await Future.delayed(const Duration(milliseconds: 250));
- final s1 = await rust.audioStats();
- expect(s1.pttActive, isTrue);
-
- await rust.setPtt(active: false);
- // Give the release-tail (set to 0 above) one tick to settle.
- await Future.delayed(const Duration(milliseconds: 50));
- final s2 = await rust.audioStats();
- expect(s2.pttActive, isFalse);
-
- await rust.disconnect();
- final connectedAfter = await rust.isConnected();
- expect(connectedAfter, isFalse);
-
- // ignore: avoid_print
- print('Beta E2E: TX=${s1.framesSent} frames, RX=${s1.framesReceived} frames');
- }, timeout: const Timeout(Duration(seconds: 30)));
+ // ignore: avoid_print
+ print(
+ 'Beta E2E: TX=${s1.framesSent} frames, RX=${s1.framesReceived} frames',
+ );
+ },
+ timeout: const Timeout(Duration(seconds: 30)),
+ skip: _runE2e ? false : _skipReason,
+ );
}
diff --git a/apps/chanora_flutter/test/widget_test.dart b/apps/chanora_flutter/test/widget_test.dart
index 02377fd..b3a3f9f 100644
--- a/apps/chanora_flutter/test/widget_test.dart
+++ b/apps/chanora_flutter/test/widget_test.dart
@@ -11,35 +11,224 @@
// Verification-plan rows: SWE4-UV-014, SWE4-UV-019, SWE4-UV-020 (swe4-unit-verification-plan.md).
import 'package:flutter/material.dart';
+import 'package:flutter/semantics.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
+// ignore_for_file: deprecated_member_use
+
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
+import 'package:chanora_flutter/services/android_permissions_service.dart';
+import 'package:chanora_flutter/services/ios_permissions_service.dart';
+import 'package:chanora_flutter/widgets/permission_state_banner.dart';
+import 'package:chanora_flutter/widgets/voice_compact.dart';
void main() {
testWidgets('renders English banner', (tester) async {
- await tester.pumpWidget(MaterialApp(
- localizationsDelegates: AppL10n.localizationsDelegates,
- supportedLocales: AppL10n.supportedLocales,
- home: Builder(builder: (ctx) {
- final l10n = AppL10n.of(ctx);
- return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
- }),
- ));
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppL10n.localizationsDelegates,
+ supportedLocales: AppL10n.supportedLocales,
+ home: Builder(
+ builder: (ctx) {
+ final l10n = AppL10n.of(ctx);
+ return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
+ },
+ ),
+ ),
+ );
await tester.pumpAndSettle();
expect(find.textContaining('Beta build'), findsOneWidget);
});
testWidgets('renders Simplified Chinese banner', (tester) async {
- await tester.pumpWidget(MaterialApp(
- locale: const Locale('zh'),
- localizationsDelegates: AppL10n.localizationsDelegates,
- supportedLocales: AppL10n.supportedLocales,
- home: Builder(builder: (ctx) {
- final l10n = AppL10n.of(ctx);
- return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
- }),
- ));
+ await tester.pumpWidget(
+ MaterialApp(
+ locale: const Locale('zh'),
+ localizationsDelegates: AppL10n.localizationsDelegates,
+ supportedLocales: AppL10n.supportedLocales,
+ home: Builder(
+ builder: (ctx) {
+ final l10n = AppL10n.of(ctx);
+ return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
+ },
+ ),
+ ),
+ );
await tester.pumpAndSettle();
expect(find.textContaining('Beta 版本'), findsOneWidget);
});
+
+ testWidgets('localizes diagnostic export save action', (tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppL10n.localizationsDelegates,
+ supportedLocales: AppL10n.supportedLocales,
+ home: Builder(
+ builder: (ctx) {
+ final l10n = AppL10n.of(ctx);
+ return Scaffold(
+ body: Column(
+ children: [
+ Text(l10n.diagnosticsSaveAction),
+ Text(l10n.diagnosticsSaved('/tmp/chanora-diagnostics.txt')),
+ ],
+ ),
+ );
+ },
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+ expect(find.text('Save export'), findsOneWidget);
+ expect(find.textContaining('chanora-diagnostics.txt'), findsOneWidget);
+ });
+
+ testWidgets('on-screen PTT exposes hold-to-talk semantics', (tester) async {
+ final semantics = tester.ensureSemantics();
+
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppL10n.localizationsDelegates,
+ supportedLocales: AppL10n.supportedLocales,
+ home: Scaffold(
+ body: VoicePttButton(active: false, onHeldChanged: (_) {}),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ final node = tester.getSemantics(find.byType(VoicePttButton));
+ expect(node.label, 'Hold to talk');
+ expect(node.hint, 'Press and hold to transmit voice; release to stop.');
+ expect(node.hasFlag(SemanticsFlag.isButton), isTrue);
+ expect(node.hasFlag(SemanticsFlag.isLiveRegion), isTrue);
+ semantics.dispose();
+ });
+
+ testWidgets('permission banner grants denied microphone access', (
+ tester,
+ ) async {
+ final state = ValueNotifier(AndroidRecordAudioPermissionState.denied);
+ var grantCount = 0;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppL10n.localizationsDelegates,
+ supportedLocales: AppL10n.supportedLocales,
+ home: Scaffold(
+ body: PermissionStateBanner.fromCallbacks(
+ recordAudioState: state,
+ ensureRecordAudio: () async {
+ grantCount += 1;
+ return AndroidRecordAudioPermissionState.granted;
+ },
+ openAppSettings: () async {},
+ ),
+ ),
+ ),
+ );
+
+ expect(find.text('Grant'), findsOneWidget);
+ await tester.tap(find.text('Grant'));
+ expect(grantCount, 1);
+ state.dispose();
+ });
+
+ testWidgets('permission banner opens settings after permanent denial', (
+ tester,
+ ) async {
+ final state = ValueNotifier(
+ AndroidRecordAudioPermissionState.permanentlyDenied,
+ );
+ var settingsCount = 0;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppL10n.localizationsDelegates,
+ supportedLocales: AppL10n.supportedLocales,
+ home: Scaffold(
+ body: PermissionStateBanner.fromCallbacks(
+ recordAudioState: state,
+ ensureRecordAudio: () async =>
+ AndroidRecordAudioPermissionState.permanentlyDenied,
+ openAppSettings: () async {
+ settingsCount += 1;
+ },
+ ),
+ ),
+ ),
+ );
+
+ expect(find.text('Open System Settings'), findsOneWidget);
+ await tester.tap(find.text('Open System Settings'));
+ expect(settingsCount, 1);
+ state.dispose();
+ });
+
+ testWidgets('on-screen PTT reports press and release gestures', (
+ tester,
+ ) async {
+ final states = [];
+
+ await tester.pumpWidget(
+ MaterialApp(
+ localizationsDelegates: AppL10n.localizationsDelegates,
+ supportedLocales: AppL10n.supportedLocales,
+ home: Scaffold(
+ body: VoicePttButton(active: false, onHeldChanged: states.add),
+ ),
+ ),
+ );
+
+ final center = tester.getCenter(find.byType(VoicePttButton));
+ final gesture = await tester.startGesture(center);
+ await tester.pump();
+ await gesture.up();
+ await tester.pump();
+
+ expect(states, [true, false]);
+ });
+
+ testWidgets('iOS permission service maps channel states and settings', (
+ tester,
+ ) async {
+ const channel = MethodChannel(iosPlatformChannelName);
+ final methods = [];
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, (call) async {
+ methods.add(call.method);
+ switch (call.method) {
+ case methodGetMicrophonePermissionState:
+ return 'NotDetermined';
+ case methodRequestMicrophonePermission:
+ return 'Granted';
+ case methodIosOpenAppSettings:
+ return true;
+ }
+ return null;
+ });
+
+ final service = IosPermissionsService(channel: channel);
+ await service.start();
+ expect(
+ service.recordAudioState.value,
+ AndroidRecordAudioPermissionState.denied,
+ );
+ expect(
+ await service.ensureRecordAudio(),
+ AndroidRecordAudioPermissionState.granted,
+ );
+ await service.openAppSettings();
+
+ expect(methods, [
+ methodGetMicrophonePermissionState,
+ methodRequestMicrophonePermission,
+ methodIosOpenAppSettings,
+ ]);
+
+ service.dispose();
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, null);
+ });
}
diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs
index f5aeece..9a5528e 100644
--- a/core/chanora_core/src/lib.rs
+++ b/core/chanora_core/src/lib.rs
@@ -54,8 +54,9 @@ use chanora_state::channel_join::{
pub mod ptt;
pub use chanora_audio::{
- AudioEngine, AudioEngineConfig, AudioTransmitGate, PttBackendDescriptor, PttCapabilityLevel,
- ReleaseTailTimer, TransmitMode, TransmitModeSelector,
+ AudioBackend, AudioEngine, AudioEngineConfig, AudioProcessingConfig, AudioProcessingStats,
+ AudioRoute, AudioTransmitGate, EffectOwner, IosVoiceProcessingMode, PttBackendDescriptor,
+ PttCapabilityLevel, ReleaseTailTimer, TransmitMode, TransmitModeSelector, VadBackend,
};
pub use chanora_audio::{PttBinding, PttInputClass};
pub use chanora_diagnostics::{
@@ -704,7 +705,7 @@ impl ChanoraSession {
/// Fails if not connected. Idempotent — calling twice replaces
/// the engine. Stores the config so the supervisor can restart
/// audio after a reconnect.
- pub async fn start_audio(&self, cfg: AudioEngineConfig) -> Result<(), CoreError> {
+ pub async fn start_audio(&self, mut cfg: AudioEngineConfig) -> Result<(), CoreError> {
let mut guard = self.inner.lock().await;
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
@@ -725,6 +726,7 @@ impl ChanoraSession {
.take_voice_in()
.ok_or(CoreError::Invariant("voice_in already taken"))?;
let gate = AudioTransmitGate::new(cfg.ptt_initial);
+ cfg.voice_activity_selector = Some(self.voice_selector.clone());
let new_engine = match chanora_audio::AudioEngine::start_with_gate(
cfg.clone(),
voice_out,
@@ -996,10 +998,10 @@ impl ChanoraSession {
}
/// Update self-mute state. `input` mutes the microphone, `output`
- /// mutes the local speaker for remote clients. Pass `None` to
- /// leave a field unchanged. Adjusting the local output mute also
- /// updates the audio engine's master output gain so playback
- /// silences immediately, independent of the server's broadcast.
+ /// mutes the local speaker. Pass `None` to leave a field
+ /// unchanged. Adjusting the local output mute also updates the
+ /// audio engine's master output gain so playback silences
+ /// immediately, independent of the server's broadcast.
pub async fn set_self_muted(
&self,
input: Option,
@@ -1007,37 +1009,24 @@ impl ChanoraSession {
) -> Result<(), CoreError> {
let mut guard = self.inner.lock().await;
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
- // Server-side output mute/deafen makes TeamSpeak/tsclientlib
- // consider the client unable to send audio. That is correct
- // for a server-visible "deafened" state, but our P0 speaker
- // button is a local playback mute. Keep output mute off the
- // server-side output/deafen flag, but fold it into the mic
- // disabled state below because P0 product semantics are:
- // speaker disabled also means microphone disabled.
if let Some(muted) = input {
state.local_input_muted = muted;
}
if let Some(muted) = output {
state.local_output_muted = muted;
}
- let mic_disabled = state.local_input_muted || state.local_output_muted;
- state.protocol.set_muted(Some(mic_disabled), None).await?;
+ state.protocol.set_muted(input, output).await?;
if let Some(muted) = output {
if let Some(audio) = state.audio.as_ref() {
audio.set_output_muted(muted);
}
}
- // When server-side input mute is engaged we must ALSO stop
- // producing outbound voice frames locally — otherwise the
- // Opus encoder happily writes packets, the protocol layer
- // hands them to tsclientlib, tsclientlib refuses them
- // because its own ClientMuted flag is set, and logs
- // "Sending audio while muted" once per 20 ms frame. That
- // flooded the log to 200 MB on the Korean test host.
- // Clamp the transmit-mode selector's hard_mute input so
- // the gate goes false too.
- self.voice_selector
- .set_hard_mute(mic_disabled);
+ // Input mute must also stop local outbound voice production
+ // so the transmit selector stays in sync with the server-side
+ // mic mute. Output mute is playback-only and must not affect
+ // the mic gate.
+ let mic_disabled = state.local_input_muted;
+ self.voice_selector.set_hard_mute(mic_disabled);
Ok(())
}
@@ -1059,18 +1048,98 @@ impl ChanoraSession {
Ok((audio.frames_sent(), audio.frames_received(), audio.ptt()))
}
- /// iOS route-change hook (SDD-100). No-op when audio is not
- /// running.
- pub async fn ios_handle_route_change(&self) -> Result<(), CoreError> {
+ /// Apply the Rust-owned P1 audio-processing configuration.
+ pub async fn set_audio_processing_config(
+ &self,
+ config: AudioProcessingConfig,
+ ) -> Result<(), CoreError> {
let guard = self.inner.lock().await;
- if let Some(state) = guard.as_ref() {
- if let Some(audio) = state.audio.as_ref() {
+ let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
+ let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
+ let new_config = config.clone();
+ let current = audio.audio_processing_config_snapshot();
+ audio.set_audio_processing_config(config)?;
+ #[cfg(any(target_os = "ios", target_os = "macos"))]
+ {
+ if current.route != new_config.route
+ || current.ios_mode != new_config.ios_mode
+ || current.processing_backend != new_config.processing_backend
+ {
audio.ios_restart_voice_unit()?;
}
}
Ok(())
}
+ /// Read the current audio-processing configuration.
+ ///
+ /// Returns the live config snapshot from the audio engine, or a
+ /// default config when no session / audio engine is active.
+ pub async fn get_audio_processing_config(&self) -> Result {
+ let guard = self.inner.lock().await;
+ if let Some(state) = guard.as_ref() {
+ if let Some(audio) = state.audio.as_ref() {
+ return Ok(audio.audio_processing_config_snapshot());
+ }
+ }
+ Ok(AudioProcessingConfig::default())
+ }
+
+ /// Read P1 audio-processing diagnostics.
+ pub async fn audio_processing_stats(&self) -> Result {
+ let guard = self.inner.lock().await;
+ let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
+ let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
+ Ok(audio.audio_processing_stats())
+ }
+
+ /// Configure the preferred Silero ONNX VAD model path.
+ ///
+ /// This does not require an active connection. Running iOS audio
+ /// backends observe the model-path epoch and reload on the next
+ /// capture frame when Silero is selected.
+ pub async fn set_vad_model_path(&self, path: String) -> Result<(), CoreError> {
+ chanora_audio::vad::set_silero_model_path(&path)?;
+ Ok(())
+ }
+
+ /// iOS route-change hook (SDD-100). No-op when audio is not running.
+ pub async fn ios_handle_route_change(&self, route: AudioRoute) -> Result<(), CoreError> {
+ let guard = self.inner.lock().await;
+ if let Some(state) = guard.as_ref() {
+ if let Some(audio) = state.audio.as_ref() {
+ let mut config = audio.audio_processing_config_snapshot();
+ config.route = route;
+ audio.set_audio_processing_config(config)?;
+ }
+ }
+ Ok(())
+ }
+
+ /// Enable or disable async WAV debug dump for the active audio
+ /// session (DIAG_002 / DIAG_003). No-op when audio is not started.
+ pub async fn set_audio_debug_wav_dump(&self, enabled: bool) -> Result<(), CoreError> {
+ let guard = self.inner.lock().await;
+ if let Some(state) = guard.as_ref() {
+ if let Some(audio) = state.audio.as_ref() {
+ let mut config = audio.audio_processing_config_snapshot();
+ config.debug_wav_dump_enabled = enabled;
+ audio.set_audio_processing_config(config)?;
+ }
+ }
+ Ok(())
+ }
+
+ /// iOS media-services-reset hook (SDD-101). Rebuilds the audio
+ /// unit using the supplied route so the processing policy is
+ /// correct after the OS-level media reset.
+ pub async fn ios_handle_media_services_reset(
+ &self,
+ route: AudioRoute,
+ ) -> Result<(), CoreError> {
+ self.ios_handle_route_change(route).await
+ }
+
/// iOS interruption-began hook (SDD-101). No-op when audio is
/// not running.
pub async fn ios_handle_interruption_began(&self) -> Result<(), CoreError> {
@@ -1519,6 +1588,7 @@ const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// declares the connection lost.
const WATCHDOG_MAX_MISSES: u32 = 3;
+#[allow(clippy::too_many_arguments)]
async fn supervisor_loop(
state_arc: Arc>>,
events_tx: broadcast::Sender,
diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml
index 97c6ac6..a7dc7e4 100644
--- a/crates/chanora_audio/Cargo.toml
+++ b/crates/chanora_audio/Cargo.toml
@@ -44,6 +44,22 @@ reqwest = { version = "0.13", default-features = false, features = ["charset", "
# Default features keep `audio_toolbox` + `core_audio`, both required
# for AudioUnit construction + property access.
coreaudio-rs = "0.14"
+# Grand Central Dispatch bindings — used to run AudioUnit initialize/start
+# on the main queue to avoid the VPIO RPC timeout on iOS simulator.
+dispatch2 = "0.3"
+# ndarray is required by ort's tensor construction API.
+ndarray = "0.16"
+
+[target.'cfg(target_os = "ios")'.dependencies]
+# ONNX Runtime Rust binding for Silero VAD v6 (P1 VAD_002). The official
+# iOS CocoaPod ships ONNX Runtime as a static framework, so iOS links it
+# into chanora_bridge at build time instead of loading a dylib at runtime.
+ort = { version = "2.0.0-rc.10", default-features = false, features = ["std", "ndarray"] }
+
+[target.'cfg(target_os = "macos")'.dependencies]
+# macOS keeps dynamic loading so developer machines can provide ORT via
+# ORT_DYLIB_PATH without forcing a bundled runtime into desktop builds.
+ort = { version = "2.0.0-rc.10", default-features = false, features = ["load-dynamic", "ndarray"] }
[target.'cfg(target_os = "android")'.dependencies]
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
diff --git a/crates/chanora_audio/benches/opus_codec.rs b/crates/chanora_audio/benches/opus_codec.rs
index 0bb891c..bf8aa65 100644
--- a/crates/chanora_audio/benches/opus_codec.rs
+++ b/crates/chanora_audio/benches/opus_codec.rs
@@ -19,7 +19,7 @@ mod common;
use common::{synthetic_opus_bytes, synthetic_opus_frame};
fn bench_opus_encode_latency(c: &mut Criterion) {
- let mut enc = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
+ let enc = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
.expect("opus encoder init");
let pcm = synthetic_opus_frame();
let mut out = vec![0u8; 1275];
diff --git a/crates/chanora_audio/benches/realtime_capture.rs b/crates/chanora_audio/benches/realtime_capture.rs
index 1d3f71e..a337131 100644
--- a/crates/chanora_audio/benches/realtime_capture.rs
+++ b/crates/chanora_audio/benches/realtime_capture.rs
@@ -15,15 +15,24 @@
// builds and other benches are unaffected per Cargo's per-bench
// compilation model.
+#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
+fn main() {}
+
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
use chanora_audio::bench_seam::CaptureBenchHandle;
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
use criterion::{black_box, criterion_group, criterion_main, Criterion};
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
mod common;
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
use common::{synthetic_capture_buffer, FRAME_SAMPLES};
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn bench_capture_alloc_count(c: &mut Criterion) {
// Build the dhat profiler in test mode so it is process-local
// and does not write a JSON heap-dump file. Held for the
@@ -90,6 +99,7 @@ fn bench_capture_alloc_count(c: &mut Criterion) {
}
}
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn bench_capture_callback_wall_clock(c: &mut Criterion) {
let mut handle = CaptureBenchHandle::new(48_000, 1);
let buf = synthetic_capture_buffer(FRAME_SAMPLES, 1);
@@ -108,9 +118,11 @@ fn bench_capture_callback_wall_clock(c: &mut Criterion) {
});
}
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
criterion_group!(
realtime_capture,
bench_capture_alloc_count,
bench_capture_callback_wall_clock
);
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
criterion_main!(realtime_capture);
diff --git a/crates/chanora_audio/benches/resampler.rs b/crates/chanora_audio/benches/resampler.rs
index 2fce3d0..ce70ba7 100644
--- a/crates/chanora_audio/benches/resampler.rs
+++ b/crates/chanora_audio/benches/resampler.rs
@@ -8,12 +8,20 @@
// feeding a 1-second buffer per iteration. Throughput is reported
// as samples/sec via criterion's `Throughput::Elements`.
+#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
+fn main() {}
+
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
use chanora_audio::bench_seam::CaptureBenchHandle;
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput};
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
mod common;
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
use common::synthetic_capture_buffer;
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn bench_resampler_throughput(c: &mut Criterion) {
let mut group = c.benchmark_group("resampler_throughput");
@@ -39,5 +47,7 @@ fn bench_resampler_throughput(c: &mut Criterion) {
group.finish();
}
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
criterion_group!(resampler, bench_resampler_throughput);
+#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
criterion_main!(resampler);
diff --git a/crates/chanora_audio/src/android_voice_unit.rs b/crates/chanora_audio/src/android_voice_unit.rs
index db97a4b..d3d05f9 100644
--- a/crates/chanora_audio/src/android_voice_unit.rs
+++ b/crates/chanora_audio/src/android_voice_unit.rs
@@ -42,7 +42,6 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use audiopus::coder::Encoder as OpusEncoder;
-use audiopus::{Application as OpusApp, Channels as OpusChannels, SampleRate as OpusSampleRate};
use tracing::{debug, info, warn};
use crate::mobile_voice_backend::{
@@ -53,7 +52,7 @@ use crate::mobile_voice_backend::{
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
SharingModeChoice,
};
-use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
+use chanora_protocol::OutPacket;
use tsclientlib::audio::AudioHandler;
use crate::{engine::SessionAudioId, AudioError};
@@ -71,19 +70,11 @@ use oboe::{
// `mobile_voice_backend` so the trait can expose `take_event_rx`
// (SDD-111 item 1) cross-platform.
-/// 20 ms at 48 kHz mono — one Opus frame's worth of samples.
-/// Matches the iOS and desktop constants; duplicated here so this
-/// module is fully self-contained and cfg-gate-clean.
-const FRAME_SAMPLES: usize = 960;
-
-/// Maximum size of an encoded Opus frame in bytes (RFC 6716 §3.2.1).
-const MAX_OPUS_FRAME: usize = 1275;
-
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
//
// Mirrors the iOS `IosCaptureState` and the cpal-side `CaptureState`.
// Oboe delivers 48 kHz mono i16 PCM; we apply mic gain, accumulate to
-// FRAME_SAMPLES, encode to Opus 32 kbps (complexity 10, inband FEC, 5 % PLC),
+// FRAME_20MS_SAMPLES, encode to Opus 32 kbps (complexity 10, inband FEC, 5 % PLC),
// and try-send the resulting packet on `voice_out_tx`.
struct AndroidCaptureState {
@@ -91,7 +82,7 @@ struct AndroidCaptureState {
/// Accumulator for 48 kHz mono PCM. 2x capacity to absorb
/// cpal-style buffer-size jitter without reallocating.
pcm_accum: Vec,
- opus_out: [u8; MAX_OPUS_FRAME],
+ opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender,
transmit_active: Arc,
frames_sent: Arc,
@@ -105,33 +96,11 @@ impl AndroidCaptureState {
frames_sent: Arc,
mic_gain: f32,
) -> Result {
- let mut encoder =
- OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
- .map_err(|e| AudioError::Opus(format!("encoder new (android): {e}")))?;
- if let Err(e) = encoder.set_bitrate(audiopus::Bitrate::BitsPerSecond(32_000)) {
- warn!(target: "chanora_audio", error = %e, "opus(android): set_bitrate(32000) failed");
- }
- if let Err(e) = encoder.set_complexity(10) {
- warn!(target: "chanora_audio", error = %e, "opus(android): set_complexity(10) failed");
- }
- if let Err(e) = encoder.set_inband_fec(true) {
- warn!(target: "chanora_audio", error = %e, "opus(android): set_inband_fec(true) failed");
- }
- if let Err(e) = encoder.set_packet_loss_perc(5) {
- warn!(target: "chanora_audio", error = %e, "opus(android): set_packet_loss_perc(5) failed");
- }
- info!(
- target: "chanora_audio",
- bitrate_bps = 32_000,
- complexity = 10,
- inband_fec = true,
- packet_loss_perc = 5,
- "android Oboe opus encoder tuned for VoIP"
- );
+ let encoder = crate::opus_voice::new_voip_encoder("android")?;
Ok(Self {
encoder,
- pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
- opus_out: [0u8; MAX_OPUS_FRAME],
+ pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
+ opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
frames_sent,
@@ -139,7 +108,7 @@ impl AndroidCaptureState {
})
}
- /// Consume i16 mono frames from Oboe, accumulate to FRAME_SAMPLES,
+ /// Consume i16 mono frames from Oboe, accumulate to FRAME_20MS_SAMPLES,
/// encode + send when PTT is held. Oboe delivers at the device's
/// native sample rate (always 48 kHz for modern Android per SRS-210),
/// so no resampling is needed.
@@ -159,28 +128,30 @@ impl AndroidCaptureState {
}));
}
// Drain complete 20 ms frames.
- while self.pcm_accum.len() >= FRAME_SAMPLES {
- let mut frame = [0i16; FRAME_SAMPLES];
- frame.copy_from_slice(&self.pcm_accum[..FRAME_SAMPLES]);
- self.pcm_accum.drain(..FRAME_SAMPLES);
+ while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
+ let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
+ frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
+ self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
Ok(len) => {
- let packet = OutAudio::new(&AudioData::C2S {
- id: 0,
- codec: CodecType::OpusVoice,
- data: &self.opus_out[..len],
- });
- match self.voice_out_tx.try_send(packet) {
- Ok(()) => {
- self.frames_sent.fetch_add(1, Ordering::Relaxed);
- }
- Err(mpsc::error::TrySendError::Full(_)) => {
- warn!(target: "chanora_audio", "android Oboe: voice_out queue full; dropping frame");
- }
- Err(mpsc::error::TrySendError::Closed(_)) => {
- debug!(target: "chanora_audio", "android Oboe: voice_out closed; capture pipeline stopping");
- }
- }
+ crate::opus_voice::send_voip_frame(
+ &self.voice_out_tx,
+ &self.frames_sent,
+ &self.opus_out,
+ len,
+ || {
+ warn!(
+ target: "chanora_audio",
+ "android Oboe: voice_out queue full; dropping frame"
+ );
+ },
+ || {
+ debug!(
+ target: "chanora_audio",
+ "android Oboe: voice_out closed; capture pipeline stopping"
+ );
+ },
+ );
}
Err(e) => {
warn!(target: "chanora_audio", error = %e, "android Oboe opus encode failed");
@@ -266,22 +237,12 @@ impl AudioOutputCallback for OutputCallback {
}
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
let muted = self.output_muted.load(Ordering::Relaxed);
- let mut peak: i16 = 0;
- for (i, dst) in frames.iter_mut().enumerate() {
- if muted {
- *dst = 0;
- continue;
- }
- let l = scratch[i * 2];
- let r = scratch[i * 2 + 1];
- let mono = (l + r) * 0.5 * gain;
- let clamped = mono.clamp(-1.0, 1.0);
- let sample = (clamped * i16::MAX as f32) as i16;
- *dst = sample;
- if sample.unsigned_abs() > peak.unsigned_abs() {
- peak = sample;
- }
- }
+ let _ = crate::voice_render::downmix_stereo_f32_to_mono_i16(
+ &scratch[..needed],
+ frames,
+ gain,
+ muted,
+ );
}));
DataCallbackResult::Continue
}
diff --git a/crates/chanora_audio/src/audio_processing.rs b/crates/chanora_audio/src/audio_processing.rs
new file mode 100644
index 0000000..482c740
--- /dev/null
+++ b/crates/chanora_audio/src/audio_processing.rs
@@ -0,0 +1,456 @@
+//! P1 audio-processing configuration and statistics.
+//!
+//! iOS P1 ships the platform VoiceProcessingIO path by default. Rust
+//! software AEC/NS/AGC backends are represented in the schema so the
+//! bridge can reject unsafe combinations instead of silently enabling
+//! double processing.
+
+use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
+
+use crate::AudioError;
+
+/// Physical/logical audio route class used for route-aware policy.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AudioRoute {
+ /// Built-in speakerphone path.
+ Speaker,
+ /// Built-in receiver/earpiece path.
+ Earpiece,
+ /// Wired headset or USB headset.
+ WiredHeadset,
+ /// Bluetooth Hands-Free Profile duplex route.
+ BluetoothHfp,
+ /// Bluetooth A2DP output-only route.
+ BluetoothA2dp,
+ /// Route could not be classified yet.
+ Unknown,
+}
+
+impl AudioRoute {
+ /// Stable bridge/debug string.
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Speaker => "speaker",
+ Self::Earpiece => "earpiece",
+ Self::WiredHeadset => "wired_headset",
+ Self::BluetoothHfp => "bluetooth_hfp",
+ Self::BluetoothA2dp => "bluetooth_a2dp",
+ Self::Unknown => "unknown",
+ }
+ }
+
+ /// Parse from the Swift-side route class string (case-insensitive).
+ /// Unrecognised strings map to `Unknown`.
+ pub fn from_route_class(s: &str) -> Self {
+ match s {
+ "Speaker" | "speaker" => Self::Speaker,
+ "Earpiece" | "earpiece" => Self::Earpiece,
+ "WiredHeadset" | "wired_headset" => Self::WiredHeadset,
+ "BluetoothHfp" | "bluetooth_hfp" => Self::BluetoothHfp,
+ "BluetoothA2dp" | "bluetooth_a2dp" => Self::BluetoothA2dp,
+ _ => Self::Unknown,
+ }
+ }
+}
+
+/// iOS voice-processing mode.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum IosVoiceProcessingMode {
+ /// Shipping default: Apple VoiceProcessingIO owns AEC/NS/AGC.
+ PlatformVoiceProcessing,
+ /// Experimental Sonora capture-processing path.
+ SonoraExperimental,
+}
+
+/// Processing backend selected by policy/config.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AudioBackend {
+ /// Platform voice processing, VPIO on iOS.
+ PlatformVoiceProcessing,
+ /// Rust-native Sonora backend.
+ Sonora,
+ /// Future WebRTC APM backend.
+ WebrtcApm,
+ /// No processing.
+ Noop,
+}
+
+impl AudioBackend {
+ /// Stable bridge/debug string.
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::PlatformVoiceProcessing => "platform_voice_processing",
+ Self::Sonora => "sonora",
+ Self::WebrtcApm => "webrtc_apm",
+ Self::Noop => "noop",
+ }
+ }
+}
+
+/// VAD backend selected by policy/config.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum VadBackend {
+ /// Silero ONNX VAD. P1 schema default when model/runtime exist.
+ SileroOnnx,
+ /// TEN VAD backend. Native TEN runtime is optional; unavailable
+ /// builds fall back to the realtime-safe WebRTC detector.
+ TenVad,
+ /// WebRTC-style fallback VAD.
+ WebrtcVad,
+ /// Debug-only energy VAD.
+ EnergyDebug,
+ /// VAD disabled.
+ Disabled,
+}
+
+impl VadBackend {
+ /// Stable bridge/debug string.
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::SileroOnnx => "silero_vad_onnx",
+ Self::TenVad => "ten_vad",
+ Self::WebrtcVad => "webrtc_vad",
+ Self::EnergyDebug => "energy_debug",
+ Self::Disabled => "disabled",
+ }
+ }
+}
+
+/// Effect owner for AEC/NS/AGC policy fields.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum EffectOwner {
+ /// Owned by platform voice processing.
+ Platform,
+ /// Owned by Sonora.
+ Sonora,
+ /// Owned by WebRTC APM.
+ WebrtcApm,
+ /// Conservative route-managed setting.
+ Conservative,
+ /// Disabled.
+ Off,
+}
+
+/// Voice-processing configuration owned by the Rust audio engine.
+#[derive(Debug, Clone, PartialEq)]
+pub struct AudioProcessingConfig {
+ /// Current route class.
+ pub route: AudioRoute,
+ /// iOS processing mode.
+ pub ios_mode: IosVoiceProcessingMode,
+ /// Processing backend.
+ pub processing_backend: AudioBackend,
+ /// VAD backend.
+ pub vad_backend: VadBackend,
+ /// AEC owner.
+ pub aec: EffectOwner,
+ /// Noise suppression owner.
+ pub ns: EffectOwner,
+ /// AGC owner.
+ pub agc: EffectOwner,
+ /// High-pass filter enabled.
+ pub hpf_enabled: bool,
+ /// Limiter enabled.
+ pub limiter_enabled: bool,
+ /// Hangover after speech closes.
+ pub vad_hangover_ms: u32,
+ /// Pre-roll before open.
+ pub vad_pre_roll_ms: u32,
+ /// Minimum transmit duration after open.
+ pub vad_min_tx_ms: u32,
+ /// Debug WAV dumps enabled.
+ pub debug_wav_dump_enabled: bool,
+}
+
+impl Default for AudioProcessingConfig {
+ fn default() -> Self {
+ Self {
+ route: AudioRoute::Speaker,
+ ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
+ processing_backend: AudioBackend::PlatformVoiceProcessing,
+ vad_backend: VadBackend::SileroOnnx,
+ aec: EffectOwner::Platform,
+ // iOS VPIO owns NS/AGC on the default shipping path. Rust/Sonora
+ // effects are opt-in through the experimental raw route only.
+ ns: EffectOwner::Platform,
+ agc: EffectOwner::Platform,
+ hpf_enabled: true,
+ limiter_enabled: true,
+ vad_hangover_ms: crate::voice_activity::VAD_HANGOVER_MS,
+ vad_pre_roll_ms: 160,
+ vad_min_tx_ms: crate::voice_activity::VAD_MIN_TX_MS,
+ debug_wav_dump_enabled: false,
+ }
+ }
+}
+
+impl AudioProcessingConfig {
+ /// Validate P1 iOS invariants before applying a config.
+ pub fn validate_for_ios(&self) -> Result<(), AudioError> {
+ if self.route == AudioRoute::BluetoothA2dp {
+ return Err(AudioError::InvalidAudioProcessingConfig(
+ "bluetooth_a2dp is output-only and cannot transmit duplex voice".to_string(),
+ ));
+ }
+ if self.ios_mode == IosVoiceProcessingMode::PlatformVoiceProcessing
+ && (self.processing_backend == AudioBackend::Sonora
+ || self.aec == EffectOwner::Sonora
+ || self.ns == EffectOwner::Sonora
+ || self.agc == EffectOwner::Sonora)
+ {
+ return Err(AudioError::InvalidAudioProcessingConfig(
+ "Sonora cannot be enabled with iOS VoiceProcessingIO".to_string(),
+ ));
+ }
+ if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental {
+ if self.processing_backend != AudioBackend::Sonora {
+ return Err(AudioError::InvalidAudioProcessingConfig(
+ "ios Sonora experimental mode requires the Sonora processing backend"
+ .to_string(),
+ ));
+ }
+ }
+ Ok(())
+ }
+
+ /// Demote a failed VAD backend to the WebRTC fallback.
+ ///
+ /// Returns `true` when the config changed.
+ pub fn disable_failed_vad_backend(&mut self, failed_backend: VadBackend) -> bool {
+ if self.vad_backend == failed_backend && failed_backend != VadBackend::WebrtcVad {
+ self.vad_backend = VadBackend::WebrtcVad;
+ true
+ } else {
+ false
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn default_config_is_valid_for_ios_vpio() {
+ let config = AudioProcessingConfig::default();
+
+ assert!(config.validate_for_ios().is_ok());
+ assert_eq!(
+ config.processing_backend,
+ AudioBackend::PlatformVoiceProcessing
+ );
+ assert_eq!(config.aec, EffectOwner::Platform);
+ assert_eq!(config.ns, EffectOwner::Platform);
+ assert_eq!(config.agc, EffectOwner::Platform);
+ assert_eq!(config.vad_backend, VadBackend::SileroOnnx);
+ }
+
+ #[test]
+ fn platform_voice_processing_rejects_sonora_effects() {
+ let config = AudioProcessingConfig {
+ ns: EffectOwner::Sonora,
+ ..AudioProcessingConfig::default()
+ };
+
+ assert!(config.validate_for_ios().is_err());
+ }
+
+ #[test]
+ fn ten_vad_has_stable_debug_string() {
+ assert_eq!(VadBackend::TenVad.as_str(), "ten_vad");
+ }
+
+ #[test]
+ fn sonora_experimental_allows_full_sonora_chain() {
+ let config = AudioProcessingConfig {
+ ios_mode: IosVoiceProcessingMode::SonoraExperimental,
+ processing_backend: AudioBackend::Sonora,
+ aec: EffectOwner::Sonora,
+ ns: EffectOwner::Sonora,
+ agc: EffectOwner::Sonora,
+ ..AudioProcessingConfig::default()
+ };
+
+ assert!(config.validate_for_ios().is_ok());
+ }
+
+ #[test]
+ fn sonora_experimental_rejects_non_sonora_backend() {
+ let config = AudioProcessingConfig {
+ ios_mode: IosVoiceProcessingMode::SonoraExperimental,
+ processing_backend: AudioBackend::PlatformVoiceProcessing,
+ aec: EffectOwner::Sonora,
+ ns: EffectOwner::Sonora,
+ agc: EffectOwner::Sonora,
+ ..AudioProcessingConfig::default()
+ };
+
+ assert!(config.validate_for_ios().is_err());
+ }
+
+ #[test]
+ fn disable_failed_vad_backend_demotes_to_webrtc() {
+ let mut config = AudioProcessingConfig {
+ vad_backend: VadBackend::SileroOnnx,
+ ..AudioProcessingConfig::default()
+ };
+
+ assert!(config.disable_failed_vad_backend(VadBackend::SileroOnnx));
+ assert_eq!(config.vad_backend, VadBackend::WebrtcVad);
+ assert!(!config.disable_failed_vad_backend(VadBackend::SileroOnnx));
+ }
+}
+
+/// Runtime audio processing stats exposed to bridge/UI diagnostics.
+#[derive(Debug, Clone)]
+pub struct AudioProcessingStats {
+ /// Input dBFS before processing.
+ pub input_dbfs: f32,
+ /// Render dBFS before playout.
+ pub render_dbfs: f32,
+ /// Processed capture dBFS.
+ pub processed_dbfs: f32,
+ /// Latest VAD probability or fallback confidence.
+ pub vad_probability: f32,
+ /// VAD active state.
+ pub vad_active: bool,
+ /// Current resolved transmit state.
+ pub transmitting: bool,
+ /// VAD backend.
+ pub vad_backend: VadBackend,
+ /// Whether fallback VAD is active.
+ pub vad_fallback_active: bool,
+ /// Processing backend.
+ pub processing_backend: AudioBackend,
+ /// iOS mode.
+ pub ios_voice_processing_mode: IosVoiceProcessingMode,
+ /// Route class.
+ pub audio_route: AudioRoute,
+ /// Actual sample rate.
+ pub actual_sample_rate_hz: u32,
+ /// Actual IO buffer frame count.
+ pub actual_io_buffer_frames: u32,
+ /// Input overrun count.
+ pub input_overruns: u64,
+ /// Output underrun count.
+ pub output_underruns: u64,
+ /// Callback xrun count.
+ pub callback_xruns: u64,
+ /// Clipped sample count.
+ pub clipped_samples: u64,
+ /// Sonora enabled.
+ pub sonora_enabled: bool,
+ /// Platform voice processing enabled.
+ pub platform_voice_processing_enabled: bool,
+}
+
+/// Lock-free stats storage shared with callbacks.
+pub struct SharedAudioProcessingStats {
+ input_dbfs: AtomicU32,
+ render_dbfs: AtomicU32,
+ processed_dbfs: AtomicU32,
+ vad_probability: AtomicU32,
+ vad_active: AtomicBool,
+ transmitting: AtomicBool,
+ vad_fallback_active: AtomicBool,
+ input_overruns: AtomicU64,
+ output_underruns: AtomicU64,
+ callback_xruns: AtomicU64,
+ clipped_samples: AtomicU64,
+ actual_sample_rate_hz: AtomicU32,
+ actual_io_buffer_frames: AtomicU32,
+}
+
+impl Default for SharedAudioProcessingStats {
+ fn default() -> Self {
+ Self {
+ input_dbfs: AtomicU32::new((-120.0_f32).to_bits()),
+ render_dbfs: AtomicU32::new((-120.0_f32).to_bits()),
+ processed_dbfs: AtomicU32::new((-120.0_f32).to_bits()),
+ vad_probability: AtomicU32::new(0.0_f32.to_bits()),
+ vad_active: AtomicBool::new(false),
+ transmitting: AtomicBool::new(false),
+ vad_fallback_active: AtomicBool::new(false),
+ input_overruns: AtomicU64::new(0),
+ output_underruns: AtomicU64::new(0),
+ callback_xruns: AtomicU64::new(0),
+ clipped_samples: AtomicU64::new(0),
+ actual_sample_rate_hz: AtomicU32::new(crate::frame::SAMPLE_RATE_HZ),
+ actual_io_buffer_frames: AtomicU32::new(crate::frame::FRAME_20MS_SAMPLES as u32),
+ }
+ }
+}
+
+impl SharedAudioProcessingStats {
+ /// Store capture levels and VAD state.
+ pub fn update_capture(
+ &self,
+ input_dbfs: f32,
+ processed_dbfs: f32,
+ probability: f32,
+ vad_active: bool,
+ transmitting: bool,
+ ) {
+ self.input_dbfs
+ .store(input_dbfs.to_bits(), Ordering::Relaxed);
+ self.processed_dbfs
+ .store(processed_dbfs.to_bits(), Ordering::Relaxed);
+ self.vad_probability
+ .store(probability.clamp(0.0, 1.0).to_bits(), Ordering::Relaxed);
+ self.vad_active.store(vad_active, Ordering::Relaxed);
+ self.transmitting.store(transmitting, Ordering::Relaxed);
+ }
+
+ /// Store render level and callback buffer size.
+ pub fn update_render(&self, dbfs: f32, io_buffer_frames: u32) {
+ self.render_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
+ self.actual_io_buffer_frames
+ .store(io_buffer_frames, Ordering::Relaxed);
+ }
+
+ /// Increment output underrun count.
+ pub fn increment_output_underrun(&self) {
+ self.output_underruns.fetch_add(1, Ordering::Relaxed);
+ }
+
+ /// Increment callback xrun count.
+ pub fn increment_callback_xrun(&self) {
+ self.callback_xruns.fetch_add(1, Ordering::Relaxed);
+ }
+
+ /// Add clipped samples.
+ pub fn add_clipped_samples(&self, count: u64) {
+ self.clipped_samples.fetch_add(count, Ordering::Relaxed);
+ }
+
+ /// Store whether the selected VAD backend is currently using a fallback.
+ pub fn set_vad_fallback_active(&self, active: bool) {
+ self.vad_fallback_active.store(active, Ordering::Relaxed);
+ }
+
+ /// Build an owned stats snapshot with config-derived labels.
+ pub fn snapshot(&self, config: &AudioProcessingConfig) -> AudioProcessingStats {
+ AudioProcessingStats {
+ input_dbfs: f32::from_bits(self.input_dbfs.load(Ordering::Relaxed)),
+ render_dbfs: f32::from_bits(self.render_dbfs.load(Ordering::Relaxed)),
+ processed_dbfs: f32::from_bits(self.processed_dbfs.load(Ordering::Relaxed)),
+ vad_probability: f32::from_bits(self.vad_probability.load(Ordering::Relaxed)),
+ vad_active: self.vad_active.load(Ordering::Relaxed),
+ transmitting: self.transmitting.load(Ordering::Relaxed),
+ vad_backend: config.vad_backend,
+ vad_fallback_active: self.vad_fallback_active.load(Ordering::Relaxed),
+ processing_backend: config.processing_backend,
+ ios_voice_processing_mode: config.ios_mode,
+ audio_route: config.route,
+ actual_sample_rate_hz: self.actual_sample_rate_hz.load(Ordering::Relaxed),
+ actual_io_buffer_frames: self.actual_io_buffer_frames.load(Ordering::Relaxed),
+ input_overruns: self.input_overruns.load(Ordering::Relaxed),
+ output_underruns: self.output_underruns.load(Ordering::Relaxed),
+ callback_xruns: self.callback_xruns.load(Ordering::Relaxed),
+ clipped_samples: self.clipped_samples.load(Ordering::Relaxed),
+ sonora_enabled: config.processing_backend == AudioBackend::Sonora,
+ platform_voice_processing_enabled: config.processing_backend
+ == AudioBackend::PlatformVoiceProcessing,
+ }
+ }
+}
diff --git a/crates/chanora_audio/src/debug_wav.rs b/crates/chanora_audio/src/debug_wav.rs
new file mode 100644
index 0000000..1865141
--- /dev/null
+++ b/crates/chanora_audio/src/debug_wav.rs
@@ -0,0 +1,330 @@
+//! Async WAV debug dump writer for P1 diagnostics.
+//!
+//! Captures three streams for offline analysis:
+//! * `raw_mic` — before AudioProcessor (INV_007: never from callback)
+//! * `render_reference` — remote mixer output before playout
+//! * `processed_mic` — after AudioProcessor
+//!
+//! ## Design
+//!
+//! The realtime callback MUST NOT write to disk (INV_007). Instead it
+//! pushes 10 ms f32 frames onto a bounded `std::sync::mpsc` channel.
+//! A background `tokio::task` drains the channel and writes WAV data.
+//!
+//! The channel is bounded (capacity = 500 frames ≈ 5 s of audio per
+//! stream). If the writer falls behind, frames are dropped rather than
+//! blocking the callback thread.
+//!
+//! WAV files are written to the OS temp directory with a filename that
+//! encodes the stream name, route, backend, and a timestamp so
+//! multiple sessions don't overwrite each other.
+//!
+//! ## Usage
+//!
+//! ```ignore
+//! let writer = WavDebugRecorder::start(route, backend);
+//! // In realtime callback (non-blocking):
+//! writer.push_raw_mic(&frame);
+//! writer.push_render_reference(&frame);
+//! writer.push_processed_mic(&frame);
+//! // On session end:
+//! writer.stop(); // flushes and closes files
+//! ```
+
+use std::io::Write;
+use std::path::PathBuf;
+use std::sync::mpsc;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use tracing::{info, warn};
+
+use crate::audio_processing::{AudioBackend, AudioRoute};
+use crate::frame::FRAME_10MS_SAMPLES;
+
+/// Maximum number of 10 ms frames buffered per stream before drops.
+const CHANNEL_CAPACITY: usize = 500;
+
+/// Sample rate for WAV output (matches the capture pipeline).
+const WAV_SAMPLE_RATE: u32 = 48_000;
+
+/// Identifies which debug stream a frame belongs to.
+#[derive(Debug, Clone, Copy)]
+enum StreamId {
+ RawMic,
+ RenderReference,
+ ProcessedMic,
+}
+
+/// A single 10 ms frame tagged with its stream.
+struct DebugFrame {
+ stream: StreamId,
+ samples: Box<[f32; FRAME_10MS_SAMPLES]>,
+}
+
+/// Handle for pushing frames from the realtime callback.
+///
+/// All push methods are non-blocking: if the channel is full the
+/// frame is silently dropped and a counter is incremented.
+pub struct WavDebugRecorder {
+ tx: mpsc::SyncSender,
+ /// Frames dropped due to full channel (diagnostic only).
+ drops: std::sync::atomic::AtomicU64,
+ /// Whether the recorder is active (set to false on stop).
+ active: std::sync::atomic::AtomicBool,
+}
+
+impl WavDebugRecorder {
+ /// Start the async WAV writer task. Returns a handle for pushing
+ /// frames from the realtime callback.
+ ///
+ /// `route` and `backend` are embedded in the output filenames.
+ pub fn start(route: AudioRoute, backend: AudioBackend) -> std::sync::Arc {
+ let (tx, rx) = mpsc::sync_channel::(CHANNEL_CAPACITY);
+ let recorder = std::sync::Arc::new(Self {
+ tx,
+ drops: std::sync::atomic::AtomicU64::new(0),
+ active: std::sync::atomic::AtomicBool::new(true),
+ });
+
+ let ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0);
+ let route_str = route.as_str().to_string();
+ let backend_str = backend.as_str().to_string();
+
+ // Spawn a blocking task so the WAV writer doesn't compete
+ // with the tokio async executor for CPU time.
+ std::thread::Builder::new()
+ .name("chanora-wav-writer".to_string())
+ .spawn(move || {
+ wav_writer_task(rx, &route_str, &backend_str, ts);
+ })
+ .ok();
+
+ recorder
+ }
+
+ /// Push a raw mic frame (before AudioProcessor). Non-blocking.
+ pub fn push_raw_mic(&self, samples: &[f32; FRAME_10MS_SAMPLES]) {
+ self.push(StreamId::RawMic, samples);
+ }
+
+ /// Push a render-reference frame (remote mixer output before playout).
+ /// Non-blocking.
+ pub fn push_render_reference(&self, samples: &[f32; FRAME_10MS_SAMPLES]) {
+ self.push(StreamId::RenderReference, samples);
+ }
+
+ /// Push a processed mic frame (after AudioProcessor). Non-blocking.
+ pub fn push_processed_mic(&self, samples: &[f32; FRAME_10MS_SAMPLES]) {
+ self.push(StreamId::ProcessedMic, samples);
+ }
+
+ /// Stop the recorder. Drops the sender so the writer task drains
+ /// and closes the WAV files.
+ pub fn stop(&self) {
+ self.active
+ .store(false, std::sync::atomic::Ordering::Relaxed);
+ // The sender is not dropped here because `self` is behind Arc.
+ // The writer task will exit when all senders are dropped (i.e.
+ // when the Arc is dropped). This is intentional: the task
+ // drains any remaining frames before closing files.
+ }
+
+ /// Number of frames dropped due to a full channel.
+ pub fn drop_count(&self) -> u64 {
+ self.drops.load(std::sync::atomic::Ordering::Relaxed)
+ }
+
+ fn push(&self, stream: StreamId, samples: &[f32; FRAME_10MS_SAMPLES]) {
+ if !self.active.load(std::sync::atomic::Ordering::Relaxed) {
+ return;
+ }
+ let mut boxed = Box::new([0.0_f32; FRAME_10MS_SAMPLES]);
+ boxed.copy_from_slice(samples);
+ let frame = DebugFrame {
+ stream,
+ samples: boxed,
+ };
+ if self.tx.try_send(frame).is_err() {
+ self.drops
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ }
+ }
+}
+
+// ---------- WAV writer task ----------
+
+struct WavFile {
+ path: PathBuf,
+ file: std::fs::File,
+ samples_written: u32,
+}
+
+impl WavFile {
+ fn create(dir: &std::path::Path, name: &str) -> Option {
+ let path = dir.join(name);
+ match std::fs::File::create(&path) {
+ Ok(mut file) => {
+ // Write a placeholder WAV header; we'll patch it on close.
+ if write_wav_header(&mut file, 0).is_ok() {
+ Some(Self {
+ path,
+ file,
+ samples_written: 0,
+ })
+ } else {
+ None
+ }
+ }
+ Err(e) => {
+ warn!(target: "chanora_audio", error = %e, path = %path.display(), "wav debug: failed to create file");
+ None
+ }
+ }
+ }
+
+ fn write_samples(&mut self, samples: &[f32]) {
+ for &s in samples {
+ let i16_val = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
+ let _ = self.file.write_all(&i16_val.to_le_bytes());
+ }
+ self.samples_written += samples.len() as u32;
+ }
+
+ fn finalize(mut self) {
+ // Seek back to the start and rewrite the header with the
+ // correct data size.
+ use std::io::Seek;
+ if self.file.seek(std::io::SeekFrom::Start(0)).is_ok() {
+ let _ = write_wav_header(&mut self.file, self.samples_written);
+ }
+ info!(
+ target: "chanora_audio",
+ path = %self.path.display(),
+ samples = self.samples_written,
+ "wav debug: file closed"
+ );
+ }
+}
+
+fn write_wav_header(file: &mut std::fs::File, num_samples: u32) -> std::io::Result<()> {
+ // PCM WAV header: 44 bytes.
+ // Channels: 1 (mono), sample rate: 48000, bit depth: 16.
+ let channels: u16 = 1;
+ let sample_rate: u32 = WAV_SAMPLE_RATE;
+ let bits_per_sample: u16 = 16;
+ let byte_rate = sample_rate * channels as u32 * bits_per_sample as u32 / 8;
+ let block_align = channels * bits_per_sample / 8;
+ let data_size = num_samples * channels as u32 * bits_per_sample as u32 / 8;
+ let chunk_size = 36 + data_size;
+
+ file.write_all(b"RIFF")?;
+ file.write_all(&chunk_size.to_le_bytes())?;
+ file.write_all(b"WAVE")?;
+ file.write_all(b"fmt ")?;
+ file.write_all(&16u32.to_le_bytes())?; // subchunk1 size
+ file.write_all(&1u16.to_le_bytes())?; // PCM format
+ file.write_all(&channels.to_le_bytes())?;
+ file.write_all(&sample_rate.to_le_bytes())?;
+ file.write_all(&byte_rate.to_le_bytes())?;
+ file.write_all(&block_align.to_le_bytes())?;
+ file.write_all(&bits_per_sample.to_le_bytes())?;
+ file.write_all(b"data")?;
+ file.write_all(&data_size.to_le_bytes())?;
+ Ok(())
+}
+
+fn wav_writer_task(rx: mpsc::Receiver, route: &str, backend: &str, ts: u64) {
+ let dir = std::env::temp_dir();
+ let prefix = format!("chanora_debug_{route}_{backend}_{ts}");
+
+ let mut raw_mic = WavFile::create(&dir, &format!("{prefix}_raw_mic.wav"));
+ let mut render_ref = WavFile::create(&dir, &format!("{prefix}_render_reference.wav"));
+ let mut processed = WavFile::create(&dir, &format!("{prefix}_processed_mic.wav"));
+
+ info!(
+ target: "chanora_audio",
+ dir = %dir.display(),
+ prefix = %prefix,
+ "wav debug: writer started"
+ );
+
+ for frame in rx {
+ match frame.stream {
+ StreamId::RawMic => {
+ if let Some(f) = raw_mic.as_mut() {
+ f.write_samples(&*frame.samples);
+ }
+ }
+ StreamId::RenderReference => {
+ if let Some(f) = render_ref.as_mut() {
+ f.write_samples(&*frame.samples);
+ }
+ }
+ StreamId::ProcessedMic => {
+ if let Some(f) = processed.as_mut() {
+ f.write_samples(&*frame.samples);
+ }
+ }
+ }
+ }
+
+ // Channel closed — finalize all files.
+ if let Some(f) = raw_mic {
+ f.finalize();
+ }
+ if let Some(f) = render_ref {
+ f.finalize();
+ }
+ if let Some(f) = processed {
+ f.finalize();
+ }
+
+ info!(target: "chanora_audio", "wav debug: writer task exited");
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn recorder_starts_and_stops_without_panic() {
+ let rec =
+ WavDebugRecorder::start(AudioRoute::Speaker, AudioBackend::PlatformVoiceProcessing);
+ let frame = [0.1_f32; FRAME_10MS_SAMPLES];
+ rec.push_raw_mic(&frame);
+ rec.push_render_reference(&frame);
+ rec.push_processed_mic(&frame);
+ rec.stop();
+ // Drop the Arc to let the writer task drain.
+ drop(rec);
+ // Give the writer thread a moment to finish.
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ }
+
+ #[test]
+ fn drop_count_increments_when_channel_full() {
+ // Use a tiny channel by creating a recorder and flooding it.
+ // We can't easily test the bounded channel directly, but we
+ // can verify the drop counter starts at zero.
+ let rec = WavDebugRecorder::start(AudioRoute::Speaker, AudioBackend::Noop);
+ assert_eq!(rec.drop_count(), 0);
+ rec.stop();
+ }
+
+ #[test]
+ fn wav_header_is_44_bytes() {
+ // Write to a temp file to test the header.
+ let tmp = std::env::temp_dir().join("chanora_test_wav_header.wav");
+ let mut f = std::fs::File::create(&tmp).unwrap();
+ write_wav_header(&mut f, 960).unwrap();
+ drop(f);
+ let data = std::fs::read(&tmp).unwrap();
+ assert_eq!(data.len(), 44, "WAV header must be 44 bytes");
+ assert_eq!(&data[0..4], b"RIFF");
+ assert_eq!(&data[8..12], b"WAVE");
+ let _ = std::fs::remove_file(&tmp);
+ }
+}
diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs
index ab09447..d6bb2b9 100644
--- a/crates/chanora_audio/src/engine.rs
+++ b/crates/chanora_audio/src/engine.rs
@@ -33,44 +33,21 @@ use tracing::{debug, info};
))]
use tracing::{error, warn};
-#[cfg(target_os = "android")]
+#[cfg(any(target_os = "ios", target_os = "android"))]
use tracing::warn;
+use tsclientlib::audio::AudioHandler;
+
+use chanora_protocol::{InboundVoice, OutPacket};
+
+use crate::AudioError;
+
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use audiopus::coder::Encoder as OpusEncoder;
-#[cfg(all(
- not(target_os = "ios"),
- not(target_os = "macos"),
- not(target_os = "android")
-))]
-use audiopus::{
- Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
- SampleRate as OpusSampleRate,
-};
-
-use tsclientlib::audio::AudioHandler;
-
-// `AudioData`, `CodecType`, `OutAudio` are referenced only by the
-// cpal capture pipeline's Opus encode path (`CaptureState::encode_and_send`).
-// `InboundVoice` + `OutPacket` are used by every platform — the
-// inbound forwarder task pumps `InboundVoice` into AudioHandler on
-// iOS too, and `OutPacket` flows out of the capture pipeline once
-// commit 3 lands. Cfg-gate the cpal-only ones to keep iOS warnings
-// clean.
-#[cfg(all(
- not(target_os = "ios"),
- not(target_os = "macos"),
- not(target_os = "android")
-))]
-use chanora_protocol::{AudioData, CodecType, OutAudio};
-use chanora_protocol::{InboundVoice, OutPacket};
-
-use crate::AudioError;
-
/// Stable Chanora-side identifier for AudioHandler bookkeeping.
/// We only ever have one connection at a time (DEC-006), so this is
/// trivially unique.
@@ -89,11 +66,8 @@ pub struct SessionAudioId(pub u64);
const SAMPLE_RATE: u32 = 48_000;
#[allow(dead_code)]
const FRAME_SAMPLES: usize = 48_000 / 50; // 960
-#[allow(dead_code)]
-const MAX_OPUS_FRAME: usize = 1275;
-
/// Engine configuration.
-#[derive(Debug, Clone)]
+#[derive(Clone)]
pub struct AudioEngineConfig {
/// Input gain applied before encoding (1.0 = pass-through).
pub mic_gain: f32,
@@ -117,6 +91,24 @@ pub struct AudioEngineConfig {
/// is rejected on Android because the P0 path intentionally has
/// no generic mobile-audio fallback.
pub mobile_voice_preset: bool,
+ /// Optional selector used by P1 VoiceActivity to publish VAD state.
+ #[doc(hidden)]
+ pub voice_activity_selector: Option>,
+}
+
+impl std::fmt::Debug for AudioEngineConfig {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("AudioEngineConfig")
+ .field("mic_gain", &self.mic_gain)
+ .field("ptt_initial", &self.ptt_initial)
+ .field("effects", &self.effects)
+ .field("mobile_voice_preset", &self.mobile_voice_preset)
+ .field(
+ "voice_activity_selector",
+ &self.voice_activity_selector.as_ref().map(|_| "present"),
+ )
+ .finish()
+ }
}
impl Default for AudioEngineConfig {
@@ -126,6 +118,7 @@ impl Default for AudioEngineConfig {
ptt_initial: false,
effects: crate::AudioEffects::default(),
mobile_voice_preset: true,
+ voice_activity_selector: None,
}
}
}
@@ -150,6 +143,16 @@ pub struct AudioEngine {
/// independent of the server-side mute the protocol layer
/// broadcasts.
output_muted: Arc,
+ audio_processing_config: Arc>,
+ audio_processing_stats: Arc,
+ #[cfg(any(target_os = "ios", target_os = "macos"))]
+ audio_handler: Arc>>,
+ #[cfg(any(target_os = "ios", target_os = "macos"))]
+ voice_out_tx: mpsc::Sender,
+ #[cfg(any(target_os = "ios", target_os = "macos"))]
+ voice_activity_selector: Option>,
+ #[cfg(any(target_os = "ios", target_os = "macos"))]
+ mic_gain: f32,
// Streams must be dropped to stop audio. Both are `!Send` because
// cpal's Stream isn't Send on some backends; we keep them in an
@@ -177,7 +180,7 @@ pub struct AudioEngine {
))]
_output_stream: Mutex