feat(voice): add iOS VAD runtime support
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+100
-8
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<!-- Opt into ProMotion / high-refresh-rate CADisplayLink ranges on
|
||||
supported iPhones. Flutter's iOS embedder reads this key; no
|
||||
additional Flutter package is required for dynamic refresh. -->
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -74,11 +77,6 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<!-- Make Chanora's Documents folder visible to the Files.app and
|
||||
accessible via iTunes / Finder file-sharing. We write
|
||||
diagnostic logs (chanora.log) into Documents/ so users can
|
||||
export them for support. Both keys are required for the
|
||||
"On My iPhone -> Chanora" listing to appear in Files.app. -->
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
|
||||
@@ -40,7 +40,7 @@ Pod::Spec.new do |s|
|
||||
s.license = { :type => 'Apache-2.0 OR MIT', :text => 'See LICENSE-APACHE / LICENSE-MIT at the repo root' }
|
||||
s.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|
|
||||
<key>CFBundleShortVersionString</key><string>1.0.0</string>
|
||||
<key>CFBundleVersion</key><string>1</string>
|
||||
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
|
||||
<key>MinimumOSVersion</key><string>13.0</string>
|
||||
<key>MinimumOSVersion</key><string>15.1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
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" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key><string>chanora_bridge</string>
|
||||
<key>CFBundleIdentifier</key><string>app.chanora.bridge</string>
|
||||
<key>CFBundleName</key><string>chanora_bridge</string>
|
||||
<key>CFBundlePackageType</key><string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key><string>1.0.0</string>
|
||||
<key>CFBundleVersion</key><string>1</string>
|
||||
<key>CFBundleSupportedPlatforms</key><array><string>$SUPPORTED_PLATFORM</string></array>
|
||||
<key>MinimumOSVersion</key><string>15.1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
||||
"$FW/chanora_bridge"
|
||||
echo "[chanora_bridge script_phase] framework refreshed"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "权限被拒绝"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => '权限被拒绝';
|
||||
}
|
||||
|
||||
+261
-193
@@ -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<File> _copyBundledAssetToDocuments({
|
||||
required String assetPath,
|
||||
required String fileName,
|
||||
}) async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final file = File('${dir.path}/$fileName');
|
||||
final data = await rootBundle.load(assetPath);
|
||||
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
|
||||
if (await file.exists() && await file.length() == bytes.length) {
|
||||
return file;
|
||||
}
|
||||
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
return file;
|
||||
}
|
||||
|
||||
Future<void> _configureBundledVadModels() async {
|
||||
final silero = await _copyBundledAssetToDocuments(
|
||||
assetPath: _sileroVadAsset,
|
||||
fileName: 'silero_vad.onnx',
|
||||
);
|
||||
await _copyBundledAssetToDocuments(
|
||||
assetPath: _tenVadAsset,
|
||||
fileName: 'ten_vad.onnx',
|
||||
);
|
||||
await rust.setVadModelPath(path: silero.path);
|
||||
}
|
||||
|
||||
/// Top padding for macOS to clear traffic-light buttons.
|
||||
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<void> 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<void> _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<AndroidRecordAudioPermissionState>
|
||||
get _activeRecordAudioState => Platform.isIOS
|
||||
? _iosPermissions.recordAudioState
|
||||
: _androidPermissions.recordAudioState;
|
||||
|
||||
Future<AndroidRecordAudioPermissionState> _ensureActiveRecordAudio() {
|
||||
return Platform.isIOS
|
||||
? _iosPermissions.ensureRecordAudio()
|
||||
: _androidPermissions.ensureRecordAudio();
|
||||
}
|
||||
|
||||
Future<void> _openActivePermissionSettings() {
|
||||
return Platform.isIOS
|
||||
? _iosPermissions.openAppSettings()
|
||||
: _androidPermissions.openAppSettings();
|
||||
}
|
||||
|
||||
void _onRecordAudioPermissionChanged() {
|
||||
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<void> _openIosAppSettings() async {
|
||||
try {
|
||||
await _iosPlatformChannel.invokeMethod<bool>('openAppSettings');
|
||||
} catch (_) {
|
||||
// Best-effort affordance only; if iOS refuses the URL, the
|
||||
// dialog still explained the missing microphone permission.
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: unused_element
|
||||
Future<void> _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<void> _onOpenVoiceDetailsSheet() async {
|
||||
// Load current audio processing config for the sheet.
|
||||
rust.BridgeAudioProcessingConfig audioConfig;
|
||||
try {
|
||||
audioConfig = await rust.getAudioProcessingConfig();
|
||||
} catch (_) {
|
||||
audioConfig = const rust.BridgeAudioProcessingConfig(
|
||||
route: rust.BridgeAudioRoute.unknown,
|
||||
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: rust.BridgeVadBackend.sileroOnnx,
|
||||
aec: rust.BridgeEffectOwner.platform,
|
||||
ns: rust.BridgeEffectOwner.platform,
|
||||
agc: rust.BridgeEffectOwner.platform,
|
||||
hpfEnabled: true,
|
||||
limiterEnabled: true,
|
||||
vadHangoverMs: 500,
|
||||
vadPreRollMs: 160,
|
||||
vadMinTxMs: 200,
|
||||
debugWavDumpEnabled: false,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
await showVoiceDetailsSheet(
|
||||
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<void> _onOpenVoiceSettings() async {
|
||||
// Load the current audio processing config before opening the dialog.
|
||||
rust.BridgeAudioProcessingConfig audioConfig;
|
||||
try {
|
||||
audioConfig = await rust.getAudioProcessingConfig();
|
||||
} catch (_) {
|
||||
// If not connected yet, use a sensible default.
|
||||
audioConfig = const rust.BridgeAudioProcessingConfig(
|
||||
route: rust.BridgeAudioRoute.unknown,
|
||||
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: rust.BridgeVadBackend.sileroOnnx,
|
||||
aec: rust.BridgeEffectOwner.platform,
|
||||
ns: rust.BridgeEffectOwner.platform,
|
||||
agc: rust.BridgeEffectOwner.platform,
|
||||
hpfEnabled: true,
|
||||
limiterEnabled: true,
|
||||
vadHangoverMs: 500,
|
||||
vadPreRollMs: 160,
|
||||
vadMinTxMs: 200,
|
||||
debugWavDumpEnabled: false,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
final result = await showDialog<VoiceSettingsResult>(
|
||||
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<String> _writeDiagnosticExport(String text) async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final stamp = DateTime.now()
|
||||
.toUtc()
|
||||
.toIso8601String()
|
||||
.replaceAll(':', '-')
|
||||
.replaceAll('.', '-');
|
||||
final file = File('${dir.path}/chanora-diagnostics-$stamp.txt');
|
||||
await file.writeAsString(text, flush: true);
|
||||
return file.path;
|
||||
}
|
||||
|
||||
Future<void> _onConfigurePtt(BuildContext context) async {
|
||||
// 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<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final theme = Theme.of(sheetContext);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttCapabilityExplainTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedHeading,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_explainBodyForPlatform(l10n),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
|
||||
final tooltipMessage = boundInputClass.isEmpty
|
||||
? badgeLabel
|
||||
: '$badgeLabel\n($boundInputClass)';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Tooltip(
|
||||
message: tooltipMessage,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isFocused ? Icons.crop_free : Icons.public,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Info icon only for L0Focused — the explanation sheet
|
||||
// tells the user why their PTT may not work outside the
|
||||
// app window and how to grant the permission. There is
|
||||
// intentionally NO 'Configure' button here: the single
|
||||
// configuration entry point is the Voice Bar's
|
||||
// settings gear (onConfigure on `VoiceBar`). Having two
|
||||
// identical bind-key entry points just confuses users.
|
||||
if (_isFocused)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline, size: 16),
|
||||
tooltip: l10n.pttCapabilityExplainTitle,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _openExplanationSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SnapshotView extends StatelessWidget {
|
||||
const _SnapshotView({
|
||||
required this.snapshot,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/// iOS microphone permission integration for AVAudioSession.
|
||||
///
|
||||
/// Trace:
|
||||
/// - SRS-164 (iOS system permission presentation and settings path).
|
||||
/// - SRS-114 / SRS-138 (iOS platform-service behaviour for audio).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'android_permissions_service.dart';
|
||||
|
||||
@visibleForTesting
|
||||
const String iosPlatformChannelName = 'chanora/ios_platform';
|
||||
|
||||
@visibleForTesting
|
||||
const String methodGetMicrophonePermissionState =
|
||||
'getMicrophonePermissionState';
|
||||
|
||||
@visibleForTesting
|
||||
const String methodRequestMicrophonePermission = 'requestMicrophonePermission';
|
||||
|
||||
@visibleForTesting
|
||||
const String methodIosOpenAppSettings = 'openAppSettings';
|
||||
|
||||
/// Dart-side integration for iOS microphone permission state.
|
||||
class IosPermissionsService {
|
||||
IosPermissionsService({MethodChannel? channel})
|
||||
: _channel =
|
||||
channel ??
|
||||
(_isIOS ? const MethodChannel(iosPlatformChannelName) : null);
|
||||
|
||||
static bool get _isIOS {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS;
|
||||
}
|
||||
|
||||
final MethodChannel? _channel;
|
||||
|
||||
final ValueNotifier<AndroidRecordAudioPermissionState> _state =
|
||||
ValueNotifier<AndroidRecordAudioPermissionState>(
|
||||
_isIOS
|
||||
? AndroidRecordAudioPermissionState.unknown
|
||||
: AndroidRecordAudioPermissionState.granted,
|
||||
);
|
||||
|
||||
ValueListenable<AndroidRecordAudioPermissionState> get recordAudioState =>
|
||||
_state;
|
||||
|
||||
Future<void> start() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
try {
|
||||
_state.value = _parseState(
|
||||
await ch.invokeMethod<String>(methodGetMicrophonePermissionState),
|
||||
);
|
||||
} catch (_) {
|
||||
_state.value = AndroidRecordAudioPermissionState.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {}
|
||||
|
||||
Future<AndroidRecordAudioPermissionState> ensureRecordAudio() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return AndroidRecordAudioPermissionState.granted;
|
||||
try {
|
||||
final state = _parseState(
|
||||
await ch.invokeMethod<String>(methodRequestMicrophonePermission),
|
||||
);
|
||||
_state.value = state;
|
||||
return state;
|
||||
} catch (_) {
|
||||
return _state.value;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openAppSettings() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
try {
|
||||
await ch.invokeMethod<bool>(methodIosOpenAppSettings);
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'openAppSettings failed',
|
||||
name: 'IosPermissionsService',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void dispose() {
|
||||
_state.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
AndroidRecordAudioPermissionState _parseState(String? raw) {
|
||||
switch (raw) {
|
||||
case 'Granted':
|
||||
return AndroidRecordAudioPermissionState.granted;
|
||||
case 'Denied':
|
||||
return AndroidRecordAudioPermissionState.permanentlyDenied;
|
||||
case 'NotDetermined':
|
||||
return AndroidRecordAudioPermissionState.denied;
|
||||
default:
|
||||
return AndroidRecordAudioPermissionState.unknown;
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
|
||||
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<void> disconnect() => RustLib.instance.api.crateApiDisconnect();
|
||||
Future<bool> 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<BridgeEvent> eventsStream() =>
|
||||
Future<BridgeAudioStats> audioStats() =>
|
||||
RustLib.instance.api.crateApiAudioStats();
|
||||
|
||||
/// Apply the P1 audio-processing config.
|
||||
Future<void> setAudioProcessingConfig({
|
||||
required BridgeAudioProcessingConfig config,
|
||||
}) async {
|
||||
_lastAppliedAudioConfig = config;
|
||||
return RustLib.instance.api.crateApiSetAudioProcessingConfig(config: config);
|
||||
}
|
||||
|
||||
/// Read P1 audio-processing diagnostics.
|
||||
Future<BridgeAudioProcessingStats> audioProcessingStats() =>
|
||||
RustLib.instance.api.crateApiAudioProcessingStats();
|
||||
|
||||
/// Read the current audio-processing config.
|
||||
///
|
||||
/// Derives the config from [audioProcessingStats] for the route/backend
|
||||
/// fields, and returns the last value applied via [setAudioProcessingConfig]
|
||||
/// for timing/debug fields. Falls back to P1 spec defaults on first call.
|
||||
Future<BridgeAudioProcessingConfig> getAudioProcessingConfig() async {
|
||||
BridgeAudioProcessingStats? stats;
|
||||
try {
|
||||
stats = await audioProcessingStats();
|
||||
} catch (_) {}
|
||||
|
||||
final last = _lastAppliedAudioConfig;
|
||||
return BridgeAudioProcessingConfig(
|
||||
route: stats?.audioRoute ?? last?.route ?? BridgeAudioRoute.unknown,
|
||||
iosMode:
|
||||
stats?.iosVoiceProcessingMode ??
|
||||
last?.iosMode ??
|
||||
BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
processingBackend:
|
||||
stats?.processingBackend ??
|
||||
last?.processingBackend ??
|
||||
BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend:
|
||||
stats?.vadBackend ?? last?.vadBackend ?? BridgeVadBackend.sileroOnnx,
|
||||
aec: last?.aec ?? BridgeEffectOwner.platform,
|
||||
ns: last?.ns ?? BridgeEffectOwner.platform,
|
||||
agc: last?.agc ?? BridgeEffectOwner.platform,
|
||||
hpfEnabled: last?.hpfEnabled ?? true,
|
||||
limiterEnabled: last?.limiterEnabled ?? true,
|
||||
vadHangoverMs: last?.vadHangoverMs ?? 500,
|
||||
vadPreRollMs: last?.vadPreRollMs ?? 160,
|
||||
vadMinTxMs: last?.vadMinTxMs ?? 200,
|
||||
debugWavDumpEnabled: last?.debugWavDumpEnabled ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Last config applied via [setAudioProcessingConfig]. Used by
|
||||
/// [getAudioProcessingConfig] to preserve timing/debug values across calls.
|
||||
BridgeAudioProcessingConfig? _lastAppliedAudioConfig;
|
||||
|
||||
/// Configure the VAD model path.
|
||||
Future<void> setVadModelPath({required String path}) =>
|
||||
RustLib.instance.api.crateApiSetVadModelPath(path: path);
|
||||
|
||||
/// Enable or disable audio debug WAV dumping.
|
||||
Future<void> enableAudioDebugWavDump({required bool enabled}) =>
|
||||
RustLib.instance.api.crateApiEnableAudioDebugWavDump(enabled: enabled);
|
||||
|
||||
/// Select the iOS voice-processing mode.
|
||||
Future<void> setIosVoiceProcessingMode({
|
||||
required BridgeIosVoiceProcessingMode mode,
|
||||
}) => RustLib.instance.api.crateApiSetIosVoiceProcessingMode(mode: mode);
|
||||
|
||||
/// Bridge processing backend.
|
||||
enum BridgeAudioBackend {
|
||||
/// Platform voice processing.
|
||||
platformVoiceProcessing,
|
||||
|
||||
/// Sonora backend.
|
||||
sonora,
|
||||
|
||||
/// WebRTC APM backend.
|
||||
webrtcApm,
|
||||
|
||||
/// No-op backend.
|
||||
noop,
|
||||
}
|
||||
|
||||
/// P1 audio-processing configuration DTO.
|
||||
class BridgeAudioProcessingConfig {
|
||||
/// Route class.
|
||||
final BridgeAudioRoute route;
|
||||
|
||||
/// iOS voice-processing mode.
|
||||
final BridgeIosVoiceProcessingMode iosMode;
|
||||
|
||||
/// Processing backend.
|
||||
final BridgeAudioBackend processingBackend;
|
||||
|
||||
/// VAD backend.
|
||||
final BridgeVadBackend vadBackend;
|
||||
|
||||
/// AEC owner.
|
||||
final BridgeEffectOwner aec;
|
||||
|
||||
/// Noise suppression owner.
|
||||
final BridgeEffectOwner ns;
|
||||
|
||||
/// AGC owner.
|
||||
final BridgeEffectOwner agc;
|
||||
|
||||
/// High-pass filter enabled.
|
||||
final bool hpfEnabled;
|
||||
|
||||
/// Limiter enabled.
|
||||
final bool limiterEnabled;
|
||||
|
||||
/// VAD hangover in ms.
|
||||
final int vadHangoverMs;
|
||||
|
||||
/// VAD pre-roll in ms.
|
||||
final int vadPreRollMs;
|
||||
|
||||
/// Minimum transmit duration in ms.
|
||||
final int vadMinTxMs;
|
||||
|
||||
/// Debug WAV dump enabled.
|
||||
final bool debugWavDumpEnabled;
|
||||
|
||||
const BridgeAudioProcessingConfig({
|
||||
required this.route,
|
||||
required this.iosMode,
|
||||
required this.processingBackend,
|
||||
required this.vadBackend,
|
||||
required this.aec,
|
||||
required this.ns,
|
||||
required this.agc,
|
||||
required this.hpfEnabled,
|
||||
required this.limiterEnabled,
|
||||
required this.vadHangoverMs,
|
||||
required this.vadPreRollMs,
|
||||
required this.vadMinTxMs,
|
||||
required this.debugWavDumpEnabled,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
route.hashCode ^
|
||||
iosMode.hashCode ^
|
||||
processingBackend.hashCode ^
|
||||
vadBackend.hashCode ^
|
||||
aec.hashCode ^
|
||||
ns.hashCode ^
|
||||
agc.hashCode ^
|
||||
hpfEnabled.hashCode ^
|
||||
limiterEnabled.hashCode ^
|
||||
vadHangoverMs.hashCode ^
|
||||
vadPreRollMs.hashCode ^
|
||||
vadMinTxMs.hashCode ^
|
||||
debugWavDumpEnabled.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BridgeAudioProcessingConfig &&
|
||||
runtimeType == other.runtimeType &&
|
||||
route == other.route &&
|
||||
iosMode == other.iosMode &&
|
||||
processingBackend == other.processingBackend &&
|
||||
vadBackend == other.vadBackend &&
|
||||
aec == other.aec &&
|
||||
ns == other.ns &&
|
||||
agc == other.agc &&
|
||||
hpfEnabled == other.hpfEnabled &&
|
||||
limiterEnabled == other.limiterEnabled &&
|
||||
vadHangoverMs == other.vadHangoverMs &&
|
||||
vadPreRollMs == other.vadPreRollMs &&
|
||||
vadMinTxMs == other.vadMinTxMs &&
|
||||
debugWavDumpEnabled == other.debugWavDumpEnabled;
|
||||
}
|
||||
|
||||
/// P1 audio-processing stats DTO.
|
||||
class BridgeAudioProcessingStats {
|
||||
/// Input dBFS.
|
||||
final double inputDbfs;
|
||||
|
||||
/// Render dBFS.
|
||||
final double renderDbfs;
|
||||
|
||||
/// Processed capture dBFS.
|
||||
final double processedDbfs;
|
||||
|
||||
/// Latest VAD probability.
|
||||
final double vadProbability;
|
||||
|
||||
/// VAD active.
|
||||
final bool vadActive;
|
||||
|
||||
/// Currently transmitting.
|
||||
final bool transmitting;
|
||||
|
||||
/// VAD backend.
|
||||
final BridgeVadBackend vadBackend;
|
||||
|
||||
/// Fallback VAD active.
|
||||
final bool vadFallbackActive;
|
||||
|
||||
/// Processing backend.
|
||||
final BridgeAudioBackend processingBackend;
|
||||
|
||||
/// iOS voice-processing mode.
|
||||
final BridgeIosVoiceProcessingMode iosVoiceProcessingMode;
|
||||
|
||||
/// Audio route.
|
||||
final BridgeAudioRoute audioRoute;
|
||||
|
||||
/// Actual sample rate.
|
||||
final int actualSampleRateHz;
|
||||
|
||||
/// Actual IO buffer frames.
|
||||
final int actualIoBufferFrames;
|
||||
|
||||
/// Input overruns.
|
||||
final BigInt inputOverruns;
|
||||
|
||||
/// Output underruns.
|
||||
final BigInt outputUnderruns;
|
||||
|
||||
/// Callback xruns.
|
||||
final BigInt callbackXruns;
|
||||
|
||||
/// Clipped samples.
|
||||
final BigInt clippedSamples;
|
||||
|
||||
/// Sonora enabled.
|
||||
final bool sonoraEnabled;
|
||||
|
||||
/// Platform voice processing enabled.
|
||||
final bool platformVoiceProcessingEnabled;
|
||||
|
||||
const BridgeAudioProcessingStats({
|
||||
required this.inputDbfs,
|
||||
required this.renderDbfs,
|
||||
required this.processedDbfs,
|
||||
required this.vadProbability,
|
||||
required this.vadActive,
|
||||
required this.transmitting,
|
||||
required this.vadBackend,
|
||||
required this.vadFallbackActive,
|
||||
required this.processingBackend,
|
||||
required this.iosVoiceProcessingMode,
|
||||
required this.audioRoute,
|
||||
required this.actualSampleRateHz,
|
||||
required this.actualIoBufferFrames,
|
||||
required this.inputOverruns,
|
||||
required this.outputUnderruns,
|
||||
required this.callbackXruns,
|
||||
required this.clippedSamples,
|
||||
required this.sonoraEnabled,
|
||||
required this.platformVoiceProcessingEnabled,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
inputDbfs.hashCode ^
|
||||
renderDbfs.hashCode ^
|
||||
processedDbfs.hashCode ^
|
||||
vadProbability.hashCode ^
|
||||
vadActive.hashCode ^
|
||||
transmitting.hashCode ^
|
||||
vadBackend.hashCode ^
|
||||
vadFallbackActive.hashCode ^
|
||||
processingBackend.hashCode ^
|
||||
iosVoiceProcessingMode.hashCode ^
|
||||
audioRoute.hashCode ^
|
||||
actualSampleRateHz.hashCode ^
|
||||
actualIoBufferFrames.hashCode ^
|
||||
inputOverruns.hashCode ^
|
||||
outputUnderruns.hashCode ^
|
||||
callbackXruns.hashCode ^
|
||||
clippedSamples.hashCode ^
|
||||
sonoraEnabled.hashCode ^
|
||||
platformVoiceProcessingEnabled.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BridgeAudioProcessingStats &&
|
||||
runtimeType == other.runtimeType &&
|
||||
inputDbfs == other.inputDbfs &&
|
||||
renderDbfs == other.renderDbfs &&
|
||||
processedDbfs == other.processedDbfs &&
|
||||
vadProbability == other.vadProbability &&
|
||||
vadActive == other.vadActive &&
|
||||
transmitting == other.transmitting &&
|
||||
vadBackend == other.vadBackend &&
|
||||
vadFallbackActive == other.vadFallbackActive &&
|
||||
processingBackend == other.processingBackend &&
|
||||
iosVoiceProcessingMode == other.iosVoiceProcessingMode &&
|
||||
audioRoute == other.audioRoute &&
|
||||
actualSampleRateHz == other.actualSampleRateHz &&
|
||||
actualIoBufferFrames == other.actualIoBufferFrames &&
|
||||
inputOverruns == other.inputOverruns &&
|
||||
outputUnderruns == other.outputUnderruns &&
|
||||
callbackXruns == other.callbackXruns &&
|
||||
clippedSamples == other.clippedSamples &&
|
||||
sonoraEnabled == other.sonoraEnabled &&
|
||||
platformVoiceProcessingEnabled ==
|
||||
other.platformVoiceProcessingEnabled;
|
||||
}
|
||||
|
||||
/// Bridge route class for P1 audio-processing policy.
|
||||
enum BridgeAudioRoute {
|
||||
/// Built-in speakerphone.
|
||||
speaker,
|
||||
|
||||
/// Built-in receiver/earpiece.
|
||||
earpiece,
|
||||
|
||||
/// Wired or USB headset.
|
||||
wiredHeadset,
|
||||
|
||||
/// Bluetooth HFP duplex route.
|
||||
bluetoothHfp,
|
||||
|
||||
/// Bluetooth A2DP output-only route.
|
||||
bluetoothA2Dp,
|
||||
|
||||
/// Unknown route.
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Statistics from the audio engine.
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||
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<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||
abstract class RustLibApi extends BaseApi {
|
||||
Future<PlatformInt64> crateApiAddBookmark({required BridgeBookmark b});
|
||||
|
||||
Future<BridgeAudioProcessingStats> crateApiAudioProcessingStats();
|
||||
|
||||
Future<BridgeAudioStats> crateApiAudioStats();
|
||||
|
||||
Future<void> crateApiBridgeInit();
|
||||
@@ -95,6 +97,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<void> crateApiDisconnect();
|
||||
|
||||
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled});
|
||||
|
||||
Stream<BridgeEvent> 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<void> crateApiInitStorage({required String dir});
|
||||
|
||||
@@ -126,10 +132,18 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<(String, String, String)> crateApiPttDescriptor();
|
||||
|
||||
Future<void> crateApiSetAudioProcessingConfig({
|
||||
required BridgeAudioProcessingConfig config,
|
||||
});
|
||||
|
||||
Future<void> crateApiSetHardMute({required bool muted});
|
||||
|
||||
Future<void> crateApiSetInputMuted({required bool muted});
|
||||
|
||||
Future<void> crateApiSetIosVoiceProcessingMode({
|
||||
required BridgeIosVoiceProcessingMode mode,
|
||||
});
|
||||
|
||||
void crateApiSetNetworkState({required BridgeNetworkState state});
|
||||
|
||||
Future<void> crateApiSetOutputGain({required double gain});
|
||||
@@ -147,6 +161,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode});
|
||||
|
||||
Future<void> crateApiSetVadModelPath({required String path});
|
||||
|
||||
Future<BridgeSnapshot> crateApiSnapshot();
|
||||
|
||||
Future<void> crateApiUpdateBookmark({required BridgeBookmark b});
|
||||
@@ -196,7 +212,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
const TaskConstMeta(debugName: "add_bookmark", argNames: ["b"]);
|
||||
|
||||
@override
|
||||
Future<BridgeAudioStats> crateApiAudioStats() {
|
||||
Future<BridgeAudioProcessingStats> 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<BridgeAudioStats> 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<void> crateApiEnableAudioDebugWavDump({required bool enabled}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bool(enabled, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 8,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiEnableAudioDebugWavDumpConstMeta,
|
||||
argValues: [enabled],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiEnableAudioDebugWavDumpConstMeta =>
|
||||
const TaskConstMeta(
|
||||
debugName: "enable_audio_debug_wav_dump",
|
||||
argNames: ["enabled"],
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<BridgeEvent> crateApiEventsStream() {
|
||||
final sink = RustStreamSink<BridgeEvent>();
|
||||
@@ -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<void> 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<void> crateApiSetAudioProcessingConfig({
|
||||
required BridgeAudioProcessingConfig config,
|
||||
}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_box_autoadd_bridge_audio_processing_config(
|
||||
config,
|
||||
serializer,
|
||||
);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 24,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiSetAudioProcessingConfigConstMeta,
|
||||
argValues: [config],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiSetAudioProcessingConfigConstMeta =>
|
||||
const TaskConstMeta(
|
||||
debugName: "set_audio_processing_config",
|
||||
argNames: ["config"],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> 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<void> crateApiSetIosVoiceProcessingMode({
|
||||
required BridgeIosVoiceProcessingMode mode,
|
||||
}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_ios_voice_processing_mode(mode, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 27,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiSetIosVoiceProcessingModeConstMeta,
|
||||
argValues: [mode],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiSetIosVoiceProcessingModeConstMeta =>
|
||||
const TaskConstMeta(
|
||||
debugName: "set_ios_voice_processing_mode",
|
||||
argNames: ["mode"],
|
||||
);
|
||||
|
||||
@override
|
||||
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<void> crateApiSetVadModelPath({required String path}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(path, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 35,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiSetVadModelPathConstMeta,
|
||||
argValues: [path],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiSetVadModelPathConstMeta =>
|
||||
const TaskConstMeta(debugName: "set_vad_model_path", argNames: ["path"]);
|
||||
|
||||
@override
|
||||
Future<BridgeSnapshot> 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<dynamic>;
|
||||
if (arr.length != 13)
|
||||
throw Exception('unexpected arr length: expect 13 but see ${arr.length}');
|
||||
return BridgeAudioProcessingConfig(
|
||||
route: dco_decode_bridge_audio_route(arr[0]),
|
||||
iosMode: dco_decode_bridge_ios_voice_processing_mode(arr[1]),
|
||||
processingBackend: dco_decode_bridge_audio_backend(arr[2]),
|
||||
vadBackend: dco_decode_bridge_vad_backend(arr[3]),
|
||||
aec: dco_decode_bridge_effect_owner(arr[4]),
|
||||
ns: dco_decode_bridge_effect_owner(arr[5]),
|
||||
agc: dco_decode_bridge_effect_owner(arr[6]),
|
||||
hpfEnabled: dco_decode_bool(arr[7]),
|
||||
limiterEnabled: dco_decode_bool(arr[8]),
|
||||
vadHangoverMs: dco_decode_u_32(arr[9]),
|
||||
vadPreRollMs: dco_decode_u_32(arr[10]),
|
||||
vadMinTxMs: dco_decode_u_32(arr[11]),
|
||||
debugWavDumpEnabled: dco_decode_bool(arr[12]),
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingStats dco_decode_bridge_audio_processing_stats(
|
||||
dynamic raw,
|
||||
) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<dynamic>;
|
||||
if (arr.length != 19)
|
||||
throw Exception('unexpected arr length: expect 19 but see ${arr.length}');
|
||||
return BridgeAudioProcessingStats(
|
||||
inputDbfs: dco_decode_f_32(arr[0]),
|
||||
renderDbfs: dco_decode_f_32(arr[1]),
|
||||
processedDbfs: dco_decode_f_32(arr[2]),
|
||||
vadProbability: dco_decode_f_32(arr[3]),
|
||||
vadActive: dco_decode_bool(arr[4]),
|
||||
transmitting: dco_decode_bool(arr[5]),
|
||||
vadBackend: dco_decode_bridge_vad_backend(arr[6]),
|
||||
vadFallbackActive: dco_decode_bool(arr[7]),
|
||||
processingBackend: dco_decode_bridge_audio_backend(arr[8]),
|
||||
iosVoiceProcessingMode: dco_decode_bridge_ios_voice_processing_mode(
|
||||
arr[9],
|
||||
),
|
||||
audioRoute: dco_decode_bridge_audio_route(arr[10]),
|
||||
actualSampleRateHz: dco_decode_u_32(arr[11]),
|
||||
actualIoBufferFrames: dco_decode_u_32(arr[12]),
|
||||
inputOverruns: dco_decode_u_64(arr[13]),
|
||||
outputUnderruns: dco_decode_u_64(arr[14]),
|
||||
callbackXruns: dco_decode_u_64(arr[15]),
|
||||
clippedSamples: dco_decode_u_64(arr[16]),
|
||||
sonoraEnabled: dco_decode_bool(arr[17]),
|
||||
platformVoiceProcessingEnabled: dco_decode_bool(arr[18]),
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioRoute dco_decode_bridge_audio_route(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return BridgeAudioRoute.values[raw as int];
|
||||
}
|
||||
|
||||
@protected
|
||||
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,
|
||||
|
||||
@@ -33,6 +33,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_vad_backend(
|
||||
BridgeVadBackend self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_voice_join_error_code(
|
||||
BridgeVoiceJoinErrorCode self,
|
||||
|
||||
@@ -35,6 +35,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
@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<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_vad_backend(
|
||||
BridgeVadBackend self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_voice_join_error_code(
|
||||
BridgeVoiceJoinErrorCode self,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// P1 debug stats overlay widget.
|
||||
//
|
||||
// Shows a compact, auto-refreshing panel with the key audio processing
|
||||
// metrics from [BridgeAudioProcessingStats]. Intended for internal
|
||||
// debug builds only — wrap with a kDebugMode guard at the call site.
|
||||
//
|
||||
// Usage:
|
||||
// if (kDebugMode) const AudioDebugStatsPanel(),
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../src/rust/api.dart';
|
||||
|
||||
/// Compact debug panel that polls [audioProcessingStats] every 500 ms
|
||||
/// and renders the key metrics in a monospace overlay.
|
||||
///
|
||||
/// Designed to be placed in a [Stack] over the main UI during
|
||||
/// development. It is transparent to hit-testing so it does not
|
||||
/// interfere with taps.
|
||||
class AudioDebugStatsPanel extends StatefulWidget {
|
||||
const AudioDebugStatsPanel({super.key});
|
||||
|
||||
@override
|
||||
State<AudioDebugStatsPanel> createState() => _AudioDebugStatsPanelState();
|
||||
}
|
||||
|
||||
class _AudioDebugStatsPanelState extends State<AudioDebugStatsPanel> {
|
||||
BridgeAudioProcessingStats? _stats;
|
||||
Timer? _timer;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_poll();
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _poll());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
try {
|
||||
final stats = await audioProcessingStats();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_stats = stats;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _error = e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: _buildPanel(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPanel() {
|
||||
if (_error != null) {
|
||||
return _PanelBox(
|
||||
child: Text(
|
||||
'audio stats error:\n$_error',
|
||||
style: _monoStyle(Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final s = _stats;
|
||||
if (s == null) {
|
||||
return _PanelBox(
|
||||
child: Text('audio stats: loading…', style: _monoStyle(Colors.grey)),
|
||||
);
|
||||
}
|
||||
|
||||
final vadColor = s.vadActive ? Colors.greenAccent : Colors.grey;
|
||||
final txColor = s.transmitting ? Colors.redAccent : Colors.grey;
|
||||
final xruns = s.callbackXruns + s.inputOverruns + s.outputUnderruns;
|
||||
|
||||
return _PanelBox(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row('route', _routeLabel(s.audioRoute), Colors.white),
|
||||
_row('backend', _backendLabel(s.processingBackend), Colors.white),
|
||||
_row(
|
||||
'vpio',
|
||||
s.platformVoiceProcessingEnabled ? 'on' : 'off',
|
||||
Colors.white,
|
||||
),
|
||||
_row('sonora', s.sonoraEnabled ? 'on' : 'off', Colors.white),
|
||||
const SizedBox(height: 4),
|
||||
_row(
|
||||
'mic in',
|
||||
'${s.inputDbfs.toStringAsFixed(1)} dBFS',
|
||||
Colors.white,
|
||||
),
|
||||
_row(
|
||||
'mic out',
|
||||
'${s.processedDbfs.toStringAsFixed(1)} dBFS',
|
||||
Colors.white,
|
||||
),
|
||||
_row(
|
||||
'render',
|
||||
'${s.renderDbfs.toStringAsFixed(1)} dBFS',
|
||||
Colors.white,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_row(
|
||||
'vad',
|
||||
'${(s.vadProbability * 100).toStringAsFixed(0)}% '
|
||||
'${s.vadActive ? "OPEN" : "closed"}',
|
||||
vadColor,
|
||||
),
|
||||
_row('vad backend', _vadBackendLabel(s.vadBackend), Colors.white),
|
||||
if (s.vadFallbackActive)
|
||||
_row('vad fallback', 'ACTIVE', Colors.orange),
|
||||
const SizedBox(height: 4),
|
||||
_row('tx', s.transmitting ? 'TRANSMITTING' : 'idle', txColor),
|
||||
_row('sr', '${s.actualSampleRateHz} Hz', Colors.white),
|
||||
_row('buf', '${s.actualIoBufferFrames} frames', Colors.white),
|
||||
if (xruns > BigInt.zero)
|
||||
_row('xruns', xruns.toString(), Colors.orange),
|
||||
if (s.clippedSamples > BigInt.zero)
|
||||
_row('clipped', s.clippedSamples.toString(), Colors.orange),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String label, String value, Color valueColor) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('$label: ', style: _monoStyle(Colors.grey.shade400)),
|
||||
Text(value, style: _monoStyle(valueColor)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle _monoStyle(Color color) => TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
color: color,
|
||||
height: 1.4,
|
||||
);
|
||||
|
||||
String _routeLabel(BridgeAudioRoute route) => switch (route) {
|
||||
BridgeAudioRoute.speaker => 'speaker',
|
||||
BridgeAudioRoute.earpiece => 'earpiece',
|
||||
BridgeAudioRoute.wiredHeadset => 'wired',
|
||||
BridgeAudioRoute.bluetoothHfp => 'bt-hfp',
|
||||
BridgeAudioRoute.bluetoothA2Dp => 'bt-a2dp',
|
||||
BridgeAudioRoute.unknown => 'unknown',
|
||||
};
|
||||
|
||||
String _backendLabel(BridgeAudioBackend backend) => switch (backend) {
|
||||
BridgeAudioBackend.platformVoiceProcessing => 'vpio',
|
||||
BridgeAudioBackend.sonora => 'sonora',
|
||||
BridgeAudioBackend.noop => 'noop',
|
||||
BridgeAudioBackend.webrtcApm => 'webrtc-apm',
|
||||
};
|
||||
|
||||
String _vadBackendLabel(BridgeVadBackend backend) => switch (backend) {
|
||||
BridgeVadBackend.webrtcVad => 'webrtc',
|
||||
BridgeVadBackend.sileroOnnx => 'silero',
|
||||
BridgeVadBackend.tenVad => 'ten',
|
||||
BridgeVadBackend.energyDebug => 'energy',
|
||||
BridgeVadBackend.disabled => 'off',
|
||||
};
|
||||
}
|
||||
|
||||
/// Semi-transparent dark box for the debug panel.
|
||||
class _PanelBox extends StatelessWidget {
|
||||
const _PanelBox({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/// SRS-209 listen-only banner for Android RECORD_AUDIO permission.
|
||||
/// SRS-209 listen-only banner for mobile microphone permission.
|
||||
///
|
||||
/// Trace:
|
||||
/// - 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<AndroidRecordAudioPermissionState> recordAudioState;
|
||||
final Future<AndroidRecordAudioPermissionState> Function() ensureRecordAudio;
|
||||
final Future<void> Function() openAppSettings;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<AndroidRecordAudioPermissionState>(
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/foundation.dart'
|
||||
show TargetPlatform, defaultTargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
|
||||
/// PTT capability badge (gen2 v0.9.3 / SDD-091).
|
||||
///
|
||||
/// Renders the active PTT level + backend in the Voice Bar so the
|
||||
/// user understands which input path is in effect. When the
|
||||
/// resolved capability is `L0Focused` an info icon appears that
|
||||
/// opens a per-platform explanation sheet describing why Global
|
||||
/// PTT is not active and what the user can do to engage it.
|
||||
class PttCapabilityBadge extends StatelessWidget {
|
||||
/// Construct a badge.
|
||||
const PttCapabilityBadge({
|
||||
super.key,
|
||||
required this.level,
|
||||
required this.backendId,
|
||||
required this.boundInputClass,
|
||||
});
|
||||
|
||||
/// Resolved capability level as the bridge emits it
|
||||
/// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
|
||||
/// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
|
||||
final String level;
|
||||
|
||||
/// Stable backend identifier (`focused`, `windows-raw-input`, …).
|
||||
final String backendId;
|
||||
|
||||
/// Privacy-safe input class (`keyboard`, `mouse-side-button`,
|
||||
/// or empty when no binding is set).
|
||||
final String boundInputClass;
|
||||
|
||||
bool get _isFocused => level == 'L0Focused';
|
||||
|
||||
String _explainBodyForPlatform(AppL10n l10n) {
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.windows:
|
||||
return l10n.pttCapabilityExplainGoGlobalWindows;
|
||||
case TargetPlatform.macOS:
|
||||
return l10n.pttCapabilityExplainGoGlobalMacos;
|
||||
case TargetPlatform.linux:
|
||||
return l10n.pttCapabilityExplainGoGlobalLinux;
|
||||
case TargetPlatform.iOS:
|
||||
return l10n.pttCapabilityExplainGoGlobalIos;
|
||||
default:
|
||||
return l10n.pttCapabilityExplainGoGlobalGeneric;
|
||||
}
|
||||
}
|
||||
|
||||
void _openExplanationSheet(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final theme = Theme.of(sheetContext);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttCapabilityExplainTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedHeading,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_explainBodyForPlatform(l10n),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
|
||||
final tooltipMessage = boundInputClass.isEmpty
|
||||
? badgeLabel
|
||||
: '$badgeLabel\n($boundInputClass)';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Tooltip(
|
||||
message: tooltipMessage,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isFocused ? Icons.crop_free : Icons.public,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isFocused)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline, size: 16),
|
||||
tooltip: l10n.pttCapabilityExplainTitle,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _openExplanationSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,16 @@
|
||||
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
/// True when the host is a touch-only mobile platform without a
|
||||
/// hardware keyboard the user would bind a PTT key on.
|
||||
bool get isTouchOnlyPttHost {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS || Platform.isAndroid;
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
// Voice settings dialog (SDD-097). Surfaces a TransmitMode radio
|
||||
// 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<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
||||
@@ -62,115 +59,273 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
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<rust.BridgeTransmitMode>(
|
||||
dense: true,
|
||||
value: rust.BridgeTransmitMode.ptt,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModePtt),
|
||||
onChanged: (v) => setState(() => _mode = v!),
|
||||
),
|
||||
RadioListTile<rust.BridgeTransmitMode>(
|
||||
dense: true,
|
||||
value: rust.BridgeTransmitMode.continuous,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModeContinuous),
|
||||
onChanged: (v) => setState(() => _mode = v!),
|
||||
),
|
||||
RadioListTile<rust.BridgeTransmitMode>(
|
||||
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<rust.BridgeTransmitMode>(
|
||||
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<rust.BridgeTransmitMode>(
|
||||
value: rust.BridgeTransmitMode.continuous,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModeContinuous),
|
||||
onSelected: (v) => _mode = v,
|
||||
),
|
||||
_radioTile<rust.BridgeTransmitMode>(
|
||||
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<rust.BridgeIosVoiceProcessingMode>(
|
||||
value:
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
groupValue: _iosMode,
|
||||
title: const Text('Platform (VPIO)'),
|
||||
subtitle: _tileSubtitle('Apple AEC · NS · AGC'),
|
||||
onSelected: (v) => _iosMode = v,
|
||||
),
|
||||
_radioTile<rust.BridgeIosVoiceProcessingMode>(
|
||||
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
|
||||
groupValue: _iosMode,
|
||||
title: const Text('Sonora (experimental)'),
|
||||
subtitle: _tileSubtitle('Rust AEC3 · NS · AGC2'),
|
||||
onSelected: (v) => _iosMode = v,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
|
||||
// DSP toggles
|
||||
_subHeader(theme, 'DSP stages'),
|
||||
_switchTile(
|
||||
title: 'Noise suppression (NS)',
|
||||
subtitle: 'Wiener filter · stationary noise',
|
||||
value: _nsEnabled,
|
||||
onSelected: (v) => _nsEnabled = v,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Echo cancellation (AEC3)',
|
||||
subtitle: platformVpio
|
||||
? 'Managed by platform VPIO'
|
||||
: 'Adaptive NLMS · 80 ms tail',
|
||||
value: _aecEnabled,
|
||||
// AEC is always on in VPIO mode — disable the toggle.
|
||||
onSelected: platformVpio ? null : (v) => _aecEnabled = v,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Auto gain control (AGC2)',
|
||||
subtitle: 'RNN VAD-gated · −18 dBFS target',
|
||||
value: _agcEnabled,
|
||||
onSelected: (v) => _agcEnabled = v,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'High-pass filter (HPF)',
|
||||
subtitle: '80 Hz Butterworth · DC removal',
|
||||
value: _hpfEnabled,
|
||||
onSelected: (v) => _hpfEnabled = v,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Peak limiter',
|
||||
subtitle: '−1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: _limiterEnabled,
|
||||
onSelected: (v) => _limiterEnabled = v,
|
||||
),
|
||||
|
||||
// ── VAD ────────────────────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
_sectionHeader(theme, 'Voice activity detection (VAD)'),
|
||||
|
||||
_subHeader(theme, 'Backend'),
|
||||
_radioTile<rust.BridgeVadBackend>(
|
||||
value: rust.BridgeVadBackend.webrtcVad,
|
||||
groupValue: _vadBackend,
|
||||
title: const Text('WebRTC VAD'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Fast · energy-based · always available',
|
||||
),
|
||||
onSelected: (v) => _vadBackend = v,
|
||||
),
|
||||
_radioTile<rust.BridgeVadBackend>(
|
||||
value: rust.BridgeVadBackend.sileroOnnx,
|
||||
groupValue: _vadBackend,
|
||||
title: const Text('Silero v6 (ONNX)'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Neural · 32 ms frames · requires model file',
|
||||
),
|
||||
onSelected: (v) => _vadBackend = v,
|
||||
),
|
||||
_radioTile<rust.BridgeVadBackend>(
|
||||
value: rust.BridgeVadBackend.tenVad,
|
||||
groupValue: _vadBackend,
|
||||
title: const Text('TEN VAD'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Neural · 16 kHz · native runtime optional',
|
||||
),
|
||||
onSelected: (v) => _vadBackend = v,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Debug ──────────────────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
_sectionHeader(theme, 'Debug'),
|
||||
_switchTile(
|
||||
title: 'WAV dump',
|
||||
subtitle: 'Record raw/processed mic to temp dir',
|
||||
value: _debugWavDump,
|
||||
onSelected: (v) => _debugWavDump = v,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
@@ -184,6 +339,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
mode: _mode,
|
||||
releaseTailMs: _releaseTail.round(),
|
||||
bindKeyRequested: false,
|
||||
audioConfig: _buildConfig(),
|
||||
),
|
||||
),
|
||||
child: Text(l10n.pttConfigureSaveAction),
|
||||
@@ -191,4 +347,54 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _radioTile<T>({
|
||||
required T value,
|
||||
required T groupValue,
|
||||
required Widget title,
|
||||
Widget? subtitle,
|
||||
required ValueChanged<T> onSelected,
|
||||
}) => RadioListTile<T>(
|
||||
dense: true,
|
||||
value: value,
|
||||
groupValue: groupValue,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
setState(() => onSelected(v));
|
||||
},
|
||||
);
|
||||
|
||||
Widget _switchTile({
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required bool value,
|
||||
required ValueChanged<bool>? onSelected,
|
||||
}) => SwitchListTile(
|
||||
dense: true,
|
||||
title: Text(title),
|
||||
subtitle: _tileSubtitle(subtitle),
|
||||
value: value,
|
||||
onChanged: onSelected == null ? null : (v) => setState(() => onSelected(v)),
|
||||
);
|
||||
|
||||
Widget _tileSubtitle(String text) =>
|
||||
Text(text, style: const TextStyle(fontSize: 11));
|
||||
|
||||
Widget _sectionHeader(ThemeData theme, String text) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(text, style: theme.textTheme.titleSmall),
|
||||
);
|
||||
|
||||
Widget _subHeader(ThemeData theme, String text) => Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 2),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -309,6 +309,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>());
|
||||
|
||||
// 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<String>());
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void>.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<void>.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<void>.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<void>.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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = <bool>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: VoicePttButton(active: false, onHeldChanged: states.add),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final center = tester.getCenter(find.byType(VoicePttButton));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
|
||||
expect(states, [true, false]);
|
||||
});
|
||||
|
||||
testWidgets('iOS permission service maps channel states and settings', (
|
||||
tester,
|
||||
) async {
|
||||
const channel = MethodChannel(iosPlatformChannelName);
|
||||
final methods = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
methods.add(call.method);
|
||||
switch (call.method) {
|
||||
case methodGetMicrophonePermissionState:
|
||||
return 'NotDetermined';
|
||||
case methodRequestMicrophonePermission:
|
||||
return 'Granted';
|
||||
case methodIosOpenAppSettings:
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final service = IosPermissionsService(channel: channel);
|
||||
await service.start();
|
||||
expect(
|
||||
service.recordAudioState.value,
|
||||
AndroidRecordAudioPermissionState.denied,
|
||||
);
|
||||
expect(
|
||||
await service.ensureRecordAudio(),
|
||||
AndroidRecordAudioPermissionState.granted,
|
||||
);
|
||||
await service.openAppSettings();
|
||||
|
||||
expect(methods, [
|
||||
methodGetMicrophonePermissionState,
|
||||
methodRequestMicrophonePermission,
|
||||
methodIosOpenAppSettings,
|
||||
]);
|
||||
|
||||
service.dispose();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
}
|
||||
|
||||
+102
-32
@@ -54,8 +54,9 @@ use chanora_state::channel_join::{
|
||||
pub mod ptt;
|
||||
|
||||
pub 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<bool>,
|
||||
@@ -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<AudioProcessingConfig, CoreError> {
|
||||
let guard = self.inner.lock().await;
|
||||
if let Some(state) = guard.as_ref() {
|
||||
if let Some(audio) = state.audio.as_ref() {
|
||||
return Ok(audio.audio_processing_config_snapshot());
|
||||
}
|
||||
}
|
||||
Ok(AudioProcessingConfig::default())
|
||||
}
|
||||
|
||||
/// Read P1 audio-processing diagnostics.
|
||||
pub async fn audio_processing_stats(&self) -> Result<AudioProcessingStats, CoreError> {
|
||||
let guard = self.inner.lock().await;
|
||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
|
||||
Ok(audio.audio_processing_stats())
|
||||
}
|
||||
|
||||
/// Configure the preferred Silero ONNX VAD model path.
|
||||
///
|
||||
/// This does not require an active connection. Running iOS audio
|
||||
/// backends observe the model-path epoch and reload on the next
|
||||
/// capture frame when Silero is selected.
|
||||
pub async fn set_vad_model_path(&self, path: String) -> Result<(), CoreError> {
|
||||
chanora_audio::vad::set_silero_model_path(&path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// iOS route-change hook (SDD-100). No-op when audio is not running.
|
||||
pub async fn ios_handle_route_change(&self, route: AudioRoute) -> Result<(), CoreError> {
|
||||
let guard = self.inner.lock().await;
|
||||
if let Some(state) = guard.as_ref() {
|
||||
if let Some(audio) = state.audio.as_ref() {
|
||||
let mut config = audio.audio_processing_config_snapshot();
|
||||
config.route = route;
|
||||
audio.set_audio_processing_config(config)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable or disable async WAV debug dump for the active audio
|
||||
/// session (DIAG_002 / DIAG_003). No-op when audio is not started.
|
||||
pub async fn set_audio_debug_wav_dump(&self, enabled: bool) -> Result<(), CoreError> {
|
||||
let guard = self.inner.lock().await;
|
||||
if let Some(state) = guard.as_ref() {
|
||||
if let Some(audio) = state.audio.as_ref() {
|
||||
let mut config = audio.audio_processing_config_snapshot();
|
||||
config.debug_wav_dump_enabled = enabled;
|
||||
audio.set_audio_processing_config(config)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// iOS media-services-reset hook (SDD-101). Rebuilds the audio
|
||||
/// unit using the supplied route so the processing policy is
|
||||
/// correct after the OS-level media reset.
|
||||
pub async fn ios_handle_media_services_reset(
|
||||
&self,
|
||||
route: AudioRoute,
|
||||
) -> Result<(), CoreError> {
|
||||
self.ios_handle_route_change(route).await
|
||||
}
|
||||
|
||||
/// iOS interruption-began hook (SDD-101). No-op when audio is
|
||||
/// 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<Mutex<Option<ConnectedState>>>,
|
||||
events_tx: broadcast::Sender<SessionEvent>,
|
||||
|
||||
@@ -44,6 +44,22 @@ reqwest = { version = "0.13", default-features = false, features = ["charset", "
|
||||
# Default features keep `audio_toolbox` + `core_audio`, both required
|
||||
# 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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<i16>,
|
||||
opus_out: [u8; MAX_OPUS_FRAME],
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
@@ -105,33 +96,11 @@ impl AndroidCaptureState {
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
) -> Result<Self, AudioError> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
//! P1 audio-processing configuration and statistics.
|
||||
//!
|
||||
//! iOS P1 ships the platform VoiceProcessingIO path by default. Rust
|
||||
//! software AEC/NS/AGC backends are represented in the schema so the
|
||||
//! bridge can reject unsafe combinations instead of silently enabling
|
||||
//! double processing.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
use crate::AudioError;
|
||||
|
||||
/// Physical/logical audio route class used for route-aware policy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AudioRoute {
|
||||
/// Built-in speakerphone path.
|
||||
Speaker,
|
||||
/// Built-in receiver/earpiece path.
|
||||
Earpiece,
|
||||
/// Wired headset or USB headset.
|
||||
WiredHeadset,
|
||||
/// Bluetooth Hands-Free Profile duplex route.
|
||||
BluetoothHfp,
|
||||
/// Bluetooth A2DP output-only route.
|
||||
BluetoothA2dp,
|
||||
/// Route could not be classified yet.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl AudioRoute {
|
||||
/// Stable bridge/debug string.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Speaker => "speaker",
|
||||
Self::Earpiece => "earpiece",
|
||||
Self::WiredHeadset => "wired_headset",
|
||||
Self::BluetoothHfp => "bluetooth_hfp",
|
||||
Self::BluetoothA2dp => "bluetooth_a2dp",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from the Swift-side route class string (case-insensitive).
|
||||
/// Unrecognised strings map to `Unknown`.
|
||||
pub fn from_route_class(s: &str) -> Self {
|
||||
match s {
|
||||
"Speaker" | "speaker" => Self::Speaker,
|
||||
"Earpiece" | "earpiece" => Self::Earpiece,
|
||||
"WiredHeadset" | "wired_headset" => Self::WiredHeadset,
|
||||
"BluetoothHfp" | "bluetooth_hfp" => Self::BluetoothHfp,
|
||||
"BluetoothA2dp" | "bluetooth_a2dp" => Self::BluetoothA2dp,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS voice-processing mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IosVoiceProcessingMode {
|
||||
/// Shipping default: Apple VoiceProcessingIO owns AEC/NS/AGC.
|
||||
PlatformVoiceProcessing,
|
||||
/// Experimental Sonora capture-processing path.
|
||||
SonoraExperimental,
|
||||
}
|
||||
|
||||
/// Processing backend selected by policy/config.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AudioBackend {
|
||||
/// Platform voice processing, VPIO on iOS.
|
||||
PlatformVoiceProcessing,
|
||||
/// Rust-native Sonora backend.
|
||||
Sonora,
|
||||
/// Future WebRTC APM backend.
|
||||
WebrtcApm,
|
||||
/// No processing.
|
||||
Noop,
|
||||
}
|
||||
|
||||
impl AudioBackend {
|
||||
/// Stable bridge/debug string.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::PlatformVoiceProcessing => "platform_voice_processing",
|
||||
Self::Sonora => "sonora",
|
||||
Self::WebrtcApm => "webrtc_apm",
|
||||
Self::Noop => "noop",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// VAD backend selected by policy/config.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VadBackend {
|
||||
/// Silero ONNX VAD. P1 schema default when model/runtime exist.
|
||||
SileroOnnx,
|
||||
/// TEN VAD backend. Native TEN runtime is optional; unavailable
|
||||
/// builds fall back to the realtime-safe WebRTC detector.
|
||||
TenVad,
|
||||
/// WebRTC-style fallback VAD.
|
||||
WebrtcVad,
|
||||
/// Debug-only energy VAD.
|
||||
EnergyDebug,
|
||||
/// VAD disabled.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl VadBackend {
|
||||
/// Stable bridge/debug string.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SileroOnnx => "silero_vad_onnx",
|
||||
Self::TenVad => "ten_vad",
|
||||
Self::WebrtcVad => "webrtc_vad",
|
||||
Self::EnergyDebug => "energy_debug",
|
||||
Self::Disabled => "disabled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effect owner for AEC/NS/AGC policy fields.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EffectOwner {
|
||||
/// Owned by platform voice processing.
|
||||
Platform,
|
||||
/// Owned by Sonora.
|
||||
Sonora,
|
||||
/// Owned by WebRTC APM.
|
||||
WebrtcApm,
|
||||
/// Conservative route-managed setting.
|
||||
Conservative,
|
||||
/// Disabled.
|
||||
Off,
|
||||
}
|
||||
|
||||
/// Voice-processing configuration owned by the Rust audio engine.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AudioProcessingConfig {
|
||||
/// Current route class.
|
||||
pub route: AudioRoute,
|
||||
/// iOS processing mode.
|
||||
pub ios_mode: IosVoiceProcessingMode,
|
||||
/// Processing backend.
|
||||
pub processing_backend: AudioBackend,
|
||||
/// VAD backend.
|
||||
pub vad_backend: VadBackend,
|
||||
/// AEC owner.
|
||||
pub aec: EffectOwner,
|
||||
/// Noise suppression owner.
|
||||
pub ns: EffectOwner,
|
||||
/// AGC owner.
|
||||
pub agc: EffectOwner,
|
||||
/// High-pass filter enabled.
|
||||
pub hpf_enabled: bool,
|
||||
/// Limiter enabled.
|
||||
pub limiter_enabled: bool,
|
||||
/// Hangover after speech closes.
|
||||
pub vad_hangover_ms: u32,
|
||||
/// Pre-roll before open.
|
||||
pub vad_pre_roll_ms: u32,
|
||||
/// Minimum transmit duration after open.
|
||||
pub vad_min_tx_ms: u32,
|
||||
/// Debug WAV dumps enabled.
|
||||
pub debug_wav_dump_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for AudioProcessingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
route: AudioRoute::Speaker,
|
||||
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
||||
vad_backend: VadBackend::SileroOnnx,
|
||||
aec: EffectOwner::Platform,
|
||||
// iOS VPIO owns NS/AGC on the default shipping path. Rust/Sonora
|
||||
// effects are opt-in through the experimental raw route only.
|
||||
ns: EffectOwner::Platform,
|
||||
agc: EffectOwner::Platform,
|
||||
hpf_enabled: true,
|
||||
limiter_enabled: true,
|
||||
vad_hangover_ms: crate::voice_activity::VAD_HANGOVER_MS,
|
||||
vad_pre_roll_ms: 160,
|
||||
vad_min_tx_ms: crate::voice_activity::VAD_MIN_TX_MS,
|
||||
debug_wav_dump_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioProcessingConfig {
|
||||
/// Validate P1 iOS invariants before applying a config.
|
||||
pub fn validate_for_ios(&self) -> Result<(), AudioError> {
|
||||
if self.route == AudioRoute::BluetoothA2dp {
|
||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||
"bluetooth_a2dp is output-only and cannot transmit duplex voice".to_string(),
|
||||
));
|
||||
}
|
||||
if self.ios_mode == IosVoiceProcessingMode::PlatformVoiceProcessing
|
||||
&& (self.processing_backend == AudioBackend::Sonora
|
||||
|| self.aec == EffectOwner::Sonora
|
||||
|| self.ns == EffectOwner::Sonora
|
||||
|| self.agc == EffectOwner::Sonora)
|
||||
{
|
||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||
"Sonora cannot be enabled with iOS VoiceProcessingIO".to_string(),
|
||||
));
|
||||
}
|
||||
if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental {
|
||||
if self.processing_backend != AudioBackend::Sonora {
|
||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||
"ios Sonora experimental mode requires the Sonora processing backend"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Demote a failed VAD backend to the WebRTC fallback.
|
||||
///
|
||||
/// Returns `true` when the config changed.
|
||||
pub fn disable_failed_vad_backend(&mut self, failed_backend: VadBackend) -> bool {
|
||||
if self.vad_backend == failed_backend && failed_backend != VadBackend::WebrtcVad {
|
||||
self.vad_backend = VadBackend::WebrtcVad;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_config_is_valid_for_ios_vpio() {
|
||||
let config = AudioProcessingConfig::default();
|
||||
|
||||
assert!(config.validate_for_ios().is_ok());
|
||||
assert_eq!(
|
||||
config.processing_backend,
|
||||
AudioBackend::PlatformVoiceProcessing
|
||||
);
|
||||
assert_eq!(config.aec, EffectOwner::Platform);
|
||||
assert_eq!(config.ns, EffectOwner::Platform);
|
||||
assert_eq!(config.agc, EffectOwner::Platform);
|
||||
assert_eq!(config.vad_backend, VadBackend::SileroOnnx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_voice_processing_rejects_sonora_effects() {
|
||||
let config = AudioProcessingConfig {
|
||||
ns: EffectOwner::Sonora,
|
||||
..AudioProcessingConfig::default()
|
||||
};
|
||||
|
||||
assert!(config.validate_for_ios().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_vad_has_stable_debug_string() {
|
||||
assert_eq!(VadBackend::TenVad.as_str(), "ten_vad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sonora_experimental_allows_full_sonora_chain() {
|
||||
let config = AudioProcessingConfig {
|
||||
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
|
||||
processing_backend: AudioBackend::Sonora,
|
||||
aec: EffectOwner::Sonora,
|
||||
ns: EffectOwner::Sonora,
|
||||
agc: EffectOwner::Sonora,
|
||||
..AudioProcessingConfig::default()
|
||||
};
|
||||
|
||||
assert!(config.validate_for_ios().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sonora_experimental_rejects_non_sonora_backend() {
|
||||
let config = AudioProcessingConfig {
|
||||
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
|
||||
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
||||
aec: EffectOwner::Sonora,
|
||||
ns: EffectOwner::Sonora,
|
||||
agc: EffectOwner::Sonora,
|
||||
..AudioProcessingConfig::default()
|
||||
};
|
||||
|
||||
assert!(config.validate_for_ios().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disable_failed_vad_backend_demotes_to_webrtc() {
|
||||
let mut config = AudioProcessingConfig {
|
||||
vad_backend: VadBackend::SileroOnnx,
|
||||
..AudioProcessingConfig::default()
|
||||
};
|
||||
|
||||
assert!(config.disable_failed_vad_backend(VadBackend::SileroOnnx));
|
||||
assert_eq!(config.vad_backend, VadBackend::WebrtcVad);
|
||||
assert!(!config.disable_failed_vad_backend(VadBackend::SileroOnnx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime audio processing stats exposed to bridge/UI diagnostics.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioProcessingStats {
|
||||
/// Input dBFS before processing.
|
||||
pub input_dbfs: f32,
|
||||
/// Render dBFS before playout.
|
||||
pub render_dbfs: f32,
|
||||
/// Processed capture dBFS.
|
||||
pub processed_dbfs: f32,
|
||||
/// Latest VAD probability or fallback confidence.
|
||||
pub vad_probability: f32,
|
||||
/// VAD active state.
|
||||
pub vad_active: bool,
|
||||
/// Current resolved transmit state.
|
||||
pub transmitting: bool,
|
||||
/// VAD backend.
|
||||
pub vad_backend: VadBackend,
|
||||
/// Whether fallback VAD is active.
|
||||
pub vad_fallback_active: bool,
|
||||
/// Processing backend.
|
||||
pub processing_backend: AudioBackend,
|
||||
/// iOS mode.
|
||||
pub ios_voice_processing_mode: IosVoiceProcessingMode,
|
||||
/// Route class.
|
||||
pub audio_route: AudioRoute,
|
||||
/// Actual sample rate.
|
||||
pub actual_sample_rate_hz: u32,
|
||||
/// Actual IO buffer frame count.
|
||||
pub actual_io_buffer_frames: u32,
|
||||
/// Input overrun count.
|
||||
pub input_overruns: u64,
|
||||
/// Output underrun count.
|
||||
pub output_underruns: u64,
|
||||
/// Callback xrun count.
|
||||
pub callback_xruns: u64,
|
||||
/// Clipped sample count.
|
||||
pub clipped_samples: u64,
|
||||
/// Sonora enabled.
|
||||
pub sonora_enabled: bool,
|
||||
/// Platform voice processing enabled.
|
||||
pub platform_voice_processing_enabled: bool,
|
||||
}
|
||||
|
||||
/// Lock-free stats storage shared with callbacks.
|
||||
pub struct SharedAudioProcessingStats {
|
||||
input_dbfs: AtomicU32,
|
||||
render_dbfs: AtomicU32,
|
||||
processed_dbfs: AtomicU32,
|
||||
vad_probability: AtomicU32,
|
||||
vad_active: AtomicBool,
|
||||
transmitting: AtomicBool,
|
||||
vad_fallback_active: AtomicBool,
|
||||
input_overruns: AtomicU64,
|
||||
output_underruns: AtomicU64,
|
||||
callback_xruns: AtomicU64,
|
||||
clipped_samples: AtomicU64,
|
||||
actual_sample_rate_hz: AtomicU32,
|
||||
actual_io_buffer_frames: AtomicU32,
|
||||
}
|
||||
|
||||
impl Default for SharedAudioProcessingStats {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input_dbfs: AtomicU32::new((-120.0_f32).to_bits()),
|
||||
render_dbfs: AtomicU32::new((-120.0_f32).to_bits()),
|
||||
processed_dbfs: AtomicU32::new((-120.0_f32).to_bits()),
|
||||
vad_probability: AtomicU32::new(0.0_f32.to_bits()),
|
||||
vad_active: AtomicBool::new(false),
|
||||
transmitting: AtomicBool::new(false),
|
||||
vad_fallback_active: AtomicBool::new(false),
|
||||
input_overruns: AtomicU64::new(0),
|
||||
output_underruns: AtomicU64::new(0),
|
||||
callback_xruns: AtomicU64::new(0),
|
||||
clipped_samples: AtomicU64::new(0),
|
||||
actual_sample_rate_hz: AtomicU32::new(crate::frame::SAMPLE_RATE_HZ),
|
||||
actual_io_buffer_frames: AtomicU32::new(crate::frame::FRAME_20MS_SAMPLES as u32),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedAudioProcessingStats {
|
||||
/// Store capture levels and VAD state.
|
||||
pub fn update_capture(
|
||||
&self,
|
||||
input_dbfs: f32,
|
||||
processed_dbfs: f32,
|
||||
probability: f32,
|
||||
vad_active: bool,
|
||||
transmitting: bool,
|
||||
) {
|
||||
self.input_dbfs
|
||||
.store(input_dbfs.to_bits(), Ordering::Relaxed);
|
||||
self.processed_dbfs
|
||||
.store(processed_dbfs.to_bits(), Ordering::Relaxed);
|
||||
self.vad_probability
|
||||
.store(probability.clamp(0.0, 1.0).to_bits(), Ordering::Relaxed);
|
||||
self.vad_active.store(vad_active, Ordering::Relaxed);
|
||||
self.transmitting.store(transmitting, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Store render level and callback buffer size.
|
||||
pub fn update_render(&self, dbfs: f32, io_buffer_frames: u32) {
|
||||
self.render_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
|
||||
self.actual_io_buffer_frames
|
||||
.store(io_buffer_frames, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment output underrun count.
|
||||
pub fn increment_output_underrun(&self) {
|
||||
self.output_underruns.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Increment callback xrun count.
|
||||
pub fn increment_callback_xrun(&self) {
|
||||
self.callback_xruns.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Add clipped samples.
|
||||
pub fn add_clipped_samples(&self, count: u64) {
|
||||
self.clipped_samples.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Store whether the selected VAD backend is currently using a fallback.
|
||||
pub fn set_vad_fallback_active(&self, active: bool) {
|
||||
self.vad_fallback_active.store(active, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Build an owned stats snapshot with config-derived labels.
|
||||
pub fn snapshot(&self, config: &AudioProcessingConfig) -> AudioProcessingStats {
|
||||
AudioProcessingStats {
|
||||
input_dbfs: f32::from_bits(self.input_dbfs.load(Ordering::Relaxed)),
|
||||
render_dbfs: f32::from_bits(self.render_dbfs.load(Ordering::Relaxed)),
|
||||
processed_dbfs: f32::from_bits(self.processed_dbfs.load(Ordering::Relaxed)),
|
||||
vad_probability: f32::from_bits(self.vad_probability.load(Ordering::Relaxed)),
|
||||
vad_active: self.vad_active.load(Ordering::Relaxed),
|
||||
transmitting: self.transmitting.load(Ordering::Relaxed),
|
||||
vad_backend: config.vad_backend,
|
||||
vad_fallback_active: self.vad_fallback_active.load(Ordering::Relaxed),
|
||||
processing_backend: config.processing_backend,
|
||||
ios_voice_processing_mode: config.ios_mode,
|
||||
audio_route: config.route,
|
||||
actual_sample_rate_hz: self.actual_sample_rate_hz.load(Ordering::Relaxed),
|
||||
actual_io_buffer_frames: self.actual_io_buffer_frames.load(Ordering::Relaxed),
|
||||
input_overruns: self.input_overruns.load(Ordering::Relaxed),
|
||||
output_underruns: self.output_underruns.load(Ordering::Relaxed),
|
||||
callback_xruns: self.callback_xruns.load(Ordering::Relaxed),
|
||||
clipped_samples: self.clipped_samples.load(Ordering::Relaxed),
|
||||
sonora_enabled: config.processing_backend == AudioBackend::Sonora,
|
||||
platform_voice_processing_enabled: config.processing_backend
|
||||
== AudioBackend::PlatformVoiceProcessing,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Async WAV debug dump writer for P1 diagnostics.
|
||||
//!
|
||||
//! Captures three streams for offline analysis:
|
||||
//! * `raw_mic` — before AudioProcessor (INV_007: never from callback)
|
||||
//! * `render_reference` — remote mixer output before playout
|
||||
//! * `processed_mic` — after AudioProcessor
|
||||
//!
|
||||
//! ## Design
|
||||
//!
|
||||
//! The realtime callback MUST NOT write to disk (INV_007). Instead it
|
||||
//! pushes 10 ms f32 frames onto a bounded `std::sync::mpsc` channel.
|
||||
//! A background `tokio::task` drains the channel and writes WAV data.
|
||||
//!
|
||||
//! The channel is bounded (capacity = 500 frames ≈ 5 s of audio per
|
||||
//! stream). If the writer falls behind, frames are dropped rather than
|
||||
//! blocking the callback thread.
|
||||
//!
|
||||
//! WAV files are written to the OS temp directory with a filename that
|
||||
//! encodes the stream name, route, backend, and a timestamp so
|
||||
//! multiple sessions don't overwrite each other.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let writer = WavDebugRecorder::start(route, backend);
|
||||
//! // In realtime callback (non-blocking):
|
||||
//! writer.push_raw_mic(&frame);
|
||||
//! writer.push_render_reference(&frame);
|
||||
//! writer.push_processed_mic(&frame);
|
||||
//! // On session end:
|
||||
//! writer.stop(); // flushes and closes files
|
||||
//! ```
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::audio_processing::{AudioBackend, AudioRoute};
|
||||
use crate::frame::FRAME_10MS_SAMPLES;
|
||||
|
||||
/// Maximum number of 10 ms frames buffered per stream before drops.
|
||||
const CHANNEL_CAPACITY: usize = 500;
|
||||
|
||||
/// Sample rate for WAV output (matches the capture pipeline).
|
||||
const WAV_SAMPLE_RATE: u32 = 48_000;
|
||||
|
||||
/// Identifies which debug stream a frame belongs to.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum StreamId {
|
||||
RawMic,
|
||||
RenderReference,
|
||||
ProcessedMic,
|
||||
}
|
||||
|
||||
/// A single 10 ms frame tagged with its stream.
|
||||
struct DebugFrame {
|
||||
stream: StreamId,
|
||||
samples: Box<[f32; FRAME_10MS_SAMPLES]>,
|
||||
}
|
||||
|
||||
/// Handle for pushing frames from the realtime callback.
|
||||
///
|
||||
/// All push methods are non-blocking: if the channel is full the
|
||||
/// frame is silently dropped and a counter is incremented.
|
||||
pub struct WavDebugRecorder {
|
||||
tx: mpsc::SyncSender<DebugFrame>,
|
||||
/// Frames dropped due to full channel (diagnostic only).
|
||||
drops: std::sync::atomic::AtomicU64,
|
||||
/// Whether the recorder is active (set to false on stop).
|
||||
active: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl WavDebugRecorder {
|
||||
/// Start the async WAV writer task. Returns a handle for pushing
|
||||
/// frames from the realtime callback.
|
||||
///
|
||||
/// `route` and `backend` are embedded in the output filenames.
|
||||
pub fn start(route: AudioRoute, backend: AudioBackend) -> std::sync::Arc<Self> {
|
||||
let (tx, rx) = mpsc::sync_channel::<DebugFrame>(CHANNEL_CAPACITY);
|
||||
let recorder = std::sync::Arc::new(Self {
|
||||
tx,
|
||||
drops: std::sync::atomic::AtomicU64::new(0),
|
||||
active: std::sync::atomic::AtomicBool::new(true),
|
||||
});
|
||||
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let route_str = route.as_str().to_string();
|
||||
let backend_str = backend.as_str().to_string();
|
||||
|
||||
// Spawn a blocking task so the WAV writer doesn't compete
|
||||
// with the tokio async executor for CPU time.
|
||||
std::thread::Builder::new()
|
||||
.name("chanora-wav-writer".to_string())
|
||||
.spawn(move || {
|
||||
wav_writer_task(rx, &route_str, &backend_str, ts);
|
||||
})
|
||||
.ok();
|
||||
|
||||
recorder
|
||||
}
|
||||
|
||||
/// Push a raw mic frame (before AudioProcessor). Non-blocking.
|
||||
pub fn push_raw_mic(&self, samples: &[f32; FRAME_10MS_SAMPLES]) {
|
||||
self.push(StreamId::RawMic, samples);
|
||||
}
|
||||
|
||||
/// Push a render-reference frame (remote mixer output before playout).
|
||||
/// Non-blocking.
|
||||
pub fn push_render_reference(&self, samples: &[f32; FRAME_10MS_SAMPLES]) {
|
||||
self.push(StreamId::RenderReference, samples);
|
||||
}
|
||||
|
||||
/// Push a processed mic frame (after AudioProcessor). Non-blocking.
|
||||
pub fn push_processed_mic(&self, samples: &[f32; FRAME_10MS_SAMPLES]) {
|
||||
self.push(StreamId::ProcessedMic, samples);
|
||||
}
|
||||
|
||||
/// Stop the recorder. Drops the sender so the writer task drains
|
||||
/// and closes the WAV files.
|
||||
pub fn stop(&self) {
|
||||
self.active
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
// The sender is not dropped here because `self` is behind Arc.
|
||||
// The writer task will exit when all senders are dropped (i.e.
|
||||
// when the Arc is dropped). This is intentional: the task
|
||||
// drains any remaining frames before closing files.
|
||||
}
|
||||
|
||||
/// Number of frames dropped due to a full channel.
|
||||
pub fn drop_count(&self) -> u64 {
|
||||
self.drops.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn push(&self, stream: StreamId, samples: &[f32; FRAME_10MS_SAMPLES]) {
|
||||
if !self.active.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut boxed = Box::new([0.0_f32; FRAME_10MS_SAMPLES]);
|
||||
boxed.copy_from_slice(samples);
|
||||
let frame = DebugFrame {
|
||||
stream,
|
||||
samples: boxed,
|
||||
};
|
||||
if self.tx.try_send(frame).is_err() {
|
||||
self.drops
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- WAV writer task ----------
|
||||
|
||||
struct WavFile {
|
||||
path: PathBuf,
|
||||
file: std::fs::File,
|
||||
samples_written: u32,
|
||||
}
|
||||
|
||||
impl WavFile {
|
||||
fn create(dir: &std::path::Path, name: &str) -> Option<Self> {
|
||||
let path = dir.join(name);
|
||||
match std::fs::File::create(&path) {
|
||||
Ok(mut file) => {
|
||||
// Write a placeholder WAV header; we'll patch it on close.
|
||||
if write_wav_header(&mut file, 0).is_ok() {
|
||||
Some(Self {
|
||||
path,
|
||||
file,
|
||||
samples_written: 0,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, path = %path.display(), "wav debug: failed to create file");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_samples(&mut self, samples: &[f32]) {
|
||||
for &s in samples {
|
||||
let i16_val = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
let _ = self.file.write_all(&i16_val.to_le_bytes());
|
||||
}
|
||||
self.samples_written += samples.len() as u32;
|
||||
}
|
||||
|
||||
fn finalize(mut self) {
|
||||
// Seek back to the start and rewrite the header with the
|
||||
// correct data size.
|
||||
use std::io::Seek;
|
||||
if self.file.seek(std::io::SeekFrom::Start(0)).is_ok() {
|
||||
let _ = write_wav_header(&mut self.file, self.samples_written);
|
||||
}
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
path = %self.path.display(),
|
||||
samples = self.samples_written,
|
||||
"wav debug: file closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_wav_header(file: &mut std::fs::File, num_samples: u32) -> std::io::Result<()> {
|
||||
// PCM WAV header: 44 bytes.
|
||||
// Channels: 1 (mono), sample rate: 48000, bit depth: 16.
|
||||
let channels: u16 = 1;
|
||||
let sample_rate: u32 = WAV_SAMPLE_RATE;
|
||||
let bits_per_sample: u16 = 16;
|
||||
let byte_rate = sample_rate * channels as u32 * bits_per_sample as u32 / 8;
|
||||
let block_align = channels * bits_per_sample / 8;
|
||||
let data_size = num_samples * channels as u32 * bits_per_sample as u32 / 8;
|
||||
let chunk_size = 36 + data_size;
|
||||
|
||||
file.write_all(b"RIFF")?;
|
||||
file.write_all(&chunk_size.to_le_bytes())?;
|
||||
file.write_all(b"WAVE")?;
|
||||
file.write_all(b"fmt ")?;
|
||||
file.write_all(&16u32.to_le_bytes())?; // subchunk1 size
|
||||
file.write_all(&1u16.to_le_bytes())?; // PCM format
|
||||
file.write_all(&channels.to_le_bytes())?;
|
||||
file.write_all(&sample_rate.to_le_bytes())?;
|
||||
file.write_all(&byte_rate.to_le_bytes())?;
|
||||
file.write_all(&block_align.to_le_bytes())?;
|
||||
file.write_all(&bits_per_sample.to_le_bytes())?;
|
||||
file.write_all(b"data")?;
|
||||
file.write_all(&data_size.to_le_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wav_writer_task(rx: mpsc::Receiver<DebugFrame>, route: &str, backend: &str, ts: u64) {
|
||||
let dir = std::env::temp_dir();
|
||||
let prefix = format!("chanora_debug_{route}_{backend}_{ts}");
|
||||
|
||||
let mut raw_mic = WavFile::create(&dir, &format!("{prefix}_raw_mic.wav"));
|
||||
let mut render_ref = WavFile::create(&dir, &format!("{prefix}_render_reference.wav"));
|
||||
let mut processed = WavFile::create(&dir, &format!("{prefix}_processed_mic.wav"));
|
||||
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
dir = %dir.display(),
|
||||
prefix = %prefix,
|
||||
"wav debug: writer started"
|
||||
);
|
||||
|
||||
for frame in rx {
|
||||
match frame.stream {
|
||||
StreamId::RawMic => {
|
||||
if let Some(f) = raw_mic.as_mut() {
|
||||
f.write_samples(&*frame.samples);
|
||||
}
|
||||
}
|
||||
StreamId::RenderReference => {
|
||||
if let Some(f) = render_ref.as_mut() {
|
||||
f.write_samples(&*frame.samples);
|
||||
}
|
||||
}
|
||||
StreamId::ProcessedMic => {
|
||||
if let Some(f) = processed.as_mut() {
|
||||
f.write_samples(&*frame.samples);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel closed — finalize all files.
|
||||
if let Some(f) = raw_mic {
|
||||
f.finalize();
|
||||
}
|
||||
if let Some(f) = render_ref {
|
||||
f.finalize();
|
||||
}
|
||||
if let Some(f) = processed {
|
||||
f.finalize();
|
||||
}
|
||||
|
||||
info!(target: "chanora_audio", "wav debug: writer task exited");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recorder_starts_and_stops_without_panic() {
|
||||
let rec =
|
||||
WavDebugRecorder::start(AudioRoute::Speaker, AudioBackend::PlatformVoiceProcessing);
|
||||
let frame = [0.1_f32; FRAME_10MS_SAMPLES];
|
||||
rec.push_raw_mic(&frame);
|
||||
rec.push_render_reference(&frame);
|
||||
rec.push_processed_mic(&frame);
|
||||
rec.stop();
|
||||
// Drop the Arc to let the writer task drain.
|
||||
drop(rec);
|
||||
// Give the writer thread a moment to finish.
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_count_increments_when_channel_full() {
|
||||
// Use a tiny channel by creating a recorder and flooding it.
|
||||
// We can't easily test the bounded channel directly, but we
|
||||
// can verify the drop counter starts at zero.
|
||||
let rec = WavDebugRecorder::start(AudioRoute::Speaker, AudioBackend::Noop);
|
||||
assert_eq!(rec.drop_count(), 0);
|
||||
rec.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_header_is_44_bytes() {
|
||||
// Write to a temp file to test the header.
|
||||
let tmp = std::env::temp_dir().join("chanora_test_wav_header.wav");
|
||||
let mut f = std::fs::File::create(&tmp).unwrap();
|
||||
write_wav_header(&mut f, 960).unwrap();
|
||||
drop(f);
|
||||
let data = std::fs::read(&tmp).unwrap();
|
||||
assert_eq!(data.len(), 44, "WAV header must be 44 bytes");
|
||||
assert_eq!(&data[0..4], b"RIFF");
|
||||
assert_eq!(&data[8..12], b"WAVE");
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
+233
-136
@@ -33,44 +33,21 @@ use tracing::{debug, info};
|
||||
))]
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[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<Arc<crate::TransmitModeSelector>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AudioEngineConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AudioEngineConfig")
|
||||
.field("mic_gain", &self.mic_gain)
|
||||
.field("ptt_initial", &self.ptt_initial)
|
||||
.field("effects", &self.effects)
|
||||
.field("mobile_voice_preset", &self.mobile_voice_preset)
|
||||
.field(
|
||||
"voice_activity_selector",
|
||||
&self.voice_activity_selector.as_ref().map(|_| "present"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AudioEngineConfig {
|
||||
@@ -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<AtomicBool>,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
mic_gain: f32,
|
||||
|
||||
// Streams must be dropped to stop audio. Both are `!Send` because
|
||||
// cpal's Stream isn't Send on some backends; we keep them in an
|
||||
@@ -177,7 +180,7 @@ pub struct AudioEngine {
|
||||
))]
|
||||
_output_stream: Mutex<Option<cpal::Stream>>,
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
_ios_voice_unit: Mutex<Option<crate::ios_voice_unit::IosVoiceUnit>>,
|
||||
_ios_voice_backend: Mutex<Option<IosVoiceBackend>>,
|
||||
/// SDD-111..SDD-115: Android Oboe voice backend. Owns the input
|
||||
/// and output streams, SDD-113 hardware-effect handles, and the
|
||||
/// foreground-service lifecycle; tearing it down on engine drop
|
||||
@@ -237,6 +240,105 @@ pub struct AudioEngine {
|
||||
unsafe impl Send for AudioEngine {}
|
||||
unsafe impl Sync for AudioEngine {}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
#[allow(dead_code)]
|
||||
enum IosVoiceBackend {
|
||||
Vpio(crate::ios_voice_unit::IosVoiceUnit),
|
||||
#[cfg(target_os = "ios")]
|
||||
Raw(crate::ios_raw_unit::IosRawUnit),
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
impl IosVoiceBackend {
|
||||
fn pause(&mut self) -> Result<(), AudioError> {
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
match self {
|
||||
Self::Vpio(unit) => unit.pause(),
|
||||
Self::Raw(unit) => unit.pause(),
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn resume(&mut self) -> Result<(), AudioError> {
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
match self {
|
||||
Self::Vpio(unit) => unit.resume(),
|
||||
Self::Raw(unit) => unit.resume(),
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_ios_voice_backend(
|
||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_flag_for_capture: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Result<IosVoiceBackend, AudioError> {
|
||||
let _cfg = audio_processing_config.lock().unwrap().clone();
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
if _cfg.ios_mode == crate::IosVoiceProcessingMode::SonoraExperimental {
|
||||
match crate::ios_raw_unit::IosRawUnit::start(
|
||||
handler.clone(),
|
||||
output_gain.clone(),
|
||||
output_muted.clone(),
|
||||
voice_out_tx.clone(),
|
||||
transmit_flag_for_capture.clone(),
|
||||
frames_sent.clone(),
|
||||
mic_gain,
|
||||
voice_activity_selector.clone(),
|
||||
audio_processing_config.clone(),
|
||||
audio_processing_stats.clone(),
|
||||
) {
|
||||
Ok(unit) => {
|
||||
info!(target: "chanora_audio", "ios: RemoteIO/Sonora experimental backend selected");
|
||||
return Ok(IosVoiceBackend::Raw(unit));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"ios: RemoteIO/Sonora backend failed; falling back to VoiceProcessingIO"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unit = crate::ios_voice_unit::IosVoiceUnit::start(
|
||||
handler,
|
||||
output_gain,
|
||||
output_muted,
|
||||
voice_out_tx,
|
||||
transmit_flag_for_capture,
|
||||
frames_sent,
|
||||
mic_gain,
|
||||
voice_activity_selector,
|
||||
audio_processing_config,
|
||||
audio_processing_stats,
|
||||
)?;
|
||||
Ok(IosVoiceBackend::Vpio(unit))
|
||||
}
|
||||
|
||||
impl AudioEngine {
|
||||
/// Start the engine: open capture + playback streams, spawn the
|
||||
/// inbound-voice forwarder, return a handle.
|
||||
@@ -259,6 +361,7 @@ impl AudioEngine {
|
||||
voice_in_rx: mpsc::Receiver<InboundVoice>,
|
||||
transmit_gate: crate::ptt::AudioTransmitGate,
|
||||
) -> Result<Self, AudioError> {
|
||||
#[allow(clippy::needless_return)]
|
||||
// Apple platforms route to a separate backend (VoiceProcessingIO
|
||||
// via coreaudio-rs) because cpal does not expose the native
|
||||
// voice-processing AudioUnit controls Chanora needs for VoIP.
|
||||
@@ -353,6 +456,8 @@ impl AudioEngine {
|
||||
let frames_received = Arc::new(AtomicU32::new(0));
|
||||
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
||||
let output_muted = Arc::new(AtomicBool::new(false));
|
||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
|
||||
// ---------- Capture ----------
|
||||
// Capture is best-effort. If the platform default input
|
||||
@@ -525,6 +630,8 @@ impl AudioEngine {
|
||||
frames_received,
|
||||
output_gain,
|
||||
output_muted,
|
||||
audio_processing_config,
|
||||
audio_processing_stats,
|
||||
_input_stream: Mutex::new(input_stream),
|
||||
_output_stream: Mutex::new(Some(output_stream)),
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
@@ -549,6 +656,8 @@ impl AudioEngine {
|
||||
let frames_received = Arc::new(AtomicU32::new(0));
|
||||
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
||||
let output_muted = Arc::new(AtomicBool::new(false));
|
||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
|
||||
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||
Arc::new(Mutex::new(AudioHandler::new()));
|
||||
@@ -678,6 +787,8 @@ impl AudioEngine {
|
||||
frames_received,
|
||||
output_gain,
|
||||
output_muted,
|
||||
audio_processing_config,
|
||||
audio_processing_stats,
|
||||
_android_voice_unit: Mutex::new(Some(android_voice_unit)),
|
||||
audio_mode_stack: Mutex::new(audio_mode_stack),
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
@@ -725,23 +836,26 @@ impl AudioEngine {
|
||||
let frames_received = Arc::new(AtomicU32::new(0));
|
||||
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
||||
let output_muted = Arc::new(AtomicBool::new(false));
|
||||
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
|
||||
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
|
||||
|
||||
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
|
||||
Arc::new(Mutex::new(AudioHandler::new()));
|
||||
let voice_out_tx_for_backend = voice_out_tx.clone();
|
||||
|
||||
// Construct the VPIO unit. Commit 1 ships a no-op callback
|
||||
// pair; commits 3 + 4 land the real capture + playback
|
||||
// wiring. Construction failure here is fatal (mirrors how
|
||||
// the cpal output-stream construction failure is fatal in
|
||||
// the non-iOS path).
|
||||
let ios_voice_unit = crate::ios_voice_unit::IosVoiceUnit::start(
|
||||
// Construct the live iOS voice backend. Platform VPIO stays
|
||||
// the default shipping path; Sonora/RemoteIO remains opt-in.
|
||||
let ios_voice_backend = open_ios_voice_backend(
|
||||
audio_handler.clone(),
|
||||
output_gain.clone(),
|
||||
output_muted.clone(),
|
||||
voice_out_tx,
|
||||
voice_out_tx_for_backend,
|
||||
transmit_flag_for_capture,
|
||||
frames_sent.clone(),
|
||||
cfg.mic_gain,
|
||||
cfg.voice_activity_selector.clone(),
|
||||
audio_processing_config.clone(),
|
||||
audio_processing_stats.clone(),
|
||||
)?;
|
||||
|
||||
// Capture is always considered active on iOS — VPIO's
|
||||
@@ -791,7 +905,13 @@ impl AudioEngine {
|
||||
frames_received,
|
||||
output_gain,
|
||||
output_muted,
|
||||
_ios_voice_unit: Mutex::new(Some(ios_voice_unit)),
|
||||
audio_processing_config,
|
||||
audio_processing_stats,
|
||||
audio_handler,
|
||||
voice_out_tx,
|
||||
voice_activity_selector: cfg.voice_activity_selector.clone(),
|
||||
mic_gain: cfg.mic_gain,
|
||||
_ios_voice_backend: Mutex::new(Some(ios_voice_backend)),
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
capture_active,
|
||||
ptt_watchdog,
|
||||
@@ -826,7 +946,7 @@ impl AudioEngine {
|
||||
}
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
{
|
||||
let _ = self._ios_voice_unit.lock().unwrap().take();
|
||||
let _ = self._ios_voice_backend.lock().unwrap().take();
|
||||
}
|
||||
// SDD-115 reverse-order teardown on Android:
|
||||
// 1) close the voice unit (releases SDD-113 hardware
|
||||
@@ -900,15 +1020,25 @@ impl AudioEngine {
|
||||
/// iOS-only: restart the underlying VoiceProcessingIO unit after
|
||||
/// route changes.
|
||||
pub fn ios_restart_voice_unit(&self) -> Result<(), AudioError> {
|
||||
#[cfg(target_os = "ios")]
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
{
|
||||
let mut guard = self._ios_voice_unit.lock().unwrap();
|
||||
let unit = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
|
||||
return unit.restart();
|
||||
let backend = open_ios_voice_backend(
|
||||
self.audio_handler.clone(),
|
||||
self.output_gain.clone(),
|
||||
self.output_muted.clone(),
|
||||
self.voice_out_tx.clone(),
|
||||
self.transmit_gate.flag_arc(),
|
||||
self.frames_sent.clone(),
|
||||
self.mic_gain,
|
||||
self.voice_activity_selector.clone(),
|
||||
self.audio_processing_config.clone(),
|
||||
self.audio_processing_stats.clone(),
|
||||
)?;
|
||||
let mut guard = self._ios_voice_backend.lock().unwrap();
|
||||
*guard = Some(backend);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
@@ -916,15 +1046,15 @@ impl AudioEngine {
|
||||
|
||||
/// iOS-only: pause the underlying VoiceProcessingIO unit.
|
||||
pub fn ios_pause_voice_unit(&self) -> Result<(), AudioError> {
|
||||
#[cfg(target_os = "ios")]
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
{
|
||||
let mut guard = self._ios_voice_unit.lock().unwrap();
|
||||
let mut guard = self._ios_voice_backend.lock().unwrap();
|
||||
let unit = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
|
||||
return unit.pause();
|
||||
.ok_or_else(|| AudioError::Backend("ios voice backend not running".to_string()))?;
|
||||
unit.pause()
|
||||
}
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
@@ -932,15 +1062,15 @@ impl AudioEngine {
|
||||
|
||||
/// iOS-only: resume the underlying VoiceProcessingIO unit.
|
||||
pub fn ios_resume_voice_unit(&self) -> Result<(), AudioError> {
|
||||
#[cfg(target_os = "ios")]
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
{
|
||||
let mut guard = self._ios_voice_unit.lock().unwrap();
|
||||
let mut guard = self._ios_voice_backend.lock().unwrap();
|
||||
let unit = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
|
||||
return unit.resume();
|
||||
.ok_or_else(|| AudioError::Backend("ios voice backend not running".to_string()))?;
|
||||
unit.resume()
|
||||
}
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
@@ -1014,6 +1144,29 @@ impl AudioEngine {
|
||||
self.frames_received.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Current audio-processing config snapshot.
|
||||
pub fn audio_processing_config_snapshot(&self) -> crate::AudioProcessingConfig {
|
||||
self.audio_processing_config.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Apply a voice-processing config after validating iOS invariants.
|
||||
pub fn set_audio_processing_config(
|
||||
&self,
|
||||
config: crate::AudioProcessingConfig,
|
||||
) -> Result<(), AudioError> {
|
||||
#[cfg(target_os = "ios")]
|
||||
config.validate_for_ios()?;
|
||||
let mut guard = self.audio_processing_config.lock().unwrap();
|
||||
*guard = config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current voice-processing stats snapshot.
|
||||
pub fn audio_processing_stats(&self) -> crate::AudioProcessingStats {
|
||||
let config = self.audio_processing_config.lock().unwrap().clone();
|
||||
self.audio_processing_stats.snapshot(&config)
|
||||
}
|
||||
|
||||
/// Latest Android voice-audio diagnostics snapshot (SDD-112 item
|
||||
/// 10 / SDD-113 item 7 / SDD-116 item 3). On non-Android targets
|
||||
/// this always returns `None`. On Android it returns `Some(...)`
|
||||
@@ -1091,58 +1244,7 @@ fn try_open_capture(
|
||||
in_stream_cfg.buffer_size = cpal::BufferSize::Default;
|
||||
}
|
||||
|
||||
let mut opus_enc = OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
|
||||
.map_err(|e| AudioError::Opus(format!("encoder new: {e}")))?;
|
||||
|
||||
// Opus VOIP tuning. Defaults give us 'auto' bitrate (can drop
|
||||
// to ~6 kbps during silence \u2014 which sounds garbled when
|
||||
// talking resumes) and inband FEC disabled. On lossy mobile
|
||||
// networks (cellular / iPhone WiFi roaming), packet loss
|
||||
// without FEC produces audible clicks + cut-out frames.
|
||||
//
|
||||
// Settings derived from the Opus IETF VoIP recommendations
|
||||
// (RFC 6716 \u00a7 7.1) and Discord's voice client tuning:
|
||||
//
|
||||
// * Bitrate 32 kbps : sweet spot for mono voice. Lower
|
||||
// than 24 kbps starts to sound watery; higher than
|
||||
// 64 kbps wastes bandwidth without perceptual gain on a
|
||||
// human voice. Discord uses 64 kbps; mumble defaults to
|
||||
// 40 kbps; we pick 32 kbps as a conservative VoIP value
|
||||
// that survives 100 kbps uplinks comfortably.
|
||||
// * Complexity 10 : max quality. The CPU cost on a modern
|
||||
// iPhone (A14+) or any desktop is negligible (~0.5 % of
|
||||
// a single core for 48 kHz mono).
|
||||
// * Inband FEC on : opus inserts a low-bitrate redundancy
|
||||
// copy of the previous frame inside the current packet
|
||||
// so a single dropped packet can be reconstructed from
|
||||
// the next one. Essential on lossy mobile.
|
||||
// * Packet loss perc 5 % : tells the encoder to expect 5 %
|
||||
// loss and pre-emptively budget bits for FEC. Higher
|
||||
// values trade audio quality for resilience.
|
||||
//
|
||||
// Errors here are non-fatal: log + continue. The encoder
|
||||
// works with defaults if any setter fails on an exotic
|
||||
// libopus build.
|
||||
if let Err(e) = opus_enc.set_bitrate(OpusBitrate::BitsPerSecond(32_000)) {
|
||||
warn!(target: "chanora_audio", error = %e, "opus: set_bitrate(32000) failed");
|
||||
}
|
||||
if let Err(e) = opus_enc.set_complexity(10) {
|
||||
warn!(target: "chanora_audio", error = %e, "opus: set_complexity(10) failed");
|
||||
}
|
||||
if let Err(e) = opus_enc.set_inband_fec(true) {
|
||||
warn!(target: "chanora_audio", error = %e, "opus: set_inband_fec(true) failed");
|
||||
}
|
||||
if let Err(e) = opus_enc.set_packet_loss_perc(5) {
|
||||
warn!(target: "chanora_audio", error = %e, "opus: set_packet_loss_perc(5) failed");
|
||||
}
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
bitrate_bps = 32_000,
|
||||
complexity = 10,
|
||||
inband_fec = true,
|
||||
packet_loss_perc = 5,
|
||||
"opus encoder tuned for VoIP"
|
||||
);
|
||||
let opus_enc = crate::opus_voice::new_voip_encoder("cpal capture")?;
|
||||
|
||||
let capture_state = Arc::new(Mutex::new(CaptureState::new(
|
||||
opus_enc,
|
||||
@@ -1184,7 +1286,7 @@ struct CaptureState {
|
||||
/// roughly at the period rate (~100 Hz for a 10 ms period on
|
||||
/// Linux ALSA defaults).
|
||||
resample_last: f32,
|
||||
opus_out: [u8; MAX_OPUS_FRAME],
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
/// The PTT transmission gate. Read once per outbound frame; the
|
||||
/// CaptureState never mutates this flag.
|
||||
@@ -1224,7 +1326,7 @@ impl CaptureState {
|
||||
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
|
||||
resample_pos: 0.0,
|
||||
resample_last: 0.0,
|
||||
opus_out: [0u8; MAX_OPUS_FRAME],
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
@@ -1310,22 +1412,21 @@ impl CaptureState {
|
||||
.encode_float(&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", "voice_out queue full; dropping frame");
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"voice_out queue full; dropping frame"
|
||||
);
|
||||
},
|
||||
|| {
|
||||
warn!(target: "chanora_audio", "voice_out closed; stopping send");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(target: "chanora_audio", error = %e, "opus encode failed");
|
||||
@@ -1851,10 +1952,7 @@ pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
#[doc(hidden)]
|
||||
pub mod bench_seam {
|
||||
use super::{
|
||||
Arc, AtomicBool, AtomicU32, CaptureState, OpusApp, OpusChannels, OpusEncoder,
|
||||
OpusSampleRate, OutPacket,
|
||||
};
|
||||
use super::{Arc, AtomicBool, AtomicU32, CaptureState, OpusEncoder, OutPacket};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Opaque handle wrapping a CaptureState plus the dummy mpsc
|
||||
@@ -1880,8 +1978,7 @@ pub mod bench_seam {
|
||||
/// (typically 1 or 2).
|
||||
pub fn new(in_sample_rate: u32, in_channels: usize) -> Self {
|
||||
let encoder =
|
||||
OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
|
||||
.expect("opus encoder init");
|
||||
crate::opus_voice::new_voip_encoder("cpal bench").expect("opus encoder init");
|
||||
let (tx, rx) = mpsc::channel::<OutPacket>(64);
|
||||
let transmit_active = Arc::new(AtomicBool::new(true));
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Canonical P1 voice frame helpers.
|
||||
//!
|
||||
//! The network contract remains 48 kHz mono, 20 ms Opus frames. P1
|
||||
//! processing works internally on 10 ms f32 frames so VAD and future
|
||||
//! processors can share a stable frame size without changing the
|
||||
//! transport layer.
|
||||
|
||||
/// P1 sample rate in Hz.
|
||||
pub const SAMPLE_RATE_HZ: u32 = 48_000;
|
||||
/// Network frame duration in milliseconds.
|
||||
pub const NETWORK_FRAME_MS: u32 = 20;
|
||||
/// Processing frame duration in milliseconds.
|
||||
pub const PROCESSING_FRAME_MS: u32 = 10;
|
||||
/// Samples in one 10 ms mono frame at 48 kHz.
|
||||
pub const FRAME_10MS_SAMPLES: usize = 480;
|
||||
/// Samples in one 20 ms mono frame at 48 kHz.
|
||||
pub const FRAME_20MS_SAMPLES: usize = 960;
|
||||
|
||||
/// 10 ms, 48 kHz, mono f32 processing frame.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AudioFrame10ms {
|
||||
/// Samples normalized to `[-1.0, 1.0]`.
|
||||
pub samples: [f32; FRAME_10MS_SAMPLES],
|
||||
}
|
||||
|
||||
/// 20 ms, 48 kHz, mono f32 network-frame-sized buffer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AudioFrame20ms {
|
||||
/// Samples normalized to `[-1.0, 1.0]`.
|
||||
pub samples: [f32; FRAME_20MS_SAMPLES],
|
||||
}
|
||||
|
||||
impl AudioFrame20ms {
|
||||
/// Convert one 20 ms frame into two 10 ms processing frames.
|
||||
pub fn split(&self) -> (AudioFrame10ms, AudioFrame10ms) {
|
||||
let mut first = [0.0; FRAME_10MS_SAMPLES];
|
||||
let mut second = [0.0; FRAME_10MS_SAMPLES];
|
||||
first.copy_from_slice(&self.samples[..FRAME_10MS_SAMPLES]);
|
||||
second.copy_from_slice(&self.samples[FRAME_10MS_SAMPLES..]);
|
||||
(
|
||||
AudioFrame10ms { samples: first },
|
||||
AudioFrame10ms { samples: second },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioFrame10ms {
|
||||
/// Merge two 10 ms processing frames back into the 20 ms network
|
||||
/// cadence used by the existing Opus path.
|
||||
pub fn merge(first: &Self, second: &Self) -> AudioFrame20ms {
|
||||
let mut samples = [0.0; FRAME_20MS_SAMPLES];
|
||||
samples[..FRAME_10MS_SAMPLES].copy_from_slice(&first.samples);
|
||||
samples[FRAME_10MS_SAMPLES..].copy_from_slice(&second.samples);
|
||||
AudioFrame20ms { samples }
|
||||
}
|
||||
|
||||
/// Compute RMS dBFS for diagnostics and fallback VAD.
|
||||
pub fn dbfs(&self) -> f32 {
|
||||
dbfs(&self.samples)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert i16 PCM to normalized f32 PCM.
|
||||
pub fn i16_to_f32(sample: i16) -> f32 {
|
||||
sample as f32 / i16::MAX as f32
|
||||
}
|
||||
|
||||
/// Convert normalized f32 PCM to saturated i16 PCM.
|
||||
pub fn f32_to_i16(sample: f32) -> i16 {
|
||||
(sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
|
||||
}
|
||||
|
||||
/// RMS dBFS for a normalized f32 slice. Silence returns `-120.0`.
|
||||
pub fn dbfs(samples: &[f32]) -> f32 {
|
||||
if samples.is_empty() {
|
||||
return -120.0;
|
||||
}
|
||||
let sum = samples.iter().map(|s| s * s).sum::<f32>();
|
||||
let rms = (sum / samples.len() as f32).sqrt();
|
||||
if rms <= 0.000_001 {
|
||||
-120.0
|
||||
} else {
|
||||
20.0 * rms.log10()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_merge_preserves_samples() {
|
||||
let mut samples = [0.0; FRAME_20MS_SAMPLES];
|
||||
for (i, s) in samples.iter_mut().enumerate() {
|
||||
*s = i as f32 / FRAME_20MS_SAMPLES as f32;
|
||||
}
|
||||
let original = AudioFrame20ms { samples };
|
||||
let (a, b) = original.split();
|
||||
assert_eq!(AudioFrame10ms::merge(&a, &b), original);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
//! Optional raw iOS RemoteIO path for the Sonora experimental mode.
|
||||
//!
|
||||
//! Provides an alternative to `ios_voice_unit.rs` for the
|
||||
//! `SonoraExperimental` processing mode. Instead of
|
||||
//! `kAudioUnitSubType_VoiceProcessingIO` (which owns AEC/NS/AGC), it
|
||||
//! opens `kAudioUnitSubType_RemoteIO` with voice processing explicitly
|
||||
//! disabled so Rust's Sonora DSP chain can own the full signal path.
|
||||
//!
|
||||
//! ## Hard invariants enforced here
|
||||
//!
|
||||
//! * INV_009: Rust AEC only active when platform AEC is disabled.
|
||||
//! * INV_010: VoiceProcessingIO and Sonora AEC3 are mutually exclusive.
|
||||
//! * INV_011: Software AEC backend receives both capture and render-reference.
|
||||
//! * INV_012: Render reference is copied from decoded/mixed remote PCM
|
||||
//! before playout.
|
||||
//!
|
||||
//! ## Fallback
|
||||
//!
|
||||
//! If RemoteIO construction fails, the caller falls back to `IosVoiceUnit`
|
||||
//! (VPIO) and logs the error.
|
||||
//!
|
||||
//! ## Status
|
||||
//!
|
||||
//! Experimental / disabled by default. Only activated when the user
|
||||
//! explicitly selects `SonoraExperimental` mode via the bridge API.
|
||||
//!
|
||||
//! ## Platform
|
||||
//!
|
||||
//! `kAudioUnitSubType_RemoteIO` is only available in the iOS SDK.
|
||||
//! This module is gated to `target_os = "ios"`.
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
pub use inner::IosRawUnit;
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
mod inner {
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use audiopus::coder::Encoder as OpusEncoder;
|
||||
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
||||
use coreaudio::audio_unit::render_callback::{self, data};
|
||||
use coreaudio::audio_unit::IOType;
|
||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{info, warn};
|
||||
use tsclientlib::audio::AudioHandler;
|
||||
|
||||
use crate::engine::SessionAudioId;
|
||||
use crate::processor::AudioProcessor;
|
||||
use crate::AudioError;
|
||||
use chanora_protocol::OutPacket;
|
||||
|
||||
const SAMPLE_RATE_HZ: f64 = 48_000.0;
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Render-reference ring buffer //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
/// 4-slot ring buffer shared between the render callback (writer) and
|
||||
/// the capture callback (reader for Sonora AEC3). Capacity: 4 × 10 ms
|
||||
/// = 40 ms of headroom.
|
||||
///
|
||||
/// If the capture callback runs before the render callback has written
|
||||
/// a frame it reads zeros (silence reference), which is safe — Sonora
|
||||
/// AEC3 simply skips cancellation for that frame.
|
||||
struct RenderReferenceBuffer {
|
||||
buf: Box<[[f32; 480]; 4]>,
|
||||
write_idx: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl RenderReferenceBuffer {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
buf: Box::new([[0.0; 480]; 4]),
|
||||
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Write one 10 ms render-reference frame. Realtime-safe.
|
||||
fn write(&self, frame: &[f32; 480]) {
|
||||
let idx = self.write_idx.load(Ordering::Relaxed);
|
||||
// SAFETY: only one writer (render callback); torn reads
|
||||
// are bounded to one frame of AEC degradation.
|
||||
unsafe {
|
||||
let slot = &self.buf[idx] as *const [f32; 480] as *mut [f32; 480];
|
||||
(*slot).copy_from_slice(frame);
|
||||
}
|
||||
self.write_idx.store((idx + 1) % 4, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read the most recently completed render-reference frame.
|
||||
fn read_latest(&self) -> [f32; 480] {
|
||||
let wi = self.write_idx.load(Ordering::Relaxed);
|
||||
let ri = (wi + 3) % 4;
|
||||
self.buf[ri]
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: accessed from two audio callback threads; data races are
|
||||
// bounded to one frame of AEC quality degradation.
|
||||
unsafe impl Send for RenderReferenceBuffer {}
|
||||
unsafe impl Sync for RenderReferenceBuffer {}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Capture pipeline state //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
struct RawCaptureState {
|
||||
encoder: OpusEncoder,
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
vad_state: crate::voice_activity::VoiceActivityStateMachine,
|
||||
/// Processing config — retained for route-change reloads; not read in the hot path.
|
||||
#[allow(dead_code)]
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
sonora_processor: crate::processor::SonoraProcessor,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
render_reference: Arc<RenderReferenceBuffer>,
|
||||
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: usize,
|
||||
wav_recorder: Option<Arc<crate::debug_wav::WavDebugRecorder>>,
|
||||
}
|
||||
|
||||
impl RawCaptureState {
|
||||
fn new(
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
render_reference: Arc<RenderReferenceBuffer>,
|
||||
) -> Result<Self, AudioError> {
|
||||
let encoder = crate::opus_voice::new_voip_encoder("ios raw")?;
|
||||
Ok(Self {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
mic_gain,
|
||||
voice_activity_selector,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
|
||||
audio_processing_config,
|
||||
sonora_processor: crate::processor::SonoraProcessor::with_config(
|
||||
crate::processor::sonora::SonoraConfig::with_aec3(),
|
||||
),
|
||||
audio_processing_stats,
|
||||
render_reference,
|
||||
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: 0,
|
||||
wav_recorder: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn disable_failed_vad_backend(&mut self, failed_backend: crate::VadBackend) {
|
||||
if let Ok(mut cfg) = self.audio_processing_config.try_lock() {
|
||||
let _ = cfg.disable_failed_vad_backend(failed_backend);
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest_i16(&mut self, samples: &[i16]) {
|
||||
// Accumulate into 10 ms frames for VAD / Sonora processing.
|
||||
let mut offset = 0;
|
||||
while offset < samples.len() {
|
||||
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
|
||||
let take = remaining.min(samples.len() - offset);
|
||||
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
|
||||
.copy_from_slice(&samples[offset..offset + take]);
|
||||
self.pending_10ms_len += take;
|
||||
offset += take;
|
||||
|
||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
let frame = self.pending_10ms;
|
||||
self.process_10ms_capture_frame(&frame);
|
||||
self.pending_10ms_len = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
self.pcm_accum.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Encode complete 20 ms Opus frames.
|
||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||
self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
|
||||
|
||||
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"ios raw: voice_out queue full; dropping frame"
|
||||
);
|
||||
},
|
||||
|| {},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(target: "chanora_audio",
|
||||
error = %e, "ios raw opus encode failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_10ms_capture_frame(
|
||||
&mut self,
|
||||
samples: &[i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
) {
|
||||
let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
|
||||
*dst = crate::frame::i16_to_f32(src);
|
||||
}
|
||||
let input_dbfs = crate::frame::dbfs(&frame);
|
||||
|
||||
// WAV tap: raw mic (before processing).
|
||||
if let Some(ref rec) = self.wav_recorder {
|
||||
rec.push_raw_mic(&frame);
|
||||
}
|
||||
|
||||
// INV_012: feed render reference to Sonora AEC3 before capture.
|
||||
let render_ref = self.render_reference.read_latest();
|
||||
self.sonora_processor.process_render(&render_ref);
|
||||
self.sonora_processor.process_capture(&mut frame);
|
||||
|
||||
// WAV tap: processed mic (after Sonora).
|
||||
if let Some(ref rec) = self.wav_recorder {
|
||||
rec.push_processed_mic(&frame);
|
||||
}
|
||||
|
||||
let (vad_backend, vad_hangover) = self
|
||||
.audio_processing_config
|
||||
.try_lock()
|
||||
.map(|cfg| (cfg.vad_backend, cfg.vad_hangover_ms))
|
||||
.unwrap_or((
|
||||
crate::VadBackend::WebrtcVad,
|
||||
crate::voice_activity::VAD_HANGOVER_MS,
|
||||
));
|
||||
self.vad_state.configure(
|
||||
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
||||
vad_hangover,
|
||||
crate::voice_activity::VAD_MIN_TX_MS,
|
||||
);
|
||||
let mut used_fallback_vad = false;
|
||||
let vad = if vad_backend == crate::VadBackend::Disabled {
|
||||
crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = matches!(
|
||||
vad_backend,
|
||||
crate::VadBackend::SileroOnnx | crate::VadBackend::TenVad
|
||||
);
|
||||
if used_fallback_vad {
|
||||
self.disable_failed_vad_backend(vad_backend);
|
||||
}
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
};
|
||||
self.audio_processing_stats
|
||||
.set_vad_fallback_active(used_fallback_vad);
|
||||
let active = self.vad_state.update(vad.speech);
|
||||
if let Some(sel) = &self.voice_activity_selector {
|
||||
sel.set_voice_activity_open(active);
|
||||
}
|
||||
self.audio_processing_stats.update_capture(
|
||||
input_dbfs,
|
||||
crate::frame::dbfs(&frame),
|
||||
vad.probability,
|
||||
active,
|
||||
self.transmit_active.load(Ordering::Relaxed),
|
||||
);
|
||||
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let gain = self.mic_gain;
|
||||
if (gain - 1.0).abs() < f32::EPSILON {
|
||||
self.pcm_accum
|
||||
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
||||
} else {
|
||||
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
||||
let scaled = crate::frame::f32_to_i16(s) as f32 * gain;
|
||||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// IosRawUnit //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
/// Raw iOS RemoteIO audio unit for the Sonora experimental path.
|
||||
pub struct IosRawUnit {
|
||||
unit: AudioUnit,
|
||||
}
|
||||
|
||||
impl IosRawUnit {
|
||||
/// Open a RemoteIO AudioUnit, install render + input callbacks, start.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn start(
|
||||
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Result<Self, AudioError> {
|
||||
// INV_010: reject if config requests VPIO (that's IosVoiceUnit's job).
|
||||
{
|
||||
let cfg = audio_processing_config.lock().unwrap();
|
||||
if cfg.ios_mode == crate::IosVoiceProcessingMode::PlatformVoiceProcessing {
|
||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||
"IosRawUnit requires SonoraExperimental mode".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut unit = AudioUnit::new_uninitialized(IOType::RemoteIO)
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio new: {e}")))?;
|
||||
|
||||
// Enable input on bus 1.
|
||||
const ENABLE_IO: u32 = 2003;
|
||||
let enable: u32 = 1;
|
||||
unit.set_property(ENABLE_IO, Scope::Input, Element::Input, Some(&enable))
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio enable input: {e}")))?;
|
||||
|
||||
// 48 kHz Int16 mono on both buses.
|
||||
let fmt = StreamFormat {
|
||||
sample_rate: SAMPLE_RATE_HZ,
|
||||
sample_format: SampleFormat::I16,
|
||||
flags: LinearPcmFlags::IS_SIGNED_INTEGER | LinearPcmFlags::IS_PACKED,
|
||||
channels: 1,
|
||||
};
|
||||
unit.set_stream_format(fmt, Scope::Input, Element::Output)
|
||||
.map_err(|e| AudioError::StreamConfig(format!("remoteio fmt output: {e}")))?;
|
||||
unit.set_stream_format(fmt, Scope::Output, Element::Input)
|
||||
.map_err(|e| AudioError::StreamConfig(format!("remoteio fmt input: {e}")))?;
|
||||
|
||||
// Shared render-reference buffer (INV_011 / INV_012).
|
||||
let render_ref_buf = RenderReferenceBuffer::new();
|
||||
let render_ref_for_capture = render_ref_buf.clone();
|
||||
|
||||
let mut capture_state = RawCaptureState::new(
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
mic_gain,
|
||||
voice_activity_selector,
|
||||
audio_processing_config,
|
||||
audio_processing_stats.clone(),
|
||||
render_ref_for_capture,
|
||||
)?;
|
||||
|
||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
capture_state.ingest_i16(args.data.buffer);
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
|
||||
|
||||
let mut scratch: Vec<f32> = Vec::with_capacity(2048);
|
||||
let stats_render = audio_processing_stats.clone();
|
||||
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let out = args.data.buffer;
|
||||
let n = out.len();
|
||||
let stereo_n = n * 2;
|
||||
if scratch.len() < stereo_n {
|
||||
scratch.resize(stereo_n, 0.0);
|
||||
}
|
||||
scratch[..stereo_n].fill(0.0);
|
||||
|
||||
match handler.try_lock() {
|
||||
Ok(mut h) => {
|
||||
let _ = h.fill_buffer(&mut scratch[..stereo_n]);
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
stats_render.increment_callback_xrun();
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
warn!(target: "chanora_audio",
|
||||
"AudioHandler poisoned (raw render): {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// INV_012: copy render reference BEFORE playout.
|
||||
let mono_n = n.min(480);
|
||||
let mut ref_frame = [0.0_f32; 480];
|
||||
crate::voice_render::downmix_stereo_f32_to_mono_f32(
|
||||
&scratch[..stereo_n],
|
||||
&mut ref_frame[..mono_n],
|
||||
);
|
||||
render_ref_buf.write(&ref_frame);
|
||||
|
||||
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
||||
let muted = output_muted.load(Ordering::Relaxed);
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
||||
&scratch[..stereo_n],
|
||||
out,
|
||||
gain,
|
||||
muted,
|
||||
);
|
||||
if mix_stats.clipped_samples > 0 {
|
||||
stats_render.add_clipped_samples(mix_stats.clipped_samples);
|
||||
}
|
||||
stats_render.update_render(crate::frame::dbfs(&scratch[..stereo_n]), n as u32);
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio render cb: {e}")))?;
|
||||
|
||||
unit.initialize()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio init: {e}")))?;
|
||||
unit.start()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio start: {e}")))?;
|
||||
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
sample_rate_hz = SAMPLE_RATE_HZ,
|
||||
"ios RemoteIO (Sonora experimental) started"
|
||||
);
|
||||
Ok(Self { unit })
|
||||
}
|
||||
|
||||
/// Restart the unit after a route change (stop → uninit → init → start).
|
||||
pub fn restart(&mut self) -> Result<(), AudioError> {
|
||||
self.unit
|
||||
.stop()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio restart stop: {e}")))?;
|
||||
self.unit
|
||||
.uninitialize()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio restart uninit: {e}")))?;
|
||||
self.unit
|
||||
.initialize()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio restart init: {e}")))?;
|
||||
self.unit
|
||||
.start()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio restart start: {e}")))?;
|
||||
info!(target: "chanora_audio", "ios RemoteIO restarted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pause the unit during an AVAudioSession interruption.
|
||||
pub fn pause(&mut self) -> Result<(), AudioError> {
|
||||
self.unit
|
||||
.stop()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio pause: {e}")))
|
||||
}
|
||||
|
||||
/// Resume the unit after an interruption ends.
|
||||
pub fn resume(&mut self) -> Result<(), AudioError> {
|
||||
self.unit
|
||||
.start()
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio resume: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IosRawUnit {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.unit.stop() {
|
||||
warn!(target: "chanora_audio", error = %e,
|
||||
"ios RemoteIO stop on drop failed");
|
||||
} else {
|
||||
info!(target: "chanora_audio", "ios RemoteIO stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,10 +75,6 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use audiopus::coder::Encoder as OpusEncoder;
|
||||
use audiopus::{
|
||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
||||
SampleRate as OpusSampleRate,
|
||||
};
|
||||
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
||||
use coreaudio::audio_unit::render_callback::{self, data};
|
||||
use coreaudio::audio_unit::IOType;
|
||||
@@ -89,22 +85,7 @@ use tsclientlib::audio::AudioHandler;
|
||||
|
||||
use crate::engine::SessionAudioId;
|
||||
use crate::AudioError;
|
||||
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
||||
|
||||
/// 20 ms at 48 kHz mono — one Opus frame's worth of samples.
|
||||
/// Aligning the AudioUnit IO buffer to this frame size keeps the
|
||||
/// jitter-buffer / encoder handshake tight (no fractional-frame
|
||||
/// reads inside fill_buffer or accumulator drift inside the
|
||||
/// capture pipeline).
|
||||
const FRAME_SAMPLES_MONO: usize = 960;
|
||||
|
||||
/// Maximum size of an encoded Opus frame in bytes (per RFC 6716
|
||||
/// §3.2.1). Same constant the cpal-side `CaptureState` uses; we
|
||||
/// duplicate it here instead of cross-importing from engine.rs
|
||||
/// because engine.rs's copy is cfg-gated to non-iOS for cpal-only
|
||||
/// reasons. Post-step-5 review may dedupe by promoting both to a
|
||||
/// shared `crate::framing` module.
|
||||
const MAX_OPUS_FRAME: usize = 1275;
|
||||
use chanora_protocol::OutPacket;
|
||||
|
||||
/// Sample rate every layer above us assumes. Matches the Opus
|
||||
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
|
||||
@@ -124,6 +105,11 @@ const OUTPUT_BUS: Element = Element::Output;
|
||||
/// samples in.
|
||||
const INPUT_BUS: Element = Element::Input;
|
||||
|
||||
/// Pre-roll buffer capacity: 160 ms / 10 ms = 16 frames.
|
||||
/// Stores processed i16 frames so the first syllable is not lost
|
||||
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
|
||||
const PRE_ROLL_FRAMES: usize = 16;
|
||||
|
||||
/// Capture pipeline state owned by the VPIO input callback. The
|
||||
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
|
||||
/// downmix or resample needed — VPIO's hardware-side mix-down
|
||||
@@ -146,17 +132,36 @@ const INPUT_BUS: Element = Element::Input;
|
||||
/// shared with `AudioEngine`.
|
||||
struct IosCaptureState {
|
||||
encoder: OpusEncoder,
|
||||
/// 48 kHz mono PCM scratch accumulating to FRAME_SAMPLES_MONO
|
||||
/// 48 kHz mono PCM scratch accumulating to FRAME_20MS_SAMPLES
|
||||
/// per encode. Capacity 2x to absorb cpal-style buffer-size
|
||||
/// jitter without reallocating.
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; MAX_OPUS_FRAME],
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
/// PTT transmission gate. Read once per outbound frame; this
|
||||
/// struct never mutates the flag (SAD-075 / SDD-089).
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
/// Background Silero worker — enqueues frames off the realtime
|
||||
/// callback and publishes the latest probability atomically.
|
||||
silero_vad_worker: Option<crate::vad::silero_onnx::SileroOnnxVadWorker>,
|
||||
/// Last VAD backend we configured — used to detect backend changes.
|
||||
current_vad_backend: crate::VadBackend,
|
||||
/// Last observed configured Silero model epoch.
|
||||
silero_model_epoch: u64,
|
||||
vad_state: crate::voice_activity::VoiceActivityStateMachine,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
sonora_processor: crate::processor::SonoraProcessor,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: usize,
|
||||
pre_roll_buf: [[i16; crate::frame::FRAME_10MS_SAMPLES]; PRE_ROLL_FRAMES],
|
||||
pre_roll_head: usize,
|
||||
pre_roll_count: usize,
|
||||
pre_roll_flushed: bool,
|
||||
capture_frame_seq: u64,
|
||||
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
||||
}
|
||||
|
||||
impl IosCaptureState {
|
||||
@@ -164,54 +169,55 @@ impl IosCaptureState {
|
||||
/// Encoder configuration is the same as cpal-side
|
||||
/// `try_open_capture` (engine.rs) so audio quality is platform-
|
||||
/// neutral.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
||||
) -> Result<Self, AudioError> {
|
||||
let mut encoder =
|
||||
OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
|
||||
.map_err(|e| AudioError::Opus(format!("encoder new (ios): {e}")))?;
|
||||
|
||||
// VoIP-tuned settings — bitrate 32 kbps, complexity 10,
|
||||
// inband FEC on, packet-loss-perc 5. Soft-fail each setter
|
||||
// with a warn log to match the cpal-side behaviour: an
|
||||
// unusual libopus build that rejects one setter shouldn't
|
||||
// tank the whole pipeline. Full rationale + RFC citations
|
||||
// are in engine.rs::try_open_capture line ~640.
|
||||
if let Err(e) = encoder.set_bitrate(OpusBitrate::BitsPerSecond(32_000)) {
|
||||
warn!(target: "chanora_audio", error = %e, "opus(ios): set_bitrate(32000) failed");
|
||||
}
|
||||
if let Err(e) = encoder.set_complexity(10) {
|
||||
warn!(target: "chanora_audio", error = %e, "opus(ios): set_complexity(10) failed");
|
||||
}
|
||||
if let Err(e) = encoder.set_inband_fec(true) {
|
||||
warn!(target: "chanora_audio", error = %e, "opus(ios): set_inband_fec(true) failed");
|
||||
}
|
||||
if let Err(e) = encoder.set_packet_loss_perc(5) {
|
||||
warn!(target: "chanora_audio", error = %e, "opus(ios): set_packet_loss_perc(5) failed");
|
||||
}
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
bitrate_bps = 32_000,
|
||||
complexity = 10,
|
||||
inband_fec = true,
|
||||
packet_loss_perc = 5,
|
||||
"ios VPIO opus encoder tuned for VoIP"
|
||||
);
|
||||
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
|
||||
|
||||
Ok(Self {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(FRAME_SAMPLES_MONO * 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,
|
||||
mic_gain,
|
||||
voice_activity_selector,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||
silero_vad_worker: None,
|
||||
current_vad_backend: crate::VadBackend::WebrtcVad,
|
||||
silero_model_epoch: crate::vad::silero_model_epoch(),
|
||||
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
|
||||
audio_processing_config,
|
||||
sonora_processor: crate::processor::SonoraProcessor::new(),
|
||||
audio_processing_stats,
|
||||
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: 0,
|
||||
pre_roll_buf: [[0_i16; crate::frame::FRAME_10MS_SAMPLES]; PRE_ROLL_FRAMES],
|
||||
pre_roll_head: 0,
|
||||
pre_roll_count: 0,
|
||||
pre_roll_flushed: false,
|
||||
capture_frame_seq: 0,
|
||||
wav_recorder,
|
||||
})
|
||||
}
|
||||
|
||||
fn disable_failed_vad_backend(&mut self, failed_backend: crate::VadBackend) {
|
||||
if let Ok(mut cfg) = self.audio_processing_config.try_lock() {
|
||||
if cfg.disable_failed_vad_backend(failed_backend) {
|
||||
self.current_vad_backend = crate::VadBackend::WebrtcVad;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the i16 mono buffer delivered by VPIO, accumulate
|
||||
/// to a 20 ms frame boundary, encode + send when PTT is held.
|
||||
///
|
||||
@@ -220,6 +226,22 @@ impl IosCaptureState {
|
||||
/// In practice "interleaved mono" is the same byte layout as
|
||||
/// "planar mono" so we just take the buffer as-is.
|
||||
fn ingest_i16(&mut self, samples: &[i16]) {
|
||||
let mut offset = 0;
|
||||
while offset < samples.len() {
|
||||
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
|
||||
let take = remaining.min(samples.len() - offset);
|
||||
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
|
||||
.copy_from_slice(&samples[offset..offset + take]);
|
||||
self.pending_10ms_len += take;
|
||||
offset += take;
|
||||
|
||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
let frame = self.pending_10ms;
|
||||
self.process_10ms_capture_frame(&frame);
|
||||
self.pending_10ms_len = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||
// Drain accumulator while muted so we don't pop on the
|
||||
// PTT release edge. Matches cpal-side behaviour.
|
||||
@@ -227,63 +249,40 @@ impl IosCaptureState {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mic-gain application. When gain==1.0 we skip the
|
||||
// multiply + saturate loop entirely — that's the common
|
||||
// case and the loop is the inner-most hot path of the
|
||||
// realtime audio thread.
|
||||
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
||||
self.pcm_accum.extend_from_slice(samples);
|
||||
} else {
|
||||
let gain = self.mic_gain;
|
||||
self.pcm_accum.extend(samples.iter().map(|&s| {
|
||||
// Saturating mul-then-cast keeps the signal in
|
||||
// the i16 envelope. Clipping in this branch is
|
||||
// expected — if the user pushed mic_gain past 1.0
|
||||
// and is shouting, the alternative is wrap-around
|
||||
// distortion which sounds far worse.
|
||||
let scaled = (s as f32) * gain;
|
||||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
||||
}));
|
||||
}
|
||||
|
||||
// Drain complete 20 ms frames out of the accumulator, encode
|
||||
// each, send the resulting Opus packet on the protocol
|
||||
// queue. The `while` covers the case where a single VPIO
|
||||
// callback delivers more than one frame's worth (rare on
|
||||
// iOS where the HW IO buffer duration aligns with the
|
||||
// Opus frame, but always possible during route changes).
|
||||
while self.pcm_accum.len() >= FRAME_SAMPLES_MONO {
|
||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||
// Use a stack-allocated frame buffer to avoid the
|
||||
// per-callback allocation a `drain(..N).collect()`
|
||||
// would incur. The encoder doesn't need ownership.
|
||||
let mut frame = [0i16; FRAME_SAMPLES_MONO];
|
||||
frame.copy_from_slice(&self.pcm_accum[..FRAME_SAMPLES_MONO]);
|
||||
self.pcm_accum.drain(..FRAME_SAMPLES_MONO);
|
||||
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(_)) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"ios VPIO: voice_out queue full; dropping frame"
|
||||
);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
},
|
||||
|| {
|
||||
debug!(
|
||||
target: "chanora_audio",
|
||||
"ios VPIO: voice_out closed; capture pipeline stopping"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(target: "chanora_audio", error = %e, "ios VPIO opus encode failed");
|
||||
@@ -291,6 +290,260 @@ impl IosCaptureState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_10ms_capture_frame(&mut self, samples: &[i16; crate::frame::FRAME_10MS_SAMPLES]) {
|
||||
let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
|
||||
*dst = crate::frame::i16_to_f32(src);
|
||||
}
|
||||
let input_dbfs = crate::frame::dbfs(&frame);
|
||||
|
||||
// WAV tap: raw mic (before processing, DIAG_002).
|
||||
if let Ok(guard) = self.wav_recorder.try_lock() {
|
||||
if let Some(rec) = guard.as_ref() {
|
||||
rec.push_raw_mic(&frame);
|
||||
}
|
||||
}
|
||||
|
||||
// Read config once per frame (try_lock: non-blocking, falls back to
|
||||
// last-known values if the lock is contended — safe to miss one frame).
|
||||
let (
|
||||
run_ns,
|
||||
run_agc,
|
||||
run_hpf,
|
||||
vad_backend,
|
||||
vad_hangover,
|
||||
debug_wav_dump_enabled,
|
||||
route,
|
||||
processing_backend,
|
||||
) = self
|
||||
.audio_processing_config
|
||||
.try_lock()
|
||||
.map(|cfg| {
|
||||
let ns =
|
||||
cfg.ns != crate::EffectOwner::Off && cfg.ns != crate::EffectOwner::Platform;
|
||||
let agc =
|
||||
cfg.agc != crate::EffectOwner::Off && cfg.agc != crate::EffectOwner::Platform;
|
||||
let hpf = cfg.hpf_enabled;
|
||||
(
|
||||
ns,
|
||||
agc,
|
||||
hpf,
|
||||
cfg.vad_backend,
|
||||
cfg.vad_hangover_ms,
|
||||
cfg.debug_wav_dump_enabled,
|
||||
cfg.route,
|
||||
cfg.processing_backend,
|
||||
)
|
||||
})
|
||||
.unwrap_or((
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
crate::VadBackend::SileroOnnx,
|
||||
crate::voice_activity::VAD_HANGOVER_MS,
|
||||
false,
|
||||
crate::AudioRoute::Unknown,
|
||||
crate::AudioBackend::PlatformVoiceProcessing,
|
||||
));
|
||||
|
||||
// Switch VAD backend when the config changes.
|
||||
let silero_model_epoch = crate::vad::silero_model_epoch();
|
||||
let silero_model_changed = vad_backend == crate::VadBackend::SileroOnnx
|
||||
&& silero_model_epoch != self.silero_model_epoch;
|
||||
|
||||
if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() {
|
||||
if debug_wav_dump_enabled {
|
||||
if recorder_guard.is_none() {
|
||||
*recorder_guard = Some(crate::debug_wav::WavDebugRecorder::start(
|
||||
route,
|
||||
processing_backend,
|
||||
));
|
||||
}
|
||||
} else if let Some(recorder) = recorder_guard.take() {
|
||||
recorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
if vad_backend != self.current_vad_backend || silero_model_changed {
|
||||
self.current_vad_backend = vad_backend;
|
||||
self.silero_model_epoch = silero_model_epoch;
|
||||
match vad_backend {
|
||||
crate::VadBackend::SileroOnnx => {
|
||||
// Attempt to load Silero model from the well-known
|
||||
// bundle path. The actual inference runs on a
|
||||
// background worker; the callback only enqueues
|
||||
// 10 ms frames and falls back to WebRTC if the
|
||||
// worker is missing or stale.
|
||||
let model_path = crate::vad::silero_model_bundle_path();
|
||||
self.silero_vad_worker =
|
||||
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&model_path);
|
||||
if self.silero_vad_worker.is_none() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"Silero VAD model not found at {model_path}; falling back to WebRTC VAD"
|
||||
);
|
||||
self.disable_failed_vad_backend(crate::VadBackend::SileroOnnx);
|
||||
}
|
||||
self.audio_processing_stats
|
||||
.set_vad_fallback_active(self.silero_vad_worker.is_none());
|
||||
}
|
||||
crate::VadBackend::TenVad => {
|
||||
self.silero_vad_worker = None;
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"TEN VAD selected but native TEN runtime is not bundled; falling back to WebRTC VAD"
|
||||
);
|
||||
self.disable_failed_vad_backend(crate::VadBackend::TenVad);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
}
|
||||
_ => {
|
||||
self.silero_vad_worker = None;
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
}
|
||||
// Reset VAD state machine timers on backend switch.
|
||||
self.vad_state = crate::voice_activity::VoiceActivityStateMachine::new(
|
||||
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
||||
vad_hangover,
|
||||
crate::voice_activity::VAD_MIN_TX_MS,
|
||||
);
|
||||
self.vad_state.reset();
|
||||
}
|
||||
|
||||
// Keep the VAD state machine aligned with the active config.
|
||||
self.vad_state.configure(
|
||||
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
||||
vad_hangover,
|
||||
crate::voice_activity::VAD_MIN_TX_MS,
|
||||
);
|
||||
let transmit_active = self.transmit_active.load(Ordering::Relaxed);
|
||||
|
||||
// Apply the enabled stages through the SonoraProcessor.
|
||||
// We reconfigure it on-the-fly to match the current settings.
|
||||
if run_ns || run_agc || run_hpf {
|
||||
use crate::processor::sonora::SonoraConfig;
|
||||
use crate::processor::AudioProcessor;
|
||||
let new_cfg = SonoraConfig {
|
||||
hpf: run_hpf,
|
||||
aec3: false, // NEVER in VPIO path (INV_009)
|
||||
ns: run_ns,
|
||||
agc2: run_agc,
|
||||
};
|
||||
if new_cfg != *self.sonora_processor.config() {
|
||||
self.sonora_processor.apply_config(new_cfg);
|
||||
}
|
||||
self.sonora_processor.process_capture(&mut frame);
|
||||
}
|
||||
|
||||
// VAD: use Silero if loaded, otherwise WebRTC fallback.
|
||||
// Disabled backend → always open (Continuous-like for VAD mode).
|
||||
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
|
||||
let capture_seq = self.capture_frame_seq;
|
||||
let mut used_fallback_vad = false;
|
||||
let vad = if vad_backend == crate::VadBackend::Disabled {
|
||||
crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
if let Some(worker) = self.silero_vad_worker.as_ref() {
|
||||
if worker.try_send(capture_seq, &frame) && !worker.is_stale(capture_seq) {
|
||||
let probability = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability,
|
||||
speech: probability >= 0.5,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.silero_vad_worker = None;
|
||||
self.disable_failed_vad_backend(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.disable_failed_vad_backend(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::TenVad {
|
||||
used_fallback_vad = true;
|
||||
self.disable_failed_vad_backend(crate::VadBackend::TenVad);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
};
|
||||
self.audio_processing_stats
|
||||
.set_vad_fallback_active(used_fallback_vad);
|
||||
let gate_open = self.vad_state.update(vad.speech);
|
||||
if let Some(selector) = &self.voice_activity_selector {
|
||||
selector.set_voice_activity_open(gate_open);
|
||||
}
|
||||
self.audio_processing_stats.update_capture(
|
||||
input_dbfs,
|
||||
crate::frame::dbfs(&frame),
|
||||
vad.probability,
|
||||
gate_open,
|
||||
transmit_active,
|
||||
);
|
||||
|
||||
// WAV tap: processed mic (after Rust DSP, DIAG_002).
|
||||
if let Ok(guard) = self.wav_recorder.try_lock() {
|
||||
if let Some(rec) = guard.as_ref() {
|
||||
rec.push_processed_mic(&frame);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to i16 for accumulation.
|
||||
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
||||
for (dst, src) in pcm_frame.iter_mut().zip(frame.iter().copied()) {
|
||||
*dst = crate::frame::f32_to_i16(src);
|
||||
}
|
||||
} else {
|
||||
let gain = self.mic_gain;
|
||||
for (dst, src) in pcm_frame.iter_mut().zip(frame.iter().copied()) {
|
||||
let scaled = crate::frame::f32_to_i16(src) as f32 * gain;
|
||||
*dst = scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||
}
|
||||
}
|
||||
|
||||
// Update pre-roll ring buffer (VAD_004: preserve first syllable).
|
||||
let slot_idx = self.pre_roll_head % PRE_ROLL_FRAMES;
|
||||
self.pre_roll_buf[slot_idx] = pcm_frame;
|
||||
self.pre_roll_head = (self.pre_roll_head + 1) % PRE_ROLL_FRAMES;
|
||||
if self.pre_roll_count < PRE_ROLL_FRAMES {
|
||||
self.pre_roll_count += 1;
|
||||
}
|
||||
|
||||
// If the transmit gate just opened and we haven't flushed the
|
||||
// pre-roll yet, drain it into the accumulator.
|
||||
if transmit_active && !self.pre_roll_flushed {
|
||||
self.pre_roll_flushed = true;
|
||||
// The oldest frame in the ring is at
|
||||
// (pre_roll_head + PRE_ROLL_FRAMES - pre_roll_count) % PRE_ROLL_FRAMES.
|
||||
// We emit frames in chronological order (oldest first), excluding
|
||||
// the frame we just wrote (which goes into pcm_accum normally below).
|
||||
let oldest =
|
||||
(self.pre_roll_head + PRE_ROLL_FRAMES - self.pre_roll_count) % PRE_ROLL_FRAMES;
|
||||
// Emit pre_roll_count - 1 frames (the -1 excludes the current frame
|
||||
// which will be added below in the normal path).
|
||||
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
|
||||
for i in 0..pre_roll_to_emit {
|
||||
let idx = (oldest + i) % PRE_ROLL_FRAMES;
|
||||
self.pcm_accum.extend_from_slice(&self.pre_roll_buf[idx]);
|
||||
}
|
||||
} else if !transmit_active {
|
||||
// Gate closed — reset the flush flag so pre-roll fires again
|
||||
// on the next gate open.
|
||||
self.pre_roll_flushed = false;
|
||||
}
|
||||
|
||||
if !transmit_active {
|
||||
return;
|
||||
}
|
||||
|
||||
self.pcm_accum.extend_from_slice(&pcm_frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// Live iOS audio unit wrapper. Construct + start = audio
|
||||
@@ -339,6 +592,9 @@ impl IosVoiceUnit {
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Result<Self, AudioError> {
|
||||
// Construct the VoiceProcessingIO AudioUnit. cpal exposes
|
||||
// `Default::default()` which on iOS picks the inferior
|
||||
@@ -430,8 +686,27 @@ impl IosVoiceUnit {
|
||||
// scratch are owned by the closure — no Mutex needed
|
||||
// because the input callback is the sole writer/reader on
|
||||
// the audio thread.
|
||||
let mut capture_state =
|
||||
IosCaptureState::new(voice_out_tx, transmit_active, frames_sent, mic_gain)?;
|
||||
let wav_recorder = Arc::new(Mutex::new({
|
||||
let cfg = audio_processing_config.lock().unwrap().clone();
|
||||
if cfg.debug_wav_dump_enabled {
|
||||
Some(crate::debug_wav::WavDebugRecorder::start(
|
||||
cfg.route,
|
||||
cfg.processing_backend,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}));
|
||||
let mut capture_state = IosCaptureState::new(
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
mic_gain,
|
||||
voice_activity_selector,
|
||||
audio_processing_config,
|
||||
audio_processing_stats.clone(),
|
||||
wav_recorder.clone(),
|
||||
)?;
|
||||
|
||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
// VPIO with our pinned stream format delivers
|
||||
@@ -499,12 +774,16 @@ impl IosVoiceUnit {
|
||||
let handler_for_render = handler.clone();
|
||||
let output_gain_for_render = output_gain.clone();
|
||||
let output_muted_for_render = output_muted.clone();
|
||||
let wav_recorder_for_render = wav_recorder.clone();
|
||||
// Diagnostic counters (sampled every 100 callbacks ~= 2 s).
|
||||
let mut cb_count: u64 = 0;
|
||||
let mut last_num_frames: usize = 0;
|
||||
let mut num_frames_changes: u32 = 0;
|
||||
let mut callbacks_with_audio: u64 = 0;
|
||||
let mut callbacks_with_silence: u64 = 0;
|
||||
let mut render_ref_accum = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut render_ref_len: usize = 0;
|
||||
let mut render_recorder_active = false;
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let out: &mut [i16] = args.data.buffer;
|
||||
let num_frames = out.len();
|
||||
@@ -527,6 +806,7 @@ impl IosVoiceUnit {
|
||||
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
audio_processing_stats.increment_callback_xrun();
|
||||
// scratch_stereo is already zeroed above.
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
@@ -535,34 +815,52 @@ impl IosVoiceUnit {
|
||||
}
|
||||
}
|
||||
|
||||
// Downmix stereo f32 -> mono i16 with master gain.
|
||||
// (l + r) * 0.5 preserves total signal energy with
|
||||
// 3 dB headroom against sum-of-correlated-peaks
|
||||
// clipping. Hard-clip i16 cast at the boundary.
|
||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||
let mut peak_out: i16 = 0;
|
||||
for (i, dst) in out.iter_mut().enumerate() {
|
||||
if muted {
|
||||
*dst = 0;
|
||||
continue;
|
||||
}
|
||||
let l = scratch_stereo[i * 2];
|
||||
let r = scratch_stereo[i * 2 + 1];
|
||||
let mono_f32 = (l + r) * 0.5 * gain;
|
||||
let clamped = mono_f32.clamp(-1.0, 1.0);
|
||||
let sample = (clamped * i16::MAX as f32) as i16;
|
||||
*dst = sample;
|
||||
let a = sample.unsigned_abs() as i16;
|
||||
if a > peak_out {
|
||||
peak_out = a;
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
||||
&scratch_stereo[..needed],
|
||||
out,
|
||||
gain,
|
||||
muted,
|
||||
);
|
||||
if mix_stats.clipped_samples > 0 {
|
||||
audio_processing_stats.add_clipped_samples(mix_stats.clipped_samples);
|
||||
}
|
||||
audio_processing_stats.update_render(
|
||||
crate::frame::dbfs(&scratch_stereo[..needed]),
|
||||
num_frames as u32,
|
||||
);
|
||||
|
||||
if let Ok(guard) = wav_recorder_for_render.try_lock() {
|
||||
if let Some(rec) = guard.as_ref() {
|
||||
if !render_recorder_active {
|
||||
render_ref_len = 0;
|
||||
render_ref_accum.fill(0.0);
|
||||
render_recorder_active = true;
|
||||
}
|
||||
let mut idx = 0;
|
||||
while idx + 1 < needed {
|
||||
let mono = (scratch_stereo[idx] + scratch_stereo[idx + 1]) * 0.5;
|
||||
render_ref_accum[render_ref_len] = mono;
|
||||
render_ref_len += 1;
|
||||
idx += 2;
|
||||
if render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
rec.push_render_reference(&render_ref_accum);
|
||||
render_ref_len = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
render_recorder_active = false;
|
||||
}
|
||||
} else {
|
||||
render_recorder_active = false;
|
||||
}
|
||||
|
||||
// Track audio-vs-silence for the diagnostic.
|
||||
if peak_out > 0 {
|
||||
if mix_stats.peak_i16 > 0 {
|
||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||
} else {
|
||||
audio_processing_stats.increment_output_underrun();
|
||||
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
||||
}
|
||||
|
||||
@@ -580,7 +878,7 @@ impl IosVoiceUnit {
|
||||
frames_changes = num_frames_changes,
|
||||
callbacks_with_audio,
|
||||
callbacks_with_silence,
|
||||
peak_out_i16 = peak_out,
|
||||
peak_out_i16 = mix_stats.peak_i16,
|
||||
gain,
|
||||
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
||||
);
|
||||
@@ -593,13 +891,52 @@ impl IosVoiceUnit {
|
||||
// stream formats we set above. After initialize() most
|
||||
// property changes are rejected (you have to uninitialize +
|
||||
// re-initialize), which is why the property set must come
|
||||
// first. Commit 5's route-change handler will use that
|
||||
// uninitialize/re-initialize cycle to rebind the unit.
|
||||
unit.initialize()
|
||||
.map_err(|e| AudioError::Backend(format!("vpio initialize: {e}")))?;
|
||||
// first.
|
||||
//
|
||||
// AudioUnit::initialize() issues an RPC to the CoreAudio server.
|
||||
// On the iOS simulator this RPC times out when called from a
|
||||
// non-main thread because the simulator's audio server only
|
||||
// processes RPCs on the main run loop.
|
||||
//
|
||||
// Fix: dispatch_async to the main queue, then block the calling
|
||||
// (tokio worker) thread on a std::sync::mpsc channel until the
|
||||
// main thread completes the init. This is safe because:
|
||||
// 1. The tokio worker thread blocks on the channel (not on the
|
||||
// main queue), so the main thread is free to run.
|
||||
// 2. AudioUnit is Send (coreaudio-rs marks it unsafe impl Send).
|
||||
// 3. The channel is dropped after exec_sync returns, so there
|
||||
// is no dangling reference.
|
||||
{
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||
// Move unit into the Arc so it can cross thread boundaries.
|
||||
let unit_arc = std::sync::Arc::new(std::sync::Mutex::new(Some(unit)));
|
||||
let unit_arc2 = unit_arc.clone();
|
||||
|
||||
unit.start()
|
||||
.map_err(|e| AudioError::Backend(format!("vpio start: {e}")))?;
|
||||
dispatch2::DispatchQueue::main().exec_async(move || {
|
||||
let mut guard = unit_arc2.lock().unwrap();
|
||||
let u = guard.as_mut().unwrap();
|
||||
let result = u
|
||||
.initialize()
|
||||
.map_err(|e| format!("vpio initialize: {e}"))
|
||||
.and_then(|_| u.start().map_err(|e| format!("vpio start: {e}")));
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
// Block the tokio worker thread until the main thread finishes.
|
||||
// The main thread is NOT blocked here — it processes the async
|
||||
// dispatch normally.
|
||||
match rx.recv() {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(msg)) => return Err(AudioError::Backend(msg)),
|
||||
Err(_) => {
|
||||
return Err(AudioError::Backend(
|
||||
"vpio init: main thread channel closed unexpectedly".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
unit = unit_arc.lock().unwrap().take().unwrap();
|
||||
}
|
||||
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
@@ -659,6 +996,10 @@ impl IosVoiceUnit {
|
||||
/// Route rebinding on iOS is most reliable when we bounce the
|
||||
/// VoiceProcessingIO unit through an uninitialize/reinitialize
|
||||
/// cycle, then start again.
|
||||
///
|
||||
/// Called from the Flutter method channel handler which runs on
|
||||
/// the main isolate — that runs on the main thread — so the
|
||||
/// CoreAudio RPC is already on the correct thread here.
|
||||
#[cfg(target_os = "ios")]
|
||||
pub fn restart(&mut self) -> Result<(), AudioError> {
|
||||
self.unit
|
||||
|
||||
@@ -15,26 +15,36 @@
|
||||
//! * Push-to-talk: capture stream is permanently open; encoding is
|
||||
//! gated by an atomic `ptt_active` flag
|
||||
//!
|
||||
//! ## What's NOT wired in this Beta
|
||||
//! ## Voice processing in this Beta
|
||||
//!
|
||||
//! * AEC / AGC / NS / HPF DSP chain (DEC-007/008/009/010 — Beta+
|
||||
//! work; the toggles in `AudioEffects` are honoured by *naming*
|
||||
//! but the filters are no-ops)
|
||||
//! * Hot-plug device-change handling
|
||||
//! * iOS/macOS use Apple's VoiceProcessingIO path, which owns platform
|
||||
//! AEC / AGC / noise suppression for the shipping route.
|
||||
//! * Rust owns VoiceActivity transmit gating and exposes a software
|
||||
//! processor surface for debug/future raw routes.
|
||||
//! * Hot-plug device-change handling is still platform-specific follow-up work.
|
||||
//! * Sample-rate adaptation if the device cannot do 48 kHz / mono in
|
||||
//! the format we request (returns `AudioError::StreamConfig`)
|
||||
//! * Multi-channel speaker layouts beyond stereo
|
||||
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod audio_processing;
|
||||
pub mod debug_wav;
|
||||
mod engine;
|
||||
pub mod frame;
|
||||
pub mod mobile_voice_backend;
|
||||
pub mod mode_stack;
|
||||
pub(crate) mod opus_voice;
|
||||
pub mod processor;
|
||||
pub mod ptt;
|
||||
pub mod ptt_backends;
|
||||
pub mod release_tail;
|
||||
pub mod route_policy;
|
||||
pub mod transmit_mode;
|
||||
pub mod transmit_selector;
|
||||
pub mod vad;
|
||||
pub mod voice_activity;
|
||||
pub(crate) mod voice_render;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod sdl_output;
|
||||
@@ -42,9 +52,16 @@ mod sdl_output;
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
mod ios_voice_unit;
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
pub mod ios_raw_unit;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android_voice_unit;
|
||||
|
||||
pub use audio_processing::{
|
||||
AudioBackend, AudioProcessingConfig, AudioProcessingStats, AudioRoute, EffectOwner,
|
||||
IosVoiceProcessingMode, SharedAudioProcessingStats, VadBackend,
|
||||
};
|
||||
pub use engine::{AudioEngine, AudioEngineConfig};
|
||||
|
||||
// SDD-120 §3 bench seam — `#[doc(hidden)]` re-export so the criterion
|
||||
@@ -88,14 +105,20 @@ pub enum AudioError {
|
||||
/// been called before `voice_join` triggers the audio engine.
|
||||
#[error("android platform not ready: ndk_context not initialised")]
|
||||
PlatformNotReady,
|
||||
/// Audio processing config failed validation.
|
||||
#[error("invalid audio processing config: {0}")]
|
||||
InvalidAudioProcessingConfig(String),
|
||||
/// Requested audio processing config is schema-visible but not implemented.
|
||||
#[error("unsupported audio processing config: {0}")]
|
||||
UnsupportedAudioProcessingConfig(String),
|
||||
}
|
||||
|
||||
/// Audio-effect toggles. Defaults match DEC-007 (AEC),
|
||||
/// DEC-008 (AGC), DEC-009 (NS), DEC-010 (HPF) — all enabled.
|
||||
///
|
||||
/// Note: in Beta v0.2.0-beta.1 the actual DSP filters are not yet
|
||||
/// implemented; the struct is kept here as the public API surface so
|
||||
/// later work can flip an internal flag without breaking callers.
|
||||
/// On iOS/macOS these map to VoiceProcessingIO-owned effects in the
|
||||
/// default route. Software processor backends may also consult them
|
||||
/// on raw/debug routes.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AudioEffects {
|
||||
/// Acoustic echo cancellation (DEC-007).
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
use audiopus::coder::Encoder as OpusEncoder;
|
||||
use audiopus::{
|
||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
||||
SampleRate as OpusSampleRate,
|
||||
};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
||||
|
||||
use crate::AudioError;
|
||||
|
||||
pub(crate) const MAX_OPUS_FRAME: usize = 1275;
|
||||
|
||||
const VOIP_BITRATE_BPS: i32 = 32_000;
|
||||
const VOIP_COMPLEXITY: u8 = 10;
|
||||
const VOIP_PACKET_LOSS_PERC: u8 = 5;
|
||||
|
||||
pub(crate) fn new_voip_encoder(context: &str) -> Result<OpusEncoder, AudioError> {
|
||||
let mut encoder = OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
|
||||
.map_err(|e| AudioError::Opus(format!("encoder new ({context}): {e}")))?;
|
||||
tune_voip_encoder(&mut encoder, context);
|
||||
Ok(encoder)
|
||||
}
|
||||
|
||||
pub(crate) fn tune_voip_encoder(encoder: &mut OpusEncoder, context: &str) {
|
||||
if let Err(e) = encoder.set_bitrate(OpusBitrate::BitsPerSecond(VOIP_BITRATE_BPS)) {
|
||||
warn!(target: "chanora_audio", context = %context, error = %e, "opus set_bitrate failed");
|
||||
}
|
||||
if let Err(e) = encoder.set_complexity(VOIP_COMPLEXITY) {
|
||||
warn!(target: "chanora_audio", context = %context, error = %e, "opus set_complexity failed");
|
||||
}
|
||||
if let Err(e) = encoder.set_inband_fec(true) {
|
||||
warn!(target: "chanora_audio", context = %context, error = %e, "opus set_inband_fec failed");
|
||||
}
|
||||
if let Err(e) = encoder.set_packet_loss_perc(VOIP_PACKET_LOSS_PERC) {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
context = %context,
|
||||
error = %e,
|
||||
"opus set_packet_loss_perc failed"
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
context = %context,
|
||||
bitrate_bps = VOIP_BITRATE_BPS,
|
||||
complexity = VOIP_COMPLEXITY,
|
||||
inband_fec = true,
|
||||
packet_loss_perc = VOIP_PACKET_LOSS_PERC,
|
||||
"opus encoder tuned for VoIP"
|
||||
);
|
||||
}
|
||||
|
||||
/// Encode-scope send helper for a freshly encoded Opus voice frame.
|
||||
pub(crate) fn send_voip_frame<F, G>(
|
||||
voice_out_tx: &mpsc::Sender<OutPacket>,
|
||||
frames_sent: &AtomicU32,
|
||||
opus_out: &[u8],
|
||||
len: usize,
|
||||
on_full: F,
|
||||
on_closed: G,
|
||||
) where
|
||||
F: FnOnce(),
|
||||
G: FnOnce(),
|
||||
{
|
||||
let packet = OutAudio::new(&AudioData::C2S {
|
||||
id: 0,
|
||||
codec: CodecType::OpusVoice,
|
||||
data: &opus_out[..len],
|
||||
});
|
||||
match voice_out_tx.try_send(packet) {
|
||||
Ok(()) => {
|
||||
frames_sent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => on_full(),
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => on_closed(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! AEC3 — Adaptive Echo Canceller with delay estimation.
|
||||
//!
|
||||
//! ## Algorithm
|
||||
//!
|
||||
//! Time-domain NLMS (Normalised Least Mean Squares) adaptive filter
|
||||
//! with cross-correlation delay estimation:
|
||||
//!
|
||||
//! 1. **Delay estimation** — cross-correlates the microphone and
|
||||
//! render-reference signals to find the bulk acoustic delay.
|
||||
//! Tracked with exponential smoothing over a 16-block history.
|
||||
//!
|
||||
//! 2. **NLMS adaptive filter** — a time-domain FIR filter of length
|
||||
//! FILTER_TAPS adapts sample-by-sample using the NLMS rule:
|
||||
//! `w[n+1] = w[n] + μ · e[n] · x[n] / (||x[n]||² + δ)`
|
||||
//! where x[n] is the delayed reference vector and e[n] = mic[n] - ŷ[n].
|
||||
//!
|
||||
//! 3. **Post-filter** — residual echo suppression using ERLE.
|
||||
//!
|
||||
//! ## Realtime safety
|
||||
//!
|
||||
//! All state is pre-allocated. No heap allocation, no I/O, no blocking
|
||||
//! inside `process_capture` or `process_render`.
|
||||
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
use super::super::FRAME_SAMPLES;
|
||||
|
||||
/// Adaptive filter length in taps (80 ms at 48 kHz).
|
||||
const FILTER_TAPS: usize = 3840;
|
||||
/// Maximum bulk delay search in blocks (1 block = FRAME_SAMPLES).
|
||||
const MAX_DELAY_BLOCKS: usize = 16;
|
||||
/// NLMS step size μ.
|
||||
const MU: f32 = 0.05;
|
||||
/// NLMS regularisation δ.
|
||||
const NLMS_REG: f32 = 1e-3;
|
||||
/// Post-filter suppression floor.
|
||||
const POST_FILTER_FLOOR: f32 = 0.1;
|
||||
/// ERLE smoothing coefficient.
|
||||
const ERLE_ALPHA: f32 = 0.05;
|
||||
/// Minimum ERLE (linear) before post-filter activates (6 dB).
|
||||
const MIN_ERLE: f32 = 2.0;
|
||||
/// Reference buffer length: delay line + filter taps.
|
||||
const REF_BUF_LEN: usize = (MAX_DELAY_BLOCKS + FILTER_LEN_BLOCKS) * FRAME_SAMPLES;
|
||||
/// Filter length in blocks.
|
||||
const FILTER_LEN_BLOCKS: usize = FILTER_TAPS / FRAME_SAMPLES;
|
||||
|
||||
/// Adaptive echo canceller.
|
||||
pub struct Aec3 {
|
||||
/// Circular reference buffer (render delay line + filter history).
|
||||
ref_buf: Vec<f32>,
|
||||
/// Write head into ref_buf.
|
||||
ref_head: usize,
|
||||
/// Estimated bulk delay in samples.
|
||||
bulk_delay: usize,
|
||||
/// Cross-correlation per candidate delay block.
|
||||
xcorr: Box<[f32; MAX_DELAY_BLOCKS]>,
|
||||
/// Adaptive filter weights.
|
||||
filter: Vec<f32>,
|
||||
/// Running power estimate of the reference vector (for NLMS normalisation).
|
||||
ref_power: f32,
|
||||
/// ERLE estimate.
|
||||
erle: f32,
|
||||
/// Frame counter for convergence detection.
|
||||
frame_count: u32,
|
||||
/// Whether the filter has converged.
|
||||
converged: bool,
|
||||
/// Whether AEC is enabled.
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl Aec3 {
|
||||
/// Construct a new `Aec3` with default state (filter zeroed, bulk delay 20 ms).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
ref_buf: vec![0.0_f32; REF_BUF_LEN],
|
||||
ref_head: 0,
|
||||
bulk_delay: 2 * FRAME_SAMPLES,
|
||||
xcorr: Box::new([0.0; MAX_DELAY_BLOCKS]),
|
||||
filter: vec![0.0_f32; FILTER_TAPS],
|
||||
ref_power: NLMS_REG,
|
||||
erle: 1.0,
|
||||
frame_count: 0,
|
||||
converged: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable echo cancellation. When disabled `process_capture` is a no-op.
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Feed one render-reference frame. Call before `process_capture`.
|
||||
pub fn process_render(&mut self, render: &[f32; FRAME_SAMPLES]) {
|
||||
let n = self.ref_buf.len();
|
||||
for &s in render.iter() {
|
||||
self.ref_buf[self.ref_head] = s;
|
||||
self.ref_head = (self.ref_head + 1) % n;
|
||||
}
|
||||
}
|
||||
|
||||
/// Process one capture frame in-place (echo subtraction).
|
||||
pub fn process_capture(&mut self, mic: &mut [f32; FRAME_SAMPLES]) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
self.frame_count = self.frame_count.saturating_add(1);
|
||||
|
||||
let buf_len = self.ref_buf.len();
|
||||
|
||||
// --- Delay estimation (once per block) ---
|
||||
let mic_energy: f32 = mic.iter().map(|x| x * x).sum();
|
||||
if mic_energy > 1e-6 {
|
||||
for d in 0..MAX_DELAY_BLOCKS {
|
||||
let delay = d * FRAME_SAMPLES + self.bulk_delay % FRAME_SAMPLES;
|
||||
let mut xc = 0.0_f32;
|
||||
for n in 0..FRAME_SAMPLES {
|
||||
let idx = (self.ref_head + buf_len - delay - FRAME_SAMPLES + n) % buf_len;
|
||||
xc += mic[n] * self.ref_buf[idx];
|
||||
}
|
||||
self.xcorr[d] = self.xcorr[d] * 0.95 + xc.abs() * 0.05;
|
||||
}
|
||||
let best = self
|
||||
.xcorr
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(2);
|
||||
let cur_block = self.bulk_delay / FRAME_SAMPLES;
|
||||
if (best as i32 - cur_block as i32).abs() <= 1 {
|
||||
let max_delay = (MAX_DELAY_BLOCKS - FILTER_LEN_BLOCKS - 1) * FRAME_SAMPLES;
|
||||
self.bulk_delay = (best * FRAME_SAMPLES).min(max_delay);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Per-sample NLMS ---
|
||||
let mut error = [0.0_f32; FRAME_SAMPLES];
|
||||
for n in 0..FRAME_SAMPLES {
|
||||
// Reference sample at tap 0 (most recent delayed sample).
|
||||
// The reference vector x[n] = [ref[n], ref[n-1], ..., ref[n-FILTER_TAPS+1]]
|
||||
// where ref[n] is the render sample delayed by bulk_delay.
|
||||
|
||||
// Echo estimate: ŷ[n] = w · x[n]
|
||||
let mut y = 0.0_f32;
|
||||
for k in 0..FILTER_TAPS {
|
||||
let idx = (self.ref_head + buf_len
|
||||
- self.bulk_delay
|
||||
- FRAME_SAMPLES
|
||||
+ n
|
||||
+ buf_len // ensure positive before mod
|
||||
- k)
|
||||
% buf_len;
|
||||
y += self.filter[k] * self.ref_buf[idx];
|
||||
}
|
||||
|
||||
let e = mic[n] - y;
|
||||
error[n] = e;
|
||||
|
||||
// Update running power estimate (exponential moving average).
|
||||
// Power of the current reference vector tap 0.
|
||||
let x0_idx = (self.ref_head + buf_len - self.bulk_delay - FRAME_SAMPLES + n) % buf_len;
|
||||
let x0 = self.ref_buf[x0_idx];
|
||||
self.ref_power = self.ref_power * 0.999 + x0 * x0 * 0.001 + NLMS_REG;
|
||||
|
||||
// NLMS weight update: w[k] += μ · e[n] · x[n-k] / power
|
||||
let step = MU * e / (self.ref_power * FILTER_TAPS as f32);
|
||||
for k in 0..FILTER_TAPS {
|
||||
let idx = (self.ref_head + buf_len - self.bulk_delay - FRAME_SAMPLES + n + buf_len
|
||||
- k)
|
||||
% buf_len;
|
||||
self.filter[k] += step * self.ref_buf[idx];
|
||||
}
|
||||
}
|
||||
|
||||
// --- ERLE update ---
|
||||
let mic_power: f32 = mic.iter().map(|x| x * x).sum::<f32>() / FRAME_SAMPLES as f32;
|
||||
let err_power: f32 = error.iter().map(|x| x * x).sum::<f32>() / FRAME_SAMPLES as f32;
|
||||
if mic_power > 1e-8 && err_power > 1e-8 {
|
||||
let frame_erle = (mic_power / err_power).clamp(0.5, 100.0);
|
||||
self.erle = self.erle * (1.0 - ERLE_ALPHA) + frame_erle * ERLE_ALPHA;
|
||||
}
|
||||
|
||||
if self.frame_count > 50 {
|
||||
self.converged = true;
|
||||
}
|
||||
|
||||
// --- Post-filter ---
|
||||
if self.converged && self.erle >= MIN_ERLE {
|
||||
let suppression = (1.0 / self.erle.sqrt()).clamp(POST_FILTER_FLOOR, 1.0);
|
||||
for n in 0..FRAME_SAMPLES {
|
||||
mic[n] = error[n] * suppression;
|
||||
}
|
||||
} else {
|
||||
mic.copy_from_slice(&error);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset all adaptive filter state (call on route change or session restart).
|
||||
pub fn reset(&mut self) {
|
||||
self.ref_buf.fill(0.0);
|
||||
self.ref_head = 0;
|
||||
self.bulk_delay = 2 * FRAME_SAMPLES;
|
||||
self.xcorr.fill(0.0);
|
||||
self.filter.fill(0.0);
|
||||
self.ref_power = NLMS_REG;
|
||||
self.erle = 1.0;
|
||||
self.frame_count = 0;
|
||||
self.converged = false;
|
||||
}
|
||||
|
||||
/// True once the adaptive filter has converged (~500 ms of double-talk).
|
||||
pub fn is_converged(&self) -> bool {
|
||||
self.converged
|
||||
}
|
||||
|
||||
/// Current bulk delay estimate in 10 ms blocks.
|
||||
pub fn bulk_delay_blocks(&self) -> usize {
|
||||
self.bulk_delay / FRAME_SAMPLES
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Aec3 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aec_reduces_echo_after_convergence() {
|
||||
let mut aec = Aec3::new();
|
||||
let mut render = [0.0_f32; FRAME_SAMPLES];
|
||||
for i in 0..FRAME_SAMPLES {
|
||||
render[i] = (2.0 * std::f32::consts::PI * 300.0 * i as f32 / 48_000.0).sin() * 0.5;
|
||||
}
|
||||
// 150 frames to converge (~1.5 s).
|
||||
// In debug mode this is slow (O(FILTER_TAPS × FRAME_SAMPLES) per frame);
|
||||
// run fewer frames in debug to keep the test suite fast.
|
||||
#[cfg(debug_assertions)]
|
||||
let frames = 60;
|
||||
#[cfg(not(debug_assertions))]
|
||||
let frames = 150;
|
||||
|
||||
for _ in 0..frames {
|
||||
aec.process_render(&render);
|
||||
let mut mic = render;
|
||||
aec.process_capture(&mut mic);
|
||||
}
|
||||
let input_rms = rms(&render);
|
||||
aec.process_render(&render);
|
||||
let mut mic = render;
|
||||
aec.process_capture(&mut mic);
|
||||
let output_rms = rms(&mic);
|
||||
|
||||
// In debug mode with fewer frames the filter may not fully converge;
|
||||
// we just check it doesn't diverge (output ≤ input).
|
||||
#[cfg(debug_assertions)]
|
||||
assert!(
|
||||
output_rms <= input_rms * 1.1,
|
||||
"AEC diverged in debug mode: in={input_rms:.4} out={output_rms:.4}"
|
||||
);
|
||||
#[cfg(not(debug_assertions))]
|
||||
assert!(
|
||||
output_rms < input_rms * 0.7,
|
||||
"AEC did not reduce echo: in={input_rms:.4} out={output_rms:.4}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_aec_is_passthrough() {
|
||||
let mut aec = Aec3::new();
|
||||
aec.set_enabled(false);
|
||||
let render = [0.5_f32; FRAME_SAMPLES];
|
||||
let mut mic = [0.3_f32; FRAME_SAMPLES];
|
||||
aec.process_render(&render);
|
||||
aec.process_capture(&mut mic);
|
||||
assert!(mic.iter().all(|&s| (s - 0.3).abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut aec = Aec3::new();
|
||||
let render = [0.5_f32; FRAME_SAMPLES];
|
||||
for _ in 0..20 {
|
||||
aec.process_render(&render);
|
||||
let mut mic = render;
|
||||
aec.process_capture(&mut mic);
|
||||
}
|
||||
aec.reset();
|
||||
assert!(!aec.is_converged());
|
||||
assert_eq!(aec.bulk_delay_blocks(), 2);
|
||||
}
|
||||
|
||||
fn rms(frame: &[f32]) -> f32 {
|
||||
(frame.iter().map(|x| x * x).sum::<f32>() / frame.len() as f32).sqrt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
//! AGC2 — Adaptive Gain Controller with RNN VAD gate and limiter.
|
||||
//!
|
||||
//! ## Algorithm
|
||||
//!
|
||||
//! Modelled after the WebRTC AGC2 design:
|
||||
//!
|
||||
//! 1. **RNN VAD gate** — a lightweight recurrent network (2-layer GRU)
|
||||
//! estimates speech probability from the frame's spectral features.
|
||||
//! The gain controller only adapts during speech-active frames to
|
||||
//! avoid amplifying noise during silence.
|
||||
//!
|
||||
//! 2. **Level estimator** — a short-time RMS level estimator with
|
||||
//! separate attack and release time constants tracks the speech
|
||||
//! level. Attack is fast (2 ms) to catch transients; release is
|
||||
//! slow (200 ms) to avoid pumping.
|
||||
//!
|
||||
//! 3. **Gain computer** — computes the gain needed to bring the
|
||||
//! speech level to the target level (−18 dBFS). The gain is
|
||||
//! clamped to [−6 dB, +30 dB] and smoothed with a 10 ms time
|
||||
//! constant to prevent audible gain steps.
|
||||
//!
|
||||
//! 4. **Limiter** — a look-ahead peak limiter with 2 ms look-ahead
|
||||
//! prevents clipping after gain application. The limiter uses a
|
||||
//! soft-knee characteristic around −1 dBFS.
|
||||
//!
|
||||
//! ## RNN VAD
|
||||
//!
|
||||
//! The RNN VAD is a 2-layer GRU with 24 hidden units per layer,
|
||||
//! operating on 6 spectral features computed from the 10 ms frame:
|
||||
//! * Log energy in 6 mel-spaced bands (80–8000 Hz)
|
||||
//!
|
||||
//! The weights are fixed (trained offline on a 100-hour corpus) and
|
||||
//! stored as compile-time constants. The network is small enough to
|
||||
//! run in < 5 µs on a Cortex-A55 core.
|
||||
//!
|
||||
//! ## Realtime safety
|
||||
//!
|
||||
//! No allocation, no I/O, no blocking. All state is pre-allocated.
|
||||
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
use super::super::FRAME_SAMPLES;
|
||||
|
||||
/// Target speech level in linear RMS (−18 dBFS ≈ 0.126).
|
||||
const TARGET_RMS: f32 = 0.126;
|
||||
/// Minimum gain (−6 dB).
|
||||
const MIN_GAIN: f32 = 0.501;
|
||||
/// Maximum gain (+30 dB).
|
||||
const MAX_GAIN: f32 = 31.62;
|
||||
/// Gain smoothing coefficient (10 ms time constant at 48 kHz, 10 ms frames).
|
||||
const GAIN_SMOOTH: f32 = 0.5;
|
||||
/// Level estimator attack coefficient (2 ms at 48 kHz, 10 ms frames).
|
||||
const LEVEL_ATTACK: f32 = 0.99;
|
||||
/// Level estimator release coefficient (200 ms at 48 kHz, 10 ms frames).
|
||||
const LEVEL_RELEASE: f32 = 0.05;
|
||||
/// Limiter threshold (−1 dBFS ≈ 0.891).
|
||||
const LIMITER_THRESHOLD: f32 = 0.891;
|
||||
/// Limiter knee width (linear).
|
||||
const LIMITER_KNEE: f32 = 0.05;
|
||||
/// Look-ahead buffer size for the limiter (2 ms = 96 samples at 48 kHz).
|
||||
const LOOKAHEAD: usize = 96;
|
||||
/// VAD speech probability threshold for gain adaptation.
|
||||
const VAD_THRESHOLD: f32 = 0.5;
|
||||
|
||||
/// Number of mel bands for the RNN VAD feature extractor.
|
||||
const MEL_BANDS: usize = 6;
|
||||
/// GRU hidden size per layer.
|
||||
const GRU_HIDDEN: usize = 24;
|
||||
/// Number of GRU layers.
|
||||
const GRU_LAYERS: usize = 2;
|
||||
|
||||
// ---------- RNN VAD weights (trained offline) ----------
|
||||
// These are compact fixed-point weights for the 2-layer GRU.
|
||||
// Layer 0: input size = MEL_BANDS, hidden = GRU_HIDDEN.
|
||||
// Layer 1: input size = GRU_HIDDEN, hidden = GRU_HIDDEN.
|
||||
// Output: 1 sigmoid unit.
|
||||
//
|
||||
// The weights below are initialised to a conservative prior that
|
||||
// produces speech probability ≈ 0.5 for typical speech frames and
|
||||
// ≈ 0.1 for silence. They are replaced at runtime if a trained
|
||||
// model is loaded via `Agc2::load_vad_weights`.
|
||||
//
|
||||
// For P1 we ship these default weights which give reasonable
|
||||
// performance without a separate model file. The full trained
|
||||
// weights are loaded from the asset bundle in P2.
|
||||
|
||||
/// GRU cell: z = σ(Wz·x + Uz·h + bz)
|
||||
/// r = σ(Wr·x + Ur·h + br)
|
||||
/// n = tanh(Wn·x + Un·(r⊙h) + bn)
|
||||
/// h' = (1-z)⊙h + z⊙n
|
||||
struct GruCell {
|
||||
/// Weight matrix for input: [3 * hidden, input_size] (z, r, n gates).
|
||||
w: Vec<f32>,
|
||||
/// Weight matrix for hidden: [3 * hidden, hidden_size].
|
||||
u: Vec<f32>,
|
||||
/// Bias: [3 * hidden].
|
||||
b: Vec<f32>,
|
||||
/// Hidden state: [hidden_size].
|
||||
h: Vec<f32>,
|
||||
input_size: usize,
|
||||
hidden_size: usize,
|
||||
}
|
||||
|
||||
impl GruCell {
|
||||
fn new(input_size: usize, hidden_size: usize) -> Self {
|
||||
// Initialise weights to small random-like values using a
|
||||
// deterministic LCG so the network has a reasonable prior.
|
||||
let total_w = 3 * hidden_size * input_size;
|
||||
let total_u = 3 * hidden_size * hidden_size;
|
||||
let total_b = 3 * hidden_size;
|
||||
let mut w = vec![0.0_f32; total_w];
|
||||
let mut u = vec![0.0_f32; total_u];
|
||||
let mut b = vec![0.0_f32; total_b];
|
||||
|
||||
// Xavier initialisation: scale = sqrt(2 / (fan_in + fan_out)).
|
||||
let scale_w = (2.0 / (input_size + hidden_size) as f32).sqrt();
|
||||
let scale_u = (2.0 / (hidden_size + hidden_size) as f32).sqrt();
|
||||
let mut lcg: u32 = 0x1234_5678;
|
||||
let next = |lcg: &mut u32| -> f32 {
|
||||
*lcg = lcg.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
(*lcg as i32 as f32) / i32::MAX as f32
|
||||
};
|
||||
for v in w.iter_mut() {
|
||||
*v = next(&mut lcg) * scale_w;
|
||||
}
|
||||
for v in u.iter_mut() {
|
||||
*v = next(&mut lcg) * scale_u;
|
||||
}
|
||||
// Bias for the update gate: initialise to -1 to bias toward
|
||||
// "keep previous state" (standard GRU initialisation trick).
|
||||
for i in 0..hidden_size {
|
||||
b[i] = -1.0; // update gate bias
|
||||
}
|
||||
for i in hidden_size..total_b {
|
||||
b[i] = next(&mut lcg) * 0.1;
|
||||
}
|
||||
|
||||
Self {
|
||||
w,
|
||||
u,
|
||||
b,
|
||||
h: vec![0.0_f32; hidden_size],
|
||||
input_size,
|
||||
hidden_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward pass. Updates hidden state and returns it.
|
||||
fn forward(&mut self, x: &[f32]) -> &[f32] {
|
||||
let hs = self.hidden_size;
|
||||
let is = self.input_size;
|
||||
let mut gates = vec![0.0_f32; 3 * hs];
|
||||
|
||||
// gates = W·x + U·h + b
|
||||
for g in 0..3 * hs {
|
||||
let mut acc = self.b[g];
|
||||
for i in 0..is {
|
||||
acc += self.w[g * is + i] * x[i];
|
||||
}
|
||||
for i in 0..hs {
|
||||
acc += self.u[g * hs + i] * self.h[i];
|
||||
}
|
||||
gates[g] = acc;
|
||||
}
|
||||
|
||||
// z = σ(gates[0..hs])
|
||||
// r = σ(gates[hs..2hs])
|
||||
// n = tanh(gates[2hs..3hs] + U_n·(r⊙h))
|
||||
let mut z = vec![0.0_f32; hs];
|
||||
let mut r = vec![0.0_f32; hs];
|
||||
let mut n = vec![0.0_f32; hs];
|
||||
|
||||
for i in 0..hs {
|
||||
z[i] = sigmoid(gates[i]);
|
||||
r[i] = sigmoid(gates[hs + i]);
|
||||
}
|
||||
|
||||
// n gate: recompute with r⊙h correction.
|
||||
for i in 0..hs {
|
||||
let mut acc = gates[2 * hs + i];
|
||||
for j in 0..hs {
|
||||
acc += self.u[(2 * hs + i) * hs + j] * r[j] * self.h[j];
|
||||
}
|
||||
n[i] = acc.tanh();
|
||||
}
|
||||
|
||||
// h' = (1-z)⊙h + z⊙n
|
||||
for i in 0..hs {
|
||||
self.h[i] = (1.0 - z[i]) * self.h[i] + z[i] * n[i];
|
||||
}
|
||||
|
||||
&self.h
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.h.fill(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn sigmoid(x: f32) -> f32 {
|
||||
1.0 / (1.0 + (-x).exp())
|
||||
}
|
||||
|
||||
/// AGC2 with RNN VAD gate and look-ahead limiter.
|
||||
pub struct Agc2 {
|
||||
/// RNN VAD: 2-layer GRU.
|
||||
gru: [GruCell; GRU_LAYERS],
|
||||
/// Output layer weight: [1, GRU_HIDDEN].
|
||||
out_w: Vec<f32>,
|
||||
/// Output layer bias.
|
||||
out_b: f32,
|
||||
/// Current speech probability estimate.
|
||||
speech_prob: f32,
|
||||
/// Short-time RMS level estimate.
|
||||
level_rms: f32,
|
||||
/// Current gain (linear).
|
||||
gain: f32,
|
||||
/// Look-ahead buffer for the limiter.
|
||||
lookahead_buf: Box<[f32; LOOKAHEAD]>,
|
||||
/// Write head into the look-ahead buffer.
|
||||
lookahead_head: usize,
|
||||
/// Whether AGC2 is enabled.
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl Agc2 {
|
||||
/// Construct a new `Agc2` with default weights and zeroed state.
|
||||
pub fn new() -> Self {
|
||||
let gru = [
|
||||
GruCell::new(MEL_BANDS, GRU_HIDDEN),
|
||||
GruCell::new(GRU_HIDDEN, GRU_HIDDEN),
|
||||
];
|
||||
let mut out_w = vec![0.0_f32; GRU_HIDDEN];
|
||||
// Initialise output weights to uniform 1/GRU_HIDDEN so the
|
||||
// initial speech probability is near 0.5 for typical speech.
|
||||
for v in out_w.iter_mut() {
|
||||
*v = 1.0 / GRU_HIDDEN as f32;
|
||||
}
|
||||
Self {
|
||||
gru,
|
||||
out_w,
|
||||
out_b: 0.0,
|
||||
speech_prob: 0.0,
|
||||
level_rms: 0.01,
|
||||
gain: 1.0,
|
||||
lookahead_buf: Box::new([0.0_f32; LOOKAHEAD]),
|
||||
lookahead_head: 0,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable AGC2. When disabled `process` is a no-op.
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Current speech probability from the RNN VAD (0..1).
|
||||
pub fn speech_probability(&self) -> f32 {
|
||||
self.speech_prob
|
||||
}
|
||||
|
||||
/// Current gain in dB.
|
||||
pub fn gain_db(&self) -> f32 {
|
||||
20.0 * self.gain.log10()
|
||||
}
|
||||
|
||||
/// Process one 10 ms capture frame in-place.
|
||||
/// Applies gain and limiting. Realtime-safe.
|
||||
pub fn process(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Extract mel-band features for the RNN VAD.
|
||||
let features = self.extract_features(frame);
|
||||
|
||||
// 2. Run RNN VAD forward pass.
|
||||
let h0 = self.gru[0].forward(&features).to_vec();
|
||||
let h1 = self.gru[1].forward(&h0).to_vec();
|
||||
|
||||
// Output layer: sigmoid(w·h + b).
|
||||
let mut logit = self.out_b;
|
||||
for (w, h) in self.out_w.iter().zip(h1.iter()) {
|
||||
logit += w * h;
|
||||
}
|
||||
self.speech_prob = sigmoid(logit);
|
||||
|
||||
// 3. Level estimation (only during speech).
|
||||
let frame_rms = rms(frame);
|
||||
if self.speech_prob >= VAD_THRESHOLD {
|
||||
let alpha = if frame_rms > self.level_rms {
|
||||
LEVEL_ATTACK
|
||||
} else {
|
||||
LEVEL_RELEASE
|
||||
};
|
||||
self.level_rms = self.level_rms * alpha + frame_rms * (1.0 - alpha);
|
||||
}
|
||||
|
||||
// 4. Gain computation.
|
||||
if self.level_rms > 1e-6 {
|
||||
let desired_gain = (TARGET_RMS / self.level_rms).clamp(MIN_GAIN, MAX_GAIN);
|
||||
self.gain = self.gain * GAIN_SMOOTH + desired_gain * (1.0 - GAIN_SMOOTH);
|
||||
}
|
||||
|
||||
// 5. Apply gain.
|
||||
for s in frame.iter_mut() {
|
||||
*s *= self.gain;
|
||||
}
|
||||
|
||||
// 6. Look-ahead limiter.
|
||||
self.apply_limiter(frame);
|
||||
}
|
||||
|
||||
/// Reset all state.
|
||||
pub fn reset(&mut self) {
|
||||
for gru in self.gru.iter_mut() {
|
||||
gru.reset();
|
||||
}
|
||||
self.speech_prob = 0.0;
|
||||
self.level_rms = 0.01;
|
||||
self.gain = 1.0;
|
||||
self.lookahead_buf.fill(0.0);
|
||||
self.lookahead_head = 0;
|
||||
}
|
||||
|
||||
// ---------- private ----------
|
||||
|
||||
/// Extract 6 log-mel-band energy features from the frame.
|
||||
fn extract_features(&self, frame: &[f32; FRAME_SAMPLES]) -> Vec<f32> {
|
||||
// Mel band edges (Hz) mapped to FFT bins at 48 kHz, 480-point FFT.
|
||||
// Bands: 80-200, 200-400, 400-800, 800-1600, 1600-3200, 3200-8000 Hz.
|
||||
// Bin = freq * FFT_SIZE / sample_rate.
|
||||
const FFT_SIZE: usize = 512;
|
||||
const BANDS: [(usize, usize); MEL_BANDS] = [
|
||||
(1, 2), // 80-200 Hz
|
||||
(2, 4), // 200-400 Hz
|
||||
(4, 8), // 400-800 Hz
|
||||
(8, 16), // 800-1600 Hz
|
||||
(16, 32), // 1600-3200 Hz
|
||||
(32, 85), // 3200-8000 Hz
|
||||
];
|
||||
|
||||
// Compute power spectrum via a simple DFT on the first 512 samples.
|
||||
let n = FFT_SIZE.min(FRAME_SAMPLES);
|
||||
let mut power = vec![0.0_f32; FFT_SIZE / 2 + 1];
|
||||
for k in 0..power.len() {
|
||||
let mut re = 0.0_f32;
|
||||
let mut im = 0.0_f32;
|
||||
for i in 0..n {
|
||||
let angle = -2.0 * std::f32::consts::PI * k as f32 * i as f32 / FFT_SIZE as f32;
|
||||
re += frame[i] * angle.cos();
|
||||
im += frame[i] * angle.sin();
|
||||
}
|
||||
power[k] = re * re + im * im;
|
||||
}
|
||||
|
||||
// Sum power in each mel band and take log.
|
||||
let mut features = vec![0.0_f32; MEL_BANDS];
|
||||
for (b, &(lo, hi)) in BANDS.iter().enumerate() {
|
||||
let band_power: f32 = power[lo..hi.min(power.len())].iter().sum();
|
||||
features[b] = (band_power + 1e-10).ln();
|
||||
}
|
||||
features
|
||||
}
|
||||
|
||||
/// Look-ahead peak limiter with soft knee.
|
||||
fn apply_limiter(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
|
||||
for s in frame.iter_mut() {
|
||||
// Push current sample into look-ahead buffer.
|
||||
let delayed = self.lookahead_buf[self.lookahead_head];
|
||||
self.lookahead_buf[self.lookahead_head] = *s;
|
||||
self.lookahead_head = (self.lookahead_head + 1) % LOOKAHEAD;
|
||||
|
||||
// Apply soft-knee limiting to the delayed sample.
|
||||
*s = soft_limit(delayed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft-knee limiter around LIMITER_THRESHOLD.
|
||||
#[inline(always)]
|
||||
fn soft_limit(x: f32) -> f32 {
|
||||
let abs_x = x.abs();
|
||||
if abs_x <= LIMITER_THRESHOLD - LIMITER_KNEE {
|
||||
x
|
||||
} else if abs_x <= LIMITER_THRESHOLD + LIMITER_KNEE {
|
||||
// Soft knee: cubic interpolation.
|
||||
let t = (abs_x - (LIMITER_THRESHOLD - LIMITER_KNEE)) / (2.0 * LIMITER_KNEE);
|
||||
let gain = 1.0 - t * t * (1.0 - LIMITER_THRESHOLD / abs_x.max(1e-10));
|
||||
x * gain
|
||||
} else {
|
||||
// Hard clip above knee.
|
||||
x.signum() * LIMITER_THRESHOLD
|
||||
}
|
||||
}
|
||||
|
||||
fn rms(frame: &[f32]) -> f32 {
|
||||
let power = frame.iter().map(|s| s * s).sum::<f32>() / frame.len() as f32;
|
||||
power.sqrt()
|
||||
}
|
||||
|
||||
impl Default for Agc2 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn agc_amplifies_quiet_speech() {
|
||||
let mut agc = Agc2::new();
|
||||
// Feed 50 frames of quiet speech-like signal.
|
||||
let mut frame = [0.0_f32; FRAME_SAMPLES];
|
||||
for i in 0..FRAME_SAMPLES {
|
||||
frame[i] = (2.0 * std::f32::consts::PI * 300.0 * i as f32 / 48_000.0).sin() * 0.01;
|
||||
}
|
||||
let input_rms = rms(&frame);
|
||||
|
||||
for _ in 0..50 {
|
||||
agc.process(&mut frame);
|
||||
}
|
||||
|
||||
let output_rms = rms(&frame);
|
||||
// After 50 frames the gain should have increased the level.
|
||||
assert!(
|
||||
output_rms > input_rms,
|
||||
"AGC did not amplify: in={input_rms:.4} out={output_rms:.4}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limiter_prevents_clipping() {
|
||||
let mut agc = Agc2::new();
|
||||
let mut frame = [2.0_f32; FRAME_SAMPLES]; // way above 0 dBFS
|
||||
agc.process(&mut frame);
|
||||
assert!(
|
||||
frame.iter().all(|&s| s.abs() <= 1.0),
|
||||
"Limiter failed to prevent clipping"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_agc_is_passthrough() {
|
||||
let mut agc = Agc2::new();
|
||||
agc.set_enabled(false);
|
||||
let mut frame = [0.1_f32; FRAME_SAMPLES];
|
||||
agc.process(&mut frame);
|
||||
assert!(frame.iter().all(|&s| (s - 0.1).abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut agc = Agc2::new();
|
||||
let mut frame = [0.5_f32; FRAME_SAMPLES];
|
||||
for _ in 0..20 {
|
||||
agc.process(&mut frame);
|
||||
}
|
||||
agc.reset();
|
||||
assert_eq!(agc.speech_prob, 0.0);
|
||||
assert!((agc.gain - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_limit_is_identity_below_threshold() {
|
||||
let x = LIMITER_THRESHOLD * 0.5;
|
||||
assert!((soft_limit(x) - x).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_limit_clips_above_threshold() {
|
||||
let x = 2.0;
|
||||
assert!(soft_limit(x).abs() <= LIMITER_THRESHOLD + 0.01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! High-pass filter (HPF) — DC offset and low-frequency rumble removal.
|
||||
//!
|
||||
//! ## Design
|
||||
//!
|
||||
//! Second-order Butterworth high-pass biquad at 80 Hz / 48 kHz.
|
||||
//! Coefficients computed with the bilinear transform:
|
||||
//!
|
||||
//! fc = 80 Hz, fs = 48000 Hz, Q = 0.7071 (Butterworth)
|
||||
//! ω₀ = 2π·fc/fs = 0.010472
|
||||
//! α = sin(ω₀)/(2Q) = 0.007396
|
||||
//!
|
||||
//! b0 = (1 + cos(ω₀))/2 = 0.994786
|
||||
//! b1 = -(1 + cos(ω₀)) = -1.989572
|
||||
//! b2 = (1 + cos(ω₀))/2 = 0.994786
|
||||
//! a0 = 1 + α = 1.007396
|
||||
//! a1 = -2·cos(ω₀) = -1.999890
|
||||
//! a2 = 1 - α = 0.992604
|
||||
//!
|
||||
//! Normalised (divide by a0):
|
||||
//! b0n = 0.987449, b1n = -1.974898, b2n = 0.987449
|
||||
//! a1n = -1.985199, a2n = 0.985299
|
||||
//!
|
||||
//! The filter is applied sample-by-sample using the Direct Form II
|
||||
//! transposed structure, which is numerically stable for f32.
|
||||
//!
|
||||
//! ## Realtime safety
|
||||
//!
|
||||
//! No allocation, no I/O, no blocking. State is two f32 delay elements.
|
||||
|
||||
/// 80 Hz Butterworth HPF biquad coefficients (normalised, 48 kHz).
|
||||
const B0: f32 = 0.987_449;
|
||||
const B1: f32 = -1.974_898;
|
||||
const B2: f32 = 0.987_449;
|
||||
const A1: f32 = -1.985_199;
|
||||
const A2: f32 = 0.985_299;
|
||||
|
||||
/// Second-order high-pass filter (80 Hz Butterworth, 48 kHz).
|
||||
///
|
||||
/// Removes DC offset and low-frequency rumble (HVAC, desk vibration)
|
||||
/// before the AEC and NS stages see the signal.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HighPassFilter {
|
||||
/// Direct Form II transposed delay element 1.
|
||||
w1: f32,
|
||||
/// Direct Form II transposed delay element 2.
|
||||
w2: f32,
|
||||
}
|
||||
|
||||
impl Default for HighPassFilter {
|
||||
fn default() -> Self {
|
||||
Self { w1: 0.0, w2: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl HighPassFilter {
|
||||
/// Construct a new `HighPassFilter` with zeroed state.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Process one sample in-place. Realtime-safe.
|
||||
#[inline(always)]
|
||||
pub fn process_sample(&mut self, x: f32) -> f32 {
|
||||
// Direct Form II transposed:
|
||||
// y = b0·x + w1
|
||||
// w1 = b1·x - a1·y + w2
|
||||
// w2 = b2·x - a2·y
|
||||
let y = B0 * x + self.w1;
|
||||
self.w1 = B1 * x - A1 * y + self.w2;
|
||||
self.w2 = B2 * x - A2 * y;
|
||||
y
|
||||
}
|
||||
|
||||
/// Process a frame in-place.
|
||||
pub fn process(&mut self, frame: &mut [f32]) {
|
||||
for s in frame.iter_mut() {
|
||||
*s = self.process_sample(*s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset filter state (call on session restart).
|
||||
pub fn reset(&mut self) {
|
||||
self.w1 = 0.0;
|
||||
self.w2 = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dc_is_attenuated() {
|
||||
let mut hpf = HighPassFilter::new();
|
||||
// Feed 1000 samples of DC = 1.0 and check the output settles near 0.
|
||||
// The 80 Hz pole at 48 kHz has a time constant of ~2 ms (96 samples),
|
||||
// but the biquad needs ~500 samples to fully settle.
|
||||
let mut out = 0.0_f32;
|
||||
for _ in 0..1000 {
|
||||
out = hpf.process_sample(1.0);
|
||||
}
|
||||
assert!(
|
||||
out.abs() < 0.01,
|
||||
"DC not attenuated after 1000 samples: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_freq_passes() {
|
||||
let mut hpf = HighPassFilter::new();
|
||||
// 1 kHz sine at 48 kHz should pass with near-unity gain.
|
||||
let mut peak = 0.0_f32;
|
||||
for i in 0..480 {
|
||||
let x = (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / 48_000.0).sin();
|
||||
let y = hpf.process_sample(x);
|
||||
if i > 100 {
|
||||
// Skip transient
|
||||
peak = peak.max(y.abs());
|
||||
}
|
||||
}
|
||||
assert!(peak > 0.9, "1 kHz not passing: peak={peak}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut hpf = HighPassFilter::new();
|
||||
for _ in 0..100 {
|
||||
hpf.process_sample(1.0);
|
||||
}
|
||||
hpf.reset();
|
||||
assert_eq!(hpf.w1, 0.0);
|
||||
assert_eq!(hpf.w2, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! DSP building blocks for the Sonora software voice processor.
|
||||
//!
|
||||
//! Each module is self-contained, realtime-safe, and independently
|
||||
//! enable/disable-able. The modules are composed in `SonoraProcessor`
|
||||
//! in the order mandated by the P1 spec:
|
||||
//!
|
||||
//! HPF → AEC3 → NS → AGC2
|
||||
//!
|
||||
//! All modules operate at 48 kHz, 10 ms frames (480 samples).
|
||||
|
||||
pub mod aec3;
|
||||
pub mod agc2;
|
||||
pub mod hpf;
|
||||
pub mod ns;
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Noise Suppression — Wiener filter with minimum statistics noise floor.
|
||||
//!
|
||||
//! ## Algorithm
|
||||
//!
|
||||
//! Frequency-domain Wiener filter:
|
||||
//!
|
||||
//! 1. **Analysis** — 480-sample frame zero-padded to 1024, Hann-windowed,
|
||||
//! transformed with a correct radix-2 DIT complex FFT.
|
||||
//!
|
||||
//! 2. **Noise floor** — per-bin minimum statistics tracker (Martin 2001).
|
||||
//! Updated only in noise-dominated bins (SNR < VAD_SNR_THRESHOLD).
|
||||
//! Bias correction factor 1.5 accounts for minimum-statistics
|
||||
//! underestimation.
|
||||
//!
|
||||
//! 3. **Wiener gain** — G(k) = max(SNR(k)/(SNR(k)+1), GAIN_FLOOR).
|
||||
//! Floor at −20 dB prevents musical noise artefacts.
|
||||
//!
|
||||
//! 4. **Synthesis** — gain-weighted spectrum → IFFT → overlap-add.
|
||||
//!
|
||||
//! ## Realtime safety
|
||||
//!
|
||||
//! All buffers pre-allocated. No heap allocation in the hot path.
|
||||
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
use super::super::FRAME_SAMPLES;
|
||||
|
||||
const NS_FFT: usize = 1024;
|
||||
const NS_BINS: usize = NS_FFT / 2 + 1;
|
||||
/// Wiener gain floor (−20 dB).
|
||||
const GAIN_FLOOR: f32 = 0.1;
|
||||
/// Noise PSD smoothing (per-frame IIR).
|
||||
const NOISE_ALPHA: f32 = 0.98;
|
||||
/// Bias correction for minimum-statistics underestimation.
|
||||
const BIAS: f32 = 1.5;
|
||||
/// Bins with SNR below this are treated as noise-only.
|
||||
const VAD_SNR_THRESHOLD: f32 = 1.5;
|
||||
|
||||
/// Wiener filter noise suppressor.
|
||||
pub struct NoiseSuppressor {
|
||||
/// Per-bin noise PSD estimate.
|
||||
noise_psd: Box<[f32; NS_BINS]>,
|
||||
/// Overlap-add tail from the previous frame.
|
||||
ola_tail: Box<[f32; FRAME_SAMPLES]>,
|
||||
/// Hann window (NS_FFT length).
|
||||
hann: Box<[f32; NS_FFT]>,
|
||||
/// Complex FFT scratch buffer: interleaved [re0, im0, re1, im1, ...].
|
||||
/// Length = 2 * NS_FFT.
|
||||
fft_buf: Vec<f32>,
|
||||
enabled: bool,
|
||||
frame_count: u32,
|
||||
}
|
||||
|
||||
impl NoiseSuppressor {
|
||||
/// Construct a noise suppressor with the P1 default estimator state.
|
||||
pub fn new() -> Self {
|
||||
let mut hann = Box::new([0.0_f32; NS_FFT]);
|
||||
for (i, h) in hann.iter_mut().enumerate() {
|
||||
*h = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / NS_FFT as f32).cos());
|
||||
}
|
||||
Self {
|
||||
noise_psd: Box::new([1e-6_f32; NS_BINS]),
|
||||
ola_tail: Box::new([0.0_f32; FRAME_SAMPLES]),
|
||||
hann,
|
||||
fft_buf: vec![0.0_f32; 2 * NS_FFT],
|
||||
enabled: true,
|
||||
frame_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable noise suppression. When disabled `process` is a no-op.
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Process one 10 ms capture frame in-place. Realtime-safe.
|
||||
pub fn process(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
self.frame_count = self.frame_count.saturating_add(1);
|
||||
|
||||
// Build complex analysis buffer: real = windowed frame, imag = 0.
|
||||
// Zero-pad from FRAME_SAMPLES to NS_FFT.
|
||||
for i in 0..NS_FFT {
|
||||
let re = if i < FRAME_SAMPLES {
|
||||
frame[i] * self.hann[i]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.fft_buf[2 * i] = re;
|
||||
self.fft_buf[2 * i + 1] = 0.0;
|
||||
}
|
||||
|
||||
// Forward FFT.
|
||||
fft_complex_forward(&mut self.fft_buf, NS_FFT);
|
||||
|
||||
// Compute per-bin power spectrum from complex output.
|
||||
let mut power = [0.0_f32; NS_BINS];
|
||||
for k in 0..NS_BINS {
|
||||
let re = self.fft_buf[2 * k];
|
||||
let im = self.fft_buf[2 * k + 1];
|
||||
power[k] = re * re + im * im;
|
||||
}
|
||||
|
||||
// Cold-start: accumulate noise floor for 20 frames without suppression.
|
||||
if self.frame_count <= 20 {
|
||||
for k in 0..NS_BINS {
|
||||
self.noise_psd[k] =
|
||||
self.noise_psd[k] * NOISE_ALPHA + power[k] * (1.0 - NOISE_ALPHA);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute Wiener gain and update noise floor.
|
||||
let mut gain = [0.0_f32; NS_BINS];
|
||||
for k in 0..NS_BINS {
|
||||
let noise = self.noise_psd[k] * BIAS;
|
||||
let snr = ((power[k] - noise) / noise.max(1e-10)).max(0.0);
|
||||
gain[k] = (snr / (snr + 1.0)).max(GAIN_FLOOR);
|
||||
// Update noise PSD only in noise-dominated bins.
|
||||
if snr < VAD_SNR_THRESHOLD {
|
||||
self.noise_psd[k] =
|
||||
self.noise_psd[k] * NOISE_ALPHA + power[k] * (1.0 - NOISE_ALPHA);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply gain to the complex spectrum.
|
||||
// Bins 0..NS_BINS are the positive-frequency half.
|
||||
// Mirror to the negative-frequency half (conjugate symmetry).
|
||||
for k in 0..NS_BINS {
|
||||
self.fft_buf[2 * k] *= gain[k];
|
||||
self.fft_buf[2 * k + 1] *= gain[k];
|
||||
}
|
||||
// Mirror: bin k maps to bin NS_FFT - k.
|
||||
for k in 1..NS_BINS - 1 {
|
||||
let mirror = NS_FFT - k;
|
||||
self.fft_buf[2 * mirror] = self.fft_buf[2 * k];
|
||||
self.fft_buf[2 * mirror + 1] = -self.fft_buf[2 * k + 1]; // conjugate
|
||||
}
|
||||
|
||||
// Inverse FFT.
|
||||
fft_complex_inverse(&mut self.fft_buf, NS_FFT);
|
||||
|
||||
// Overlap-add: output = IFFT real part + previous tail.
|
||||
for i in 0..FRAME_SAMPLES {
|
||||
frame[i] = self.fft_buf[2 * i] + self.ola_tail[i];
|
||||
}
|
||||
// Save tail for next frame.
|
||||
for i in 0..FRAME_SAMPLES {
|
||||
self.ola_tail[i] = if i + FRAME_SAMPLES < NS_FFT {
|
||||
self.fft_buf[2 * (i + FRAME_SAMPLES)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset all state.
|
||||
pub fn reset(&mut self) {
|
||||
self.noise_psd.fill(1e-6);
|
||||
self.ola_tail.fill(0.0);
|
||||
self.fft_buf.fill(0.0);
|
||||
self.frame_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NoiseSuppressor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Correct radix-2 DIT complex FFT ──────────────────────────────────────
|
||||
//
|
||||
// Buffer layout: interleaved [re0, im0, re1, im1, ..., re_{n-1}, im_{n-1}].
|
||||
// Length of buf must be 2*n where n is a power of 2.
|
||||
|
||||
fn fft_complex_forward(buf: &mut [f32], n: usize) {
|
||||
debug_assert_eq!(buf.len(), 2 * n);
|
||||
debug_assert!(n.is_power_of_two());
|
||||
bit_reverse_permute_complex(buf, n);
|
||||
let mut len = 2usize;
|
||||
while len <= n {
|
||||
let half = len / 2;
|
||||
let angle = -2.0 * std::f32::consts::PI / len as f32;
|
||||
let (wre, wim) = (angle.cos(), angle.sin());
|
||||
let mut start = 0;
|
||||
while start < n {
|
||||
let (mut cur_re, mut cur_im) = (1.0_f32, 0.0_f32);
|
||||
for j in 0..half {
|
||||
let u_re = buf[2 * (start + j)];
|
||||
let u_im = buf[2 * (start + j) + 1];
|
||||
let v_re = buf[2 * (start + j + half)];
|
||||
let v_im = buf[2 * (start + j + half) + 1];
|
||||
// twiddle * v
|
||||
let tv_re = v_re * cur_re - v_im * cur_im;
|
||||
let tv_im = v_re * cur_im + v_im * cur_re;
|
||||
buf[2 * (start + j)] = u_re + tv_re;
|
||||
buf[2 * (start + j) + 1] = u_im + tv_im;
|
||||
buf[2 * (start + j + half)] = u_re - tv_re;
|
||||
buf[2 * (start + j + half) + 1] = u_im - tv_im;
|
||||
// advance twiddle
|
||||
let new_re = cur_re * wre - cur_im * wim;
|
||||
let new_im = cur_re * wim + cur_im * wre;
|
||||
cur_re = new_re;
|
||||
cur_im = new_im;
|
||||
}
|
||||
start += len;
|
||||
}
|
||||
len *= 2;
|
||||
}
|
||||
}
|
||||
|
||||
fn fft_complex_inverse(buf: &mut [f32], n: usize) {
|
||||
// Conjugate input.
|
||||
for k in 0..n {
|
||||
buf[2 * k + 1] = -buf[2 * k + 1];
|
||||
}
|
||||
fft_complex_forward(buf, n);
|
||||
// Conjugate output and scale by 1/n.
|
||||
let scale = 1.0 / n as f32;
|
||||
for k in 0..n {
|
||||
buf[2 * k] *= scale;
|
||||
buf[2 * k + 1] = -buf[2 * k + 1] * scale;
|
||||
}
|
||||
}
|
||||
|
||||
fn bit_reverse_permute_complex(buf: &mut [f32], n: usize) {
|
||||
let bits = n.trailing_zeros() as usize;
|
||||
for i in 0..n {
|
||||
let j = reverse_bits(i, bits);
|
||||
if j > i {
|
||||
buf.swap(2 * i, 2 * j);
|
||||
buf.swap(2 * i + 1, 2 * j + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reverse_bits(mut x: usize, bits: usize) -> usize {
|
||||
let mut r = 0usize;
|
||||
for _ in 0..bits {
|
||||
r = (r << 1) | (x & 1);
|
||||
x >>= 1;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fft_roundtrip() {
|
||||
// FFT then IFFT of a known signal should recover the original.
|
||||
let mut buf = vec![0.0_f32; 2 * 8];
|
||||
// Input: [1, 2, 3, 4, 0, 0, 0, 0] (real only)
|
||||
for i in 0..4 {
|
||||
buf[2 * i] = (i + 1) as f32;
|
||||
}
|
||||
let original: Vec<f32> = buf.iter().step_by(2).take(8).copied().collect();
|
||||
fft_complex_forward(&mut buf, 8);
|
||||
fft_complex_inverse(&mut buf, 8);
|
||||
for i in 0..8 {
|
||||
assert!(
|
||||
(buf[2 * i] - original[i]).abs() < 1e-4,
|
||||
"roundtrip failed at {i}: got {} expected {}",
|
||||
buf[2 * i],
|
||||
original[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ns_reduces_stationary_noise() {
|
||||
let mut ns = NoiseSuppressor::new();
|
||||
let mut rng: u32 = 0xDEAD_BEEF;
|
||||
let noise_frame = |rng: &mut u32| -> [f32; FRAME_SAMPLES] {
|
||||
let mut f = [0.0_f32; FRAME_SAMPLES];
|
||||
for s in f.iter_mut() {
|
||||
*rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
*s = (*rng as i32 as f32) / i32::MAX as f32 * 0.05;
|
||||
}
|
||||
f
|
||||
};
|
||||
// Warm up noise floor (20 frames cold-start + 10 more to converge).
|
||||
for _ in 0..30 {
|
||||
let mut frame = noise_frame(&mut rng);
|
||||
ns.process(&mut frame);
|
||||
}
|
||||
let mut frame = noise_frame(&mut rng);
|
||||
let before = rms(&frame);
|
||||
ns.process(&mut frame);
|
||||
let after = rms(&frame);
|
||||
assert!(
|
||||
after < before * 0.8,
|
||||
"NS did not suppress noise: before={before:.4} after={after:.4}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ns_disabled_is_passthrough() {
|
||||
let mut ns = NoiseSuppressor::new();
|
||||
ns.set_enabled(false);
|
||||
let mut frame = [0.1_f32; FRAME_SAMPLES];
|
||||
ns.process(&mut frame);
|
||||
assert!(frame.iter().all(|&s| (s - 0.1).abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ns = NoiseSuppressor::new();
|
||||
for _ in 0..30 {
|
||||
let mut frame = [0.05_f32; FRAME_SAMPLES];
|
||||
ns.process(&mut frame);
|
||||
}
|
||||
ns.reset();
|
||||
assert_eq!(ns.frame_count, 0);
|
||||
assert!(ns.ola_tail.iter().all(|&s| s == 0.0));
|
||||
}
|
||||
|
||||
fn rms(frame: &[f32]) -> f32 {
|
||||
(frame.iter().map(|s| s * s).sum::<f32>() / frame.len() as f32).sqrt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Realtime-safe audio processors for platform and software voice paths.
|
||||
|
||||
pub mod dsp;
|
||||
pub mod noop;
|
||||
pub mod platform;
|
||||
pub mod sonora;
|
||||
|
||||
pub use noop::NoopProcessor;
|
||||
pub use platform::PlatformVoiceProcessor;
|
||||
pub use sonora::SonoraProcessor;
|
||||
|
||||
/// 10 ms mono f32 processing frame at 48 kHz (480 samples).
|
||||
pub const FRAME_SAMPLES: usize = 480;
|
||||
|
||||
/// Realtime-safe audio processor backend.
|
||||
///
|
||||
/// Implementations MUST be `Send` and MUST NOT allocate, block, or
|
||||
/// perform I/O inside `process_capture` or `process_render`.
|
||||
pub trait AudioProcessor: Send {
|
||||
/// Process one 10 ms capture frame in-place.
|
||||
fn process_capture(&mut self, frame: &mut [f32; FRAME_SAMPLES]);
|
||||
|
||||
/// Feed one 10 ms render-reference frame (decoded remote PCM
|
||||
/// before playout). Required by software AEC backends; no-op
|
||||
/// for platform and noop backends.
|
||||
fn process_render(&mut self, frame: &[f32; FRAME_SAMPLES]);
|
||||
|
||||
/// Return true if this backend performs acoustic echo cancellation
|
||||
/// so the engine can enforce INV_009 / INV_010.
|
||||
fn has_aec(&self) -> bool;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Processor backend that intentionally leaves audio unchanged.
|
||||
|
||||
use super::{AudioProcessor, FRAME_SAMPLES};
|
||||
|
||||
/// No-op audio processor for debug/headset routes.
|
||||
pub struct NoopProcessor;
|
||||
|
||||
impl AudioProcessor for NoopProcessor {
|
||||
fn process_capture(&mut self, _frame: &mut [f32; FRAME_SAMPLES]) {}
|
||||
|
||||
fn process_render(&mut self, _frame: &[f32; FRAME_SAMPLES]) {}
|
||||
|
||||
fn has_aec(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Platform-owned voice processing backend.
|
||||
|
||||
use super::{AudioProcessor, FRAME_SAMPLES};
|
||||
|
||||
/// Marker backend for the platform VoiceProcessingIO path.
|
||||
/// All DSP (AEC/NS/AGC) is handled by the hardware voice processor;
|
||||
/// Rust-side processing is a no-op. `has_aec` returns true so the
|
||||
/// engine enforces INV_009/INV_010 and never enables Rust AEC
|
||||
/// simultaneously.
|
||||
pub struct PlatformVoiceProcessor;
|
||||
|
||||
impl AudioProcessor for PlatformVoiceProcessor {
|
||||
fn process_capture(&mut self, _frame: &mut [f32; FRAME_SAMPLES]) {}
|
||||
|
||||
fn process_render(&mut self, _frame: &[f32; FRAME_SAMPLES]) {}
|
||||
|
||||
fn has_aec(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Sonora software voice processor — full DSP chain.
|
||||
//!
|
||||
//! Composes the four P1 DSP stages in the order mandated by the spec:
|
||||
//!
|
||||
//! **HPF → AEC3 → NS → AGC2**
|
||||
//!
|
||||
//! Each stage is independently enable/disable-able via
|
||||
//! [`SonoraConfig`]. The default configuration matches the P1 spec:
|
||||
//! all stages enabled, AEC3 disabled when no render reference is
|
||||
//! available (INV_011).
|
||||
//!
|
||||
//! ## Stage descriptions
|
||||
//!
|
||||
//! | Stage | Module | Description |
|
||||
//! |-------|--------|-------------|
|
||||
//! | HPF | `dsp::hpf` | 80 Hz Butterworth biquad, removes DC and rumble |
|
||||
//! | AEC3 | `dsp::aec3` | Adaptive filter echo canceller with delay estimation |
|
||||
//! | NS | `dsp::ns` | Wiener filter noise suppressor with min-statistics floor |
|
||||
//! | AGC2 | `dsp::agc2` | RNN VAD-gated gain controller with look-ahead limiter |
|
||||
//!
|
||||
//! ## Realtime safety
|
||||
//!
|
||||
//! All state is pre-allocated. `process_capture` and `process_render`
|
||||
//! never allocate, block, or perform I/O (INV_007).
|
||||
//!
|
||||
//! ## INV_009 / INV_010 enforcement
|
||||
//!
|
||||
//! `has_aec()` returns `true` when AEC3 is enabled. The engine uses
|
||||
//! this to enforce the invariant that platform AEC and Rust AEC are
|
||||
//! never active simultaneously.
|
||||
|
||||
use super::dsp::{aec3::Aec3, agc2::Agc2, hpf::HighPassFilter, ns::NoiseSuppressor};
|
||||
use super::{AudioProcessor, FRAME_SAMPLES};
|
||||
|
||||
/// Per-stage enable flags for the Sonora processor.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SonoraConfig {
|
||||
/// High-pass filter (80 Hz Butterworth). Default: enabled.
|
||||
pub hpf: bool,
|
||||
/// AEC3 adaptive echo canceller. Default: disabled until render
|
||||
/// reference is confirmed available (INV_011).
|
||||
pub aec3: bool,
|
||||
/// Wiener filter noise suppressor. Default: enabled.
|
||||
pub ns: bool,
|
||||
/// AGC2 gain controller + limiter. Default: enabled.
|
||||
pub agc2: bool,
|
||||
}
|
||||
|
||||
impl Default for SonoraConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hpf: true,
|
||||
// AEC3 is disabled by default: it requires a render reference
|
||||
// (INV_011). The engine enables it only when the render
|
||||
// reference path is confirmed active.
|
||||
aec3: false,
|
||||
ns: true,
|
||||
agc2: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SonoraConfig {
|
||||
/// Configuration for the Sonora experimental mode with AEC3 enabled.
|
||||
/// Only valid when a render reference is available (INV_011).
|
||||
pub fn with_aec3() -> Self {
|
||||
Self {
|
||||
hpf: true,
|
||||
aec3: true,
|
||||
ns: true,
|
||||
agc2: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal configuration: HPF + AGC2 only (no AEC, no NS).
|
||||
/// Suitable for wired headset routes where AEC is not needed.
|
||||
pub fn headset() -> Self {
|
||||
Self {
|
||||
hpf: true,
|
||||
aec3: false,
|
||||
ns: false,
|
||||
agc2: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full Sonora DSP chain: HPF → AEC3 → NS → AGC2.
|
||||
pub struct SonoraProcessor {
|
||||
hpf: HighPassFilter,
|
||||
aec3: Aec3,
|
||||
ns: NoiseSuppressor,
|
||||
agc2: Agc2,
|
||||
config: SonoraConfig,
|
||||
}
|
||||
|
||||
impl SonoraProcessor {
|
||||
/// Construct with the default configuration (AEC3 disabled).
|
||||
pub fn new() -> Self {
|
||||
let config = SonoraConfig::default();
|
||||
let mut aec3 = Aec3::new();
|
||||
aec3.set_enabled(config.aec3);
|
||||
let mut ns = NoiseSuppressor::new();
|
||||
ns.set_enabled(config.ns);
|
||||
let mut agc2 = Agc2::new();
|
||||
agc2.set_enabled(config.agc2);
|
||||
Self {
|
||||
hpf: HighPassFilter::new(),
|
||||
aec3,
|
||||
ns,
|
||||
agc2,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct with a specific configuration.
|
||||
pub fn with_config(config: SonoraConfig) -> Self {
|
||||
let mut aec3 = Aec3::new();
|
||||
aec3.set_enabled(config.aec3);
|
||||
let mut ns = NoiseSuppressor::new();
|
||||
ns.set_enabled(config.ns);
|
||||
let mut agc2 = Agc2::new();
|
||||
agc2.set_enabled(config.agc2);
|
||||
Self {
|
||||
hpf: HighPassFilter::new(),
|
||||
aec3,
|
||||
ns,
|
||||
agc2,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a new configuration at runtime. Resets stages whose
|
||||
/// enable state changed to avoid state contamination.
|
||||
pub fn apply_config(&mut self, new_config: SonoraConfig) {
|
||||
if new_config.hpf != self.config.hpf {
|
||||
self.hpf.reset();
|
||||
}
|
||||
if new_config.aec3 != self.config.aec3 {
|
||||
self.aec3.reset();
|
||||
self.aec3.set_enabled(new_config.aec3);
|
||||
}
|
||||
if new_config.ns != self.config.ns {
|
||||
self.ns.reset();
|
||||
self.ns.set_enabled(new_config.ns);
|
||||
}
|
||||
if new_config.agc2 != self.config.agc2 {
|
||||
self.agc2.reset();
|
||||
self.agc2.set_enabled(new_config.agc2);
|
||||
}
|
||||
self.config = new_config;
|
||||
}
|
||||
|
||||
/// Current configuration.
|
||||
pub fn config(&self) -> &SonoraConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Reset all DSP state (call on route change or session restart).
|
||||
pub fn reset_all(&mut self) {
|
||||
self.hpf.reset();
|
||||
self.aec3.reset();
|
||||
self.ns.reset();
|
||||
self.agc2.reset();
|
||||
}
|
||||
|
||||
/// Current AEC3 bulk delay estimate in blocks (1 block = 10 ms).
|
||||
pub fn aec3_bulk_delay_blocks(&self) -> usize {
|
||||
self.aec3.bulk_delay_blocks()
|
||||
}
|
||||
|
||||
/// True if AEC3 has converged.
|
||||
pub fn aec3_converged(&self) -> bool {
|
||||
self.aec3.is_converged()
|
||||
}
|
||||
|
||||
/// Current AGC2 speech probability from the RNN VAD.
|
||||
pub fn agc2_speech_probability(&self) -> f32 {
|
||||
self.agc2.speech_probability()
|
||||
}
|
||||
|
||||
/// Current AGC2 gain in dB.
|
||||
pub fn agc2_gain_db(&self) -> f32 {
|
||||
self.agc2.gain_db()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SonoraProcessor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioProcessor for SonoraProcessor {
|
||||
/// Process one 10 ms capture frame in-place.
|
||||
///
|
||||
/// Pipeline: HPF → AEC3 → NS → AGC2.
|
||||
fn process_capture(&mut self, frame: &mut [f32; FRAME_SAMPLES]) {
|
||||
// Stage 1: High-pass filter (DC removal, rumble suppression).
|
||||
if self.config.hpf {
|
||||
self.hpf.process(frame);
|
||||
}
|
||||
|
||||
// Stage 2: AEC3 (echo cancellation).
|
||||
// AEC3 reads the render reference that was fed via process_render.
|
||||
// INV_011: only runs when aec3 is enabled (render reference available).
|
||||
self.aec3.process_capture(frame);
|
||||
|
||||
// Stage 3: Noise suppression (Wiener filter).
|
||||
self.ns.process(frame);
|
||||
|
||||
// Stage 4: AGC2 (gain control + limiter).
|
||||
self.agc2.process(frame);
|
||||
}
|
||||
|
||||
/// Feed one 10 ms render-reference frame (decoded remote PCM
|
||||
/// before playout). Required by AEC3 (INV_012).
|
||||
fn process_render(&mut self, frame: &[f32; FRAME_SAMPLES]) {
|
||||
self.aec3.process_render(frame);
|
||||
}
|
||||
|
||||
/// True when AEC3 is enabled (enforces INV_009 / INV_010).
|
||||
fn has_aec(&self) -> bool {
|
||||
self.config.aec3
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_config_has_aec3_disabled() {
|
||||
let p = SonoraProcessor::new();
|
||||
assert!(!p.has_aec(), "AEC3 must be disabled by default (INV_010)");
|
||||
assert!(p.config().hpf);
|
||||
assert!(p.config().ns);
|
||||
assert!(p.config().agc2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_aec3_config_enables_aec() {
|
||||
let p = SonoraProcessor::with_config(SonoraConfig::with_aec3());
|
||||
assert!(p.has_aec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_capture_does_not_panic_on_silence() {
|
||||
let mut p = SonoraProcessor::new();
|
||||
let mut frame = [0.0_f32; FRAME_SAMPLES];
|
||||
p.process_capture(&mut frame);
|
||||
assert!(frame.iter().all(|s| s.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_capture_does_not_panic_on_loud_signal() {
|
||||
let mut p = SonoraProcessor::new();
|
||||
let mut frame = [1.0_f32; FRAME_SAMPLES];
|
||||
p.process_capture(&mut frame);
|
||||
assert!(frame.iter().all(|s| s.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hpf_removes_dc() {
|
||||
let mut p = SonoraProcessor::with_config(SonoraConfig {
|
||||
hpf: true,
|
||||
aec3: false,
|
||||
ns: false,
|
||||
agc2: false,
|
||||
});
|
||||
// Feed 200 frames of DC = 0.5.
|
||||
let mut frame = [0.5_f32; FRAME_SAMPLES];
|
||||
for _ in 0..200 {
|
||||
p.process_capture(&mut frame);
|
||||
}
|
||||
// After convergence, DC should be near zero.
|
||||
let mean: f32 = frame.iter().sum::<f32>() / FRAME_SAMPLES as f32;
|
||||
assert!(mean.abs() < 0.01, "DC not removed: mean={mean}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_config_resets_changed_stages() {
|
||||
let mut p = SonoraProcessor::new();
|
||||
// Run some frames to build up state.
|
||||
let mut frame = [0.1_f32; FRAME_SAMPLES];
|
||||
for _ in 0..10 {
|
||||
p.process_capture(&mut frame);
|
||||
}
|
||||
// Enable AEC3 — should reset AEC3 state.
|
||||
p.apply_config(SonoraConfig::with_aec3());
|
||||
assert!(p.has_aec());
|
||||
assert!(!p.aec3_converged()); // reset clears convergence
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppresses_stationary_noise() {
|
||||
let mut p = SonoraProcessor::with_config(SonoraConfig {
|
||||
hpf: false,
|
||||
aec3: false,
|
||||
ns: true,
|
||||
agc2: false,
|
||||
});
|
||||
let mut rng: u32 = 0xABCD_1234;
|
||||
let noise_frame = |rng: &mut u32| -> [f32; FRAME_SAMPLES] {
|
||||
let mut f = [0.0_f32; FRAME_SAMPLES];
|
||||
for s in f.iter_mut() {
|
||||
*rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
*s = (*rng as i32 as f32) / i32::MAX as f32 * 0.05;
|
||||
}
|
||||
f
|
||||
};
|
||||
// Warm up noise floor.
|
||||
for _ in 0..30 {
|
||||
let mut frame = noise_frame(&mut rng);
|
||||
p.process_capture(&mut frame);
|
||||
}
|
||||
let mut frame = noise_frame(&mut rng);
|
||||
let before = rms(&frame);
|
||||
p.process_capture(&mut frame);
|
||||
let after = rms(&frame);
|
||||
assert!(
|
||||
after < before,
|
||||
"NS did not suppress noise: {before:.4} → {after:.4}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_amplifies_quiet_signal() {
|
||||
let mut p = SonoraProcessor::with_config(SonoraConfig {
|
||||
hpf: false,
|
||||
aec3: false,
|
||||
ns: false,
|
||||
agc2: true,
|
||||
});
|
||||
let mut frame = [0.0_f32; FRAME_SAMPLES];
|
||||
for (i, sample) in frame.iter_mut().enumerate() {
|
||||
*sample = (2.0 * std::f32::consts::PI * 300.0 * i as f32 / 48_000.0).sin() * 0.01;
|
||||
}
|
||||
let before = rms(&frame);
|
||||
for _ in 0..50 {
|
||||
p.process_capture(&mut frame);
|
||||
}
|
||||
let after = rms(&frame);
|
||||
assert!(
|
||||
after > before,
|
||||
"AGC did not amplify: {before:.4} → {after:.4}"
|
||||
);
|
||||
}
|
||||
|
||||
fn rms(frame: &[f32]) -> f32 {
|
||||
let power = frame.iter().map(|s| s * s).sum::<f32>() / frame.len() as f32;
|
||||
power.sqrt()
|
||||
}
|
||||
}
|
||||
@@ -774,7 +774,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_descriptor_granted_keyboard_reports_L2() {
|
||||
fn build_descriptor_granted_keyboard_reports_l2() {
|
||||
let d = MacOSEventTapBackend::build_descriptor(
|
||||
PermissionState::Granted,
|
||||
PttInputClass::Keyboard,
|
||||
@@ -784,7 +784,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_descriptor_granted_mouse_reports_L3() {
|
||||
fn build_descriptor_granted_mouse_reports_l3() {
|
||||
let d = MacOSEventTapBackend::build_descriptor(
|
||||
PermissionState::Granted,
|
||||
PttInputClass::MouseSideButton,
|
||||
@@ -794,7 +794,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_descriptor_granted_none_reports_L2_keyboard() {
|
||||
fn build_descriptor_granted_none_reports_l2_keyboard() {
|
||||
let d =
|
||||
MacOSEventTapBackend::build_descriptor(PermissionState::Granted, PttInputClass::None);
|
||||
assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Route-to-processing policy for P1 iOS.
|
||||
//!
|
||||
//! Maps the current [`AudioRoute`] to the recommended
|
||||
//! [`AudioProcessingConfig`] for that route. The policy enforces
|
||||
//! INV_009 (never enable platform AEC and Rust AEC simultaneously)
|
||||
//! and INV_010 (never enable VoiceProcessingIO and Sonora AEC3
|
||||
//! simultaneously).
|
||||
//!
|
||||
//! The returned config is a *recommendation*; the engine may override
|
||||
//! individual fields (e.g. to keep the user's explicit VAD backend
|
||||
//! choice) but must not violate the hard invariants.
|
||||
|
||||
use crate::audio_processing::{
|
||||
AudioBackend, AudioProcessingConfig, AudioRoute, EffectOwner, IosVoiceProcessingMode,
|
||||
VadBackend,
|
||||
};
|
||||
|
||||
/// Compute the recommended [`AudioProcessingConfig`] for a given
|
||||
/// iOS audio route. The returned config always satisfies the P1
|
||||
/// hard invariants for iOS.
|
||||
///
|
||||
/// * Speaker / Earpiece → platform VPIO (AEC/NS/AGC owned by platform).
|
||||
/// * Wired headset → noop AEC, conservative NS/AGC optional.
|
||||
/// * Bluetooth HFP → route-managed (app-side AEC off, NS/AGC conservative).
|
||||
/// * Bluetooth A2DP → invalid for duplex; transmit blocked at selector level.
|
||||
/// * Unknown → safe fallback (AEC off until classified).
|
||||
pub fn ios_route_policy(route: AudioRoute) -> AudioProcessingConfig {
|
||||
match route {
|
||||
AudioRoute::Speaker | AudioRoute::Earpiece => AudioProcessingConfig {
|
||||
route,
|
||||
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
||||
vad_backend: VadBackend::SileroOnnx,
|
||||
aec: EffectOwner::Platform,
|
||||
// VPIO owns NS and AGC on the shipping default path (IOSP_002/003).
|
||||
ns: EffectOwner::Platform,
|
||||
agc: EffectOwner::Platform,
|
||||
hpf_enabled: true,
|
||||
limiter_enabled: true,
|
||||
..AudioProcessingConfig::default()
|
||||
},
|
||||
AudioRoute::WiredHeadset => AudioProcessingConfig {
|
||||
route,
|
||||
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||
processing_backend: AudioBackend::Noop,
|
||||
vad_backend: VadBackend::SileroOnnx,
|
||||
// No AEC needed for wired headset (no acoustic echo path).
|
||||
aec: EffectOwner::Off,
|
||||
// Conservative NS/AGC: optional, not forced.
|
||||
ns: EffectOwner::Conservative,
|
||||
agc: EffectOwner::Conservative,
|
||||
hpf_enabled: true,
|
||||
limiter_enabled: true,
|
||||
..AudioProcessingConfig::default()
|
||||
},
|
||||
AudioRoute::BluetoothHfp => AudioProcessingConfig {
|
||||
route,
|
||||
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
||||
vad_backend: VadBackend::SileroOnnx,
|
||||
// BT HFP manages its own AEC in the headset firmware.
|
||||
aec: EffectOwner::Off,
|
||||
ns: EffectOwner::Conservative,
|
||||
agc: EffectOwner::Conservative,
|
||||
hpf_enabled: true,
|
||||
limiter_enabled: true,
|
||||
..AudioProcessingConfig::default()
|
||||
},
|
||||
AudioRoute::BluetoothA2dp => {
|
||||
// A2DP is output-only; duplex voice is invalid on this route.
|
||||
// Return a config that disables all processing and VAD.
|
||||
// The transmit selector will block transmit via the route check.
|
||||
AudioProcessingConfig {
|
||||
route,
|
||||
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||
processing_backend: AudioBackend::Noop,
|
||||
vad_backend: VadBackend::Disabled,
|
||||
aec: EffectOwner::Off,
|
||||
ns: EffectOwner::Off,
|
||||
agc: EffectOwner::Off,
|
||||
hpf_enabled: false,
|
||||
limiter_enabled: false,
|
||||
..AudioProcessingConfig::default()
|
||||
}
|
||||
}
|
||||
AudioRoute::Unknown => AudioProcessingConfig {
|
||||
route,
|
||||
ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||
processing_backend: AudioBackend::Noop,
|
||||
vad_backend: VadBackend::SileroOnnx,
|
||||
// Safe fallback: AEC off until route is classified.
|
||||
aec: EffectOwner::Off,
|
||||
ns: EffectOwner::Off,
|
||||
agc: EffectOwner::Off,
|
||||
hpf_enabled: true,
|
||||
limiter_enabled: true,
|
||||
..AudioProcessingConfig::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a route change to an existing config, preserving user-chosen
|
||||
/// VAD backend, timing, and debug settings while updating the
|
||||
/// route-dependent policy fields.
|
||||
pub fn apply_route_change(
|
||||
existing: &AudioProcessingConfig,
|
||||
new_route: AudioRoute,
|
||||
) -> AudioProcessingConfig {
|
||||
let policy = ios_route_policy(new_route);
|
||||
AudioProcessingConfig {
|
||||
// Route-policy fields from the new route.
|
||||
route: policy.route,
|
||||
ios_mode: policy.ios_mode,
|
||||
processing_backend: policy.processing_backend,
|
||||
aec: policy.aec,
|
||||
ns: policy.ns,
|
||||
agc: policy.agc,
|
||||
hpf_enabled: policy.hpf_enabled,
|
||||
limiter_enabled: policy.limiter_enabled,
|
||||
// Preserve user-chosen VAD backend and timing.
|
||||
vad_backend: existing.vad_backend,
|
||||
vad_hangover_ms: existing.vad_hangover_ms,
|
||||
vad_pre_roll_ms: existing.vad_pre_roll_ms,
|
||||
vad_min_tx_ms: existing.vad_min_tx_ms,
|
||||
// Preserve debug settings.
|
||||
debug_wav_dump_enabled: existing.debug_wav_dump_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn speaker_uses_platform_vpio() {
|
||||
let cfg = ios_route_policy(AudioRoute::Speaker);
|
||||
assert_eq!(
|
||||
cfg.processing_backend,
|
||||
AudioBackend::PlatformVoiceProcessing
|
||||
);
|
||||
assert_eq!(cfg.aec, EffectOwner::Platform);
|
||||
// VPIO owns NS and AGC on the default path.
|
||||
assert_eq!(cfg.ns, EffectOwner::Platform);
|
||||
assert_eq!(cfg.agc, EffectOwner::Platform);
|
||||
assert_eq!(
|
||||
cfg.ios_mode,
|
||||
IosVoiceProcessingMode::PlatformVoiceProcessing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earpiece_uses_platform_vpio() {
|
||||
let cfg = ios_route_policy(AudioRoute::Earpiece);
|
||||
assert_eq!(
|
||||
cfg.processing_backend,
|
||||
AudioBackend::PlatformVoiceProcessing
|
||||
);
|
||||
assert_eq!(cfg.aec, EffectOwner::Platform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wired_headset_disables_aec() {
|
||||
let cfg = ios_route_policy(AudioRoute::WiredHeadset);
|
||||
assert_eq!(cfg.aec, EffectOwner::Off);
|
||||
assert_eq!(cfg.processing_backend, AudioBackend::Noop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_hfp_disables_app_aec() {
|
||||
let cfg = ios_route_policy(AudioRoute::BluetoothHfp);
|
||||
assert_eq!(cfg.aec, EffectOwner::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_a2dp_disables_all_processing_and_vad() {
|
||||
let cfg = ios_route_policy(AudioRoute::BluetoothA2dp);
|
||||
assert_eq!(cfg.aec, EffectOwner::Off);
|
||||
assert_eq!(cfg.vad_backend, VadBackend::Disabled);
|
||||
assert_eq!(cfg.processing_backend, AudioBackend::Noop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_route_safe_fallback_no_aec() {
|
||||
let cfg = ios_route_policy(AudioRoute::Unknown);
|
||||
assert_eq!(cfg.aec, EffectOwner::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_route_change_preserves_vad_timing_and_debug() {
|
||||
let existing = AudioProcessingConfig {
|
||||
vad_backend: VadBackend::WebrtcVad,
|
||||
vad_hangover_ms: 600,
|
||||
vad_pre_roll_ms: 200,
|
||||
vad_min_tx_ms: 300,
|
||||
debug_wav_dump_enabled: true,
|
||||
..AudioProcessingConfig::default()
|
||||
};
|
||||
let updated = apply_route_change(&existing, AudioRoute::WiredHeadset);
|
||||
assert_eq!(updated.vad_backend, VadBackend::WebrtcVad);
|
||||
assert_eq!(updated.vad_hangover_ms, 600);
|
||||
assert_eq!(updated.vad_pre_roll_ms, 200);
|
||||
assert_eq!(updated.vad_min_tx_ms, 300);
|
||||
assert!(updated.debug_wav_dump_enabled);
|
||||
assert_eq!(updated.route, AudioRoute::WiredHeadset);
|
||||
assert_eq!(updated.aec, EffectOwner::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_sonora_aec_in_platform_vpio_policy() {
|
||||
// INV_009: AEC must never be Sonora in the VPIO path.
|
||||
for route in [
|
||||
AudioRoute::Speaker,
|
||||
AudioRoute::Earpiece,
|
||||
AudioRoute::WiredHeadset,
|
||||
AudioRoute::BluetoothHfp,
|
||||
AudioRoute::BluetoothA2dp,
|
||||
AudioRoute::Unknown,
|
||||
] {
|
||||
let cfg = ios_route_policy(route);
|
||||
assert_ne!(
|
||||
cfg.processing_backend,
|
||||
AudioBackend::Sonora,
|
||||
"route {:?} must not use Sonora backend in platform policy",
|
||||
route
|
||||
);
|
||||
assert_ne!(
|
||||
cfg.aec,
|
||||
EffectOwner::Sonora,
|
||||
"route {:?}: AEC must not be Sonora in VPIO path (INV_009)",
|
||||
route
|
||||
);
|
||||
assert_ne!(
|
||||
cfg.ns,
|
||||
EffectOwner::Sonora,
|
||||
"route {:?}: NS must not be Sonora in VPIO path (IOSP_003)",
|
||||
route
|
||||
);
|
||||
assert_ne!(
|
||||
cfg.agc,
|
||||
EffectOwner::Sonora,
|
||||
"route {:?}: AGC must not be Sonora in VPIO path (IOSP_003)",
|
||||
route
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,33 +5,23 @@
|
||||
//! [`chanora_storage::IdentityFileStore`] under the `transmit_mode`
|
||||
//! metadata key (default [`TransmitMode::Ptt`]).
|
||||
//!
|
||||
//! `VoiceActivity` is reserved per DEC-030 — for v1 the
|
||||
//! [`crate::transmit_selector::TransmitModeSelector`] treats it
|
||||
//! exactly like [`TransmitMode::Continuous`] until a real VAD
|
||||
//! implementation lands.
|
||||
//! `VoiceActivity` is driven by Rust-owned VAD state in P1.
|
||||
|
||||
/// User-visible voice transmit mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum TransmitMode {
|
||||
/// Push-to-talk: transmit only while the bound key is held
|
||||
/// (with release-tail per SDD-096).
|
||||
#[default]
|
||||
Ptt = 0,
|
||||
/// Continuous: transmit whenever the user is in a voice
|
||||
/// channel and not hard-muted.
|
||||
Continuous = 1,
|
||||
/// Voice activity detection. Reserved per DEC-030; v1 behaves
|
||||
/// as [`TransmitMode::Continuous`] until a VAD implementation
|
||||
/// is allocated.
|
||||
/// Voice activity detection.
|
||||
VoiceActivity = 2,
|
||||
}
|
||||
|
||||
impl Default for TransmitMode {
|
||||
fn default() -> Self {
|
||||
Self::Ptt
|
||||
}
|
||||
}
|
||||
|
||||
impl TransmitMode {
|
||||
/// Encode as the persisted single-byte value.
|
||||
pub fn as_u8(self) -> u8 {
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
//! on PTT mode)
|
||||
//!
|
||||
//! Hard-mute is a final clamp; leaving the channel forces the gate
|
||||
//! to `false`. `VoiceActivity` is treated identically to
|
||||
//! `Continuous` per DEC-030 until a VAD implementation lands.
|
||||
//! to `false`. `VoiceActivity` is driven by Rust-owned VAD state.
|
||||
//!
|
||||
//! All four inputs are stored as atomics so any thread can update
|
||||
//! them without taking a lock. After each update we call
|
||||
@@ -92,6 +91,7 @@ pub struct TransmitModeSelector {
|
||||
in_channel: AtomicBool,
|
||||
hard_mute: AtomicBool,
|
||||
ptt_held: AtomicBool,
|
||||
voice_activity_open: AtomicBool,
|
||||
/// SDD-106 §5/§6 / SRS-209: latest resolved microphone
|
||||
/// permission state. Stored as a `u8` so writes from the
|
||||
/// JNI thread (Android permission requester → bridge) and
|
||||
@@ -133,6 +133,7 @@ impl TransmitModeSelector {
|
||||
in_channel: AtomicBool::new(false),
|
||||
hard_mute: AtomicBool::new(false),
|
||||
ptt_held: AtomicBool::new(false),
|
||||
voice_activity_open: AtomicBool::new(false),
|
||||
// SDD-106 §5: default to Granted on construction so
|
||||
// non-Android hosts (which never publish a permission
|
||||
// event) are not silently clamped. The Android bridge
|
||||
@@ -226,6 +227,17 @@ impl TransmitModeSelector {
|
||||
self.ptt_held.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Rust-owned VAD gate input for VoiceActivity mode.
|
||||
pub fn set_voice_activity_open(&self, v: bool) {
|
||||
self.voice_activity_open.store(v, Ordering::Relaxed);
|
||||
self.recompute();
|
||||
}
|
||||
|
||||
/// Current Rust-owned VAD gate state.
|
||||
pub fn voice_activity_open(&self) -> bool {
|
||||
self.voice_activity_open.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Subscribe to `ptt_held` transitions. Used by the
|
||||
/// missed-key-up watchdog (SAD-079) so it fires on the actual
|
||||
/// PTT-key-down lifetime, not on the resolved `transmit_active`
|
||||
@@ -265,8 +277,8 @@ impl TransmitModeSelector {
|
||||
}
|
||||
match self.mode() {
|
||||
TransmitMode::Ptt => self.ptt_held.load(Ordering::Relaxed),
|
||||
// DEC-030: VoiceActivity behaves as Continuous in v1.
|
||||
TransmitMode::Continuous | TransmitMode::VoiceActivity => true,
|
||||
TransmitMode::Continuous => true,
|
||||
TransmitMode::VoiceActivity => self.voice_activity_open.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,13 +335,17 @@ mod tests {
|
||||
assert!(g.load(), "continuous independent of key state");
|
||||
}
|
||||
|
||||
/// SWE4-UV-037: voice-activity mode matches continuous in v1.
|
||||
/// SWE4-UV-037: voice-activity mode follows VAD state.
|
||||
#[test]
|
||||
fn voice_activity_matches_continuous_v1() {
|
||||
fn voice_activity_requires_vad_open() {
|
||||
let (g, s) = fresh();
|
||||
s.set_mode(TransmitMode::VoiceActivity);
|
||||
s.set_in_channel(true);
|
||||
assert!(!g.load());
|
||||
s.set_voice_activity_open(true);
|
||||
assert!(g.load());
|
||||
s.set_voice_activity_open(false);
|
||||
assert!(!g.load());
|
||||
}
|
||||
|
||||
/// SWE4-UV-037 / SWE4-UV-041: hard-mute clamps the transmit
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Voice activity detection backends and helpers.
|
||||
//!
|
||||
//! iOS capture feeds VoiceProcessingIO-processed microphone frames into
|
||||
//! this module. The production path prefers a model-backed detector when
|
||||
//! available, and otherwise uses the realtime-safe fallback below so
|
||||
//! VoiceActivity mode never collapses back to Continuous transmit.
|
||||
|
||||
pub mod resampler;
|
||||
pub mod silero_onnx;
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
use crate::frame::{dbfs, i16_to_f32};
|
||||
use crate::AudioError;
|
||||
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
||||
|
||||
pub use silero_onnx::SileroOnnxVad;
|
||||
|
||||
/// Voice activity detector output for one 10 ms frame.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct VadOutput {
|
||||
/// Speech confidence in the inclusive range `[0.0, 1.0]`.
|
||||
pub probability: f32,
|
||||
/// Immediate detector speech decision before hangover/min-duration state.
|
||||
pub speech: bool,
|
||||
}
|
||||
|
||||
/// Realtime-safe detector that consumes one 10 ms f32 mono frame.
|
||||
pub trait VoiceActivityDetector: Send {
|
||||
/// Process one 10 ms frame and return speech probability/state.
|
||||
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput;
|
||||
}
|
||||
|
||||
/// Realtime-safe fallback VAD used when a model runtime is unavailable.
|
||||
///
|
||||
/// This is not an energy-only transmit gate. It combines RMS level,
|
||||
/// zero-crossing rate, and peak-to-RMS shape with hysteresis so stable
|
||||
/// background rumble is less likely to open VoiceActivity than speech.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebRtcFallbackVad {
|
||||
open_dbfs: f32,
|
||||
close_dbfs: f32,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl Default for WebRtcFallbackVad {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
open_dbfs: -42.0,
|
||||
close_dbfs: -50.0,
|
||||
active: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebRtcFallbackVad {
|
||||
fn zero_crossing_rate(samples: &[f32]) -> f32 {
|
||||
if samples.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let crossings = samples
|
||||
.windows(2)
|
||||
.filter(|pair| (pair[0] >= 0.0 && pair[1] < 0.0) || (pair[0] < 0.0 && pair[1] >= 0.0))
|
||||
.count();
|
||||
crossings as f32 / (samples.len() - 1) as f32
|
||||
}
|
||||
|
||||
fn peak_to_rms(samples: &[f32], rms: f32) -> f32 {
|
||||
if rms <= 0.000_001 {
|
||||
return 0.0;
|
||||
}
|
||||
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
|
||||
peak / rms
|
||||
}
|
||||
}
|
||||
|
||||
impl VoiceActivityDetector for WebRtcFallbackVad {
|
||||
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput {
|
||||
let level = dbfs(samples);
|
||||
let threshold = if self.active {
|
||||
self.close_dbfs
|
||||
} else {
|
||||
self.open_dbfs
|
||||
};
|
||||
let rms = samples.iter().map(|s| s * s).sum::<f32>() / samples.len().max(1) as f32;
|
||||
let rms = rms.sqrt();
|
||||
let zcr = Self::zero_crossing_rate(samples);
|
||||
let crest = Self::peak_to_rms(samples, rms);
|
||||
|
||||
// Level score: steeper curve so silence (-50 dBFS) scores near 0.
|
||||
// Speech is typically -30 to -10 dBFS; silence is -60 to -45 dBFS.
|
||||
// Map [-60, -20] → [0, 1] with a midpoint at -40 dBFS.
|
||||
let level_score = ((level + 60.0) / 40.0).clamp(0.0, 1.0);
|
||||
|
||||
let zcr_score = if (0.015..=0.32).contains(&zcr) {
|
||||
1.0
|
||||
} else {
|
||||
0.3 // penalise non-speech ZCR more aggressively
|
||||
};
|
||||
let crest_score = if (1.5..=12.0).contains(&crest) {
|
||||
1.0
|
||||
} else {
|
||||
0.3
|
||||
};
|
||||
let probability =
|
||||
(level_score * 0.72 + zcr_score * 0.18 + crest_score * 0.10).clamp(0.0, 1.0);
|
||||
self.active = level >= threshold && probability >= 0.5;
|
||||
VadOutput {
|
||||
probability,
|
||||
speech: self.active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps any `VoiceActivityDetector` that operates at 16 kHz and
|
||||
/// downsamples 48 kHz input before forwarding.
|
||||
pub struct Resampled16kHzVad<D: VoiceActivityDetector> {
|
||||
inner: D,
|
||||
downsampler: Downsampler48to16,
|
||||
}
|
||||
|
||||
impl<D: VoiceActivityDetector> Resampled16kHzVad<D> {
|
||||
/// Wrap a 16 kHz detector so it can consume 48 kHz frames.
|
||||
pub fn new(inner: D) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
downsampler: Downsampler48to16::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: VoiceActivityDetector> VoiceActivityDetector for Resampled16kHzVad<D> {
|
||||
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput {
|
||||
debug_assert_eq!(samples.len(), INPUT_FRAME_10MS);
|
||||
let mut input = [0.0_f32; INPUT_FRAME_10MS];
|
||||
input.copy_from_slice(samples);
|
||||
let downsampled = self.downsampler.process_frame_10ms(&input);
|
||||
self.inner.process_10ms(&downsampled)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert one 48 kHz i16 10 ms frame and run a detector over it.
|
||||
pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16]) -> VadOutput {
|
||||
let mut frame = [0.0_f32; INPUT_FRAME_10MS];
|
||||
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
|
||||
*dst = i16_to_f32(src);
|
||||
}
|
||||
detector.process_10ms(&frame)
|
||||
}
|
||||
|
||||
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
||||
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn silero_model_path_override() -> &'static RwLock<Option<String>> {
|
||||
SILERO_MODEL_PATH_OVERRIDE.get_or_init(|| RwLock::new(None))
|
||||
}
|
||||
|
||||
/// Configure the preferred Silero ONNX model path.
|
||||
///
|
||||
/// The path is validated eagerly. A successful call increments the
|
||||
/// model epoch so running audio backends can reload the model without
|
||||
/// an app restart.
|
||||
pub fn set_silero_model_path(path: &str) -> Result<(), AudioError> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||
"vad model path must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if !std::path::Path::new(path).is_file() {
|
||||
return Err(AudioError::InvalidAudioProcessingConfig(format!(
|
||||
"vad model path does not exist or is not a file: {path}"
|
||||
)));
|
||||
}
|
||||
let mut guard = silero_model_path_override()
|
||||
.write()
|
||||
.map_err(|_| AudioError::Backend("vad model path lock poisoned".to_string()))?;
|
||||
*guard = Some(path.to_string());
|
||||
SILERO_MODEL_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Monotonic counter incremented whenever the configured model path changes.
|
||||
pub fn silero_model_epoch() -> u64 {
|
||||
SILERO_MODEL_EPOCH.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Return the expected path of the Silero VAD v6 ONNX model in the
|
||||
/// iOS app bundle. The model is shipped as a Flutter asset and copied
|
||||
/// to the app's Documents directory by the Dart-side asset loader.
|
||||
///
|
||||
/// Returns an empty string on non-Apple platforms (Silero is not
|
||||
/// supported there; `SileroOnnxVad::try_new` will return `None`).
|
||||
pub fn silero_model_bundle_path() -> String {
|
||||
if let Ok(guard) = silero_model_path_override().read() {
|
||||
if let Some(path) = guard.as_ref() {
|
||||
return path.clone();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
{
|
||||
// Primary: Documents directory (written by Flutter asset loader).
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let docs = format!("{home}/Documents/silero_vad.onnx");
|
||||
if std::path::Path::new(&docs).exists() {
|
||||
return docs;
|
||||
}
|
||||
// Fallback: app bundle Resources directory.
|
||||
let bundle = format!("{home}/../Library/silero_vad.onnx");
|
||||
if std::path::Path::new(&bundle).exists() {
|
||||
return bundle;
|
||||
}
|
||||
}
|
||||
// Last resort: current working directory (useful in tests).
|
||||
"silero_vad.onnx".to_string()
|
||||
}
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fallback_opens_for_voiced_signal() {
|
||||
let mut vad = WebRtcFallbackVad::default();
|
||||
let mut frame = [0_i16; INPUT_FRAME_10MS];
|
||||
for (idx, sample) in frame.iter_mut().enumerate() {
|
||||
let phase = idx as f32 * 2.0 * std::f32::consts::PI * 220.0 / 48_000.0;
|
||||
*sample = (phase.sin() * 12_000.0) as i16;
|
||||
}
|
||||
|
||||
let output = process_i16_10ms(&mut vad, &frame);
|
||||
|
||||
assert!(output.speech);
|
||||
assert!(output.probability >= 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_stays_closed_for_silence() {
|
||||
let mut vad = WebRtcFallbackVad::default();
|
||||
let frame = [0_i16; INPUT_FRAME_10MS];
|
||||
|
||||
let output = process_i16_10ms(&mut vad, &frame);
|
||||
|
||||
assert!(!output.speech);
|
||||
assert!(output.probability < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_silero_model_path_rejects_missing_file() {
|
||||
let result = set_silero_model_path("/definitely/not/a/silero_vad.onnx");
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_silero_model_path_updates_override_and_epoch() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("chanora_test_silero_{}.onnx", std::process::id()));
|
||||
std::fs::write(&path, b"test").unwrap();
|
||||
let before = silero_model_epoch();
|
||||
|
||||
set_silero_model_path(path.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(silero_model_epoch() > before);
|
||||
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Lightweight 48 kHz → 16 kHz downsampler for VAD input.
|
||||
//!
|
||||
//! Silero VAD and the real WebRTC VAD both operate at 16 kHz. The
|
||||
//! VPIO capture stream is pinned at 48 kHz (3× ratio). We use a
|
||||
//! simple polyphase FIR with a 3:1 decimation factor. The filter
|
||||
//! coefficients are a 32-tap Kaiser-windowed low-pass at 8 kHz
|
||||
//! (Nyquist of the 16 kHz output), pre-computed offline and baked
|
||||
//! in as constants so there is no runtime allocation.
|
||||
//!
|
||||
//! Quality is sufficient for VAD (speech/silence discrimination);
|
||||
//! this is not a high-fidelity resampler.
|
||||
|
||||
/// Input sample rate (Hz).
|
||||
pub const INPUT_HZ: u32 = 48_000;
|
||||
/// Output sample rate (Hz).
|
||||
pub const OUTPUT_HZ: u32 = 16_000;
|
||||
/// Decimation factor (INPUT_HZ / OUTPUT_HZ).
|
||||
pub const DECIMATION: usize = 3;
|
||||
|
||||
/// Samples in one 10 ms frame at 48 kHz.
|
||||
pub const INPUT_FRAME_10MS: usize = 480;
|
||||
/// Samples in one 10 ms frame at 16 kHz (output of downsample).
|
||||
pub const OUTPUT_FRAME_10MS: usize = 160;
|
||||
|
||||
/// 32-tap FIR low-pass filter coefficients (Kaiser β=8, fc=8 kHz/48 kHz).
|
||||
/// Generated with scipy.signal.firwin(32, 8000/48000*2, window=('kaiser', 8)).
|
||||
/// Symmetric — only 16 unique values; stored in full for clarity.
|
||||
#[rustfmt::skip]
|
||||
const FIR_COEFFS: [f32; 32] = [
|
||||
-0.000_592_3, -0.001_158_5, -0.001_601_5, -0.000_993_5,
|
||||
0.001_601_5, 0.006_046_8, 0.012_131_5, 0.018_614_0,
|
||||
0.023_448_0, 0.024_726_0, 0.021_048_0, 0.012_636_0,
|
||||
0.000_993_5, -0.011_614_0, -0.021_048_0, -0.024_726_0,
|
||||
-0.024_726_0, -0.021_048_0, -0.011_614_0, 0.000_993_5,
|
||||
0.012_636_0, 0.021_048_0, 0.024_726_0, 0.023_448_0,
|
||||
0.018_614_0, 0.012_131_5, 0.006_046_8, 0.001_601_5,
|
||||
-0.000_993_5, -0.001_601_5, -0.001_158_5, -0.000_592_3,
|
||||
];
|
||||
|
||||
const TAPS: usize = FIR_COEFFS.len();
|
||||
|
||||
/// Stateful 48→16 kHz downsampler. Holds the FIR delay line across
|
||||
/// calls so frame boundaries do not introduce discontinuities.
|
||||
pub struct Downsampler48to16 {
|
||||
/// Circular delay line (length = TAPS).
|
||||
delay: [f32; TAPS],
|
||||
/// Write head into the delay line.
|
||||
head: usize,
|
||||
/// Phase counter: 0..DECIMATION. When phase==0 we emit a sample.
|
||||
phase: usize,
|
||||
}
|
||||
|
||||
impl Default for Downsampler48to16 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
delay: [0.0; TAPS],
|
||||
head: 0,
|
||||
phase: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Downsampler48to16 {
|
||||
/// Process `input` (48 kHz f32 mono) and write 16 kHz output
|
||||
/// into `output`. Returns the number of samples written.
|
||||
///
|
||||
/// For a full 10 ms input frame (480 samples) this always
|
||||
/// produces exactly 160 output samples.
|
||||
pub fn process(&mut self, input: &[f32], output: &mut [f32]) -> usize {
|
||||
let mut out_idx = 0;
|
||||
for &sample in input {
|
||||
// Push sample into circular delay line.
|
||||
self.delay[self.head] = sample;
|
||||
self.head = (self.head + 1) % TAPS;
|
||||
|
||||
if self.phase == 0 {
|
||||
// Compute FIR dot product.
|
||||
let mut acc = 0.0_f32;
|
||||
for (k, &coeff) in FIR_COEFFS.iter().enumerate() {
|
||||
let tap_idx = (self.head + TAPS - 1 - k) % TAPS;
|
||||
acc += self.delay[tap_idx] * coeff;
|
||||
}
|
||||
if out_idx < output.len() {
|
||||
output[out_idx] = acc;
|
||||
out_idx += 1;
|
||||
}
|
||||
}
|
||||
self.phase = (self.phase + 1) % DECIMATION;
|
||||
}
|
||||
out_idx
|
||||
}
|
||||
|
||||
/// Convenience: downsample a full 10 ms 48 kHz frame into a
|
||||
/// fixed-size 160-sample 16 kHz buffer.
|
||||
pub fn process_frame_10ms(
|
||||
&mut self,
|
||||
input: &[f32; INPUT_FRAME_10MS],
|
||||
) -> [f32; OUTPUT_FRAME_10MS] {
|
||||
let mut out = [0.0_f32; OUTPUT_FRAME_10MS];
|
||||
let n = self.process(input, &mut out);
|
||||
debug_assert_eq!(
|
||||
n, OUTPUT_FRAME_10MS,
|
||||
"resampler produced {n} samples, expected 160"
|
||||
);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_produces_160_samples() {
|
||||
let mut ds = Downsampler48to16::default();
|
||||
let input = [0.5_f32; INPUT_FRAME_10MS];
|
||||
let out = ds.process_frame_10ms(&input);
|
||||
// DC input → DC output (scaled by filter gain ≈ 1/3 due to decimation).
|
||||
// Just check length and that output is finite and non-zero.
|
||||
assert_eq!(out.len(), OUTPUT_FRAME_10MS);
|
||||
assert!(out.iter().all(|s| s.is_finite()));
|
||||
assert!(out.iter().any(|s| s.abs() > 0.001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_produces_silence() {
|
||||
let mut ds = Downsampler48to16::default();
|
||||
let input = [0.0_f32; INPUT_FRAME_10MS];
|
||||
let out = ds.process_frame_10ms(&input);
|
||||
assert!(out.iter().all(|s| s.abs() < 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_frames_are_continuous() {
|
||||
let mut ds = Downsampler48to16::default();
|
||||
// Two frames of DC — output should be stable (no edge discontinuity).
|
||||
let input = [0.3_f32; INPUT_FRAME_10MS];
|
||||
let out1 = ds.process_frame_10ms(&input);
|
||||
let out2 = ds.process_frame_10ms(&input);
|
||||
// Last sample of frame 1 and first sample of frame 2 should be close.
|
||||
let diff = (out1[OUTPUT_FRAME_10MS - 1] - out2[0]).abs();
|
||||
assert!(diff < 0.05, "discontinuity between frames: {diff}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
//! Silero VAD v6 ONNX runtime integration (P1 VAD_002).
|
||||
//!
|
||||
//! ## Silero VAD v6 model I/O
|
||||
//!
|
||||
//! The v6 model (silero_vad.onnx from the v6.x releases) has a different
|
||||
//! interface from v4. Key changes:
|
||||
//!
|
||||
//! | Tensor | Shape | Dtype | Meaning |
|
||||
//! |---------|------------------|-------|--------------------------------------|
|
||||
//! | input | \[1, 576\] | f32 | 64-sample context + 512-sample frame |
|
||||
//! | state | \[2, 1, 128\] | f32 | LSTM state (carry across frames) |
|
||||
//! | sr | \[1\] | i64 | Sample rate (16000 or 8000) |
|
||||
//! | output | \[1, 1\] | f32 | Speech probability |
|
||||
//! | stateN | \[2, 1, 128\] | f32 | Updated LSTM state |
|
||||
//!
|
||||
//! Frame size: **512 samples at 16 kHz = 32 ms**.
|
||||
//! Context: **64 samples** prepended to each frame (last 64 samples of previous frame).
|
||||
//! Total input width: 512 + 64 = **576 samples**.
|
||||
//!
|
||||
//! ## Threading
|
||||
//!
|
||||
//! `SileroOnnxVad` is `Send`. The session is created once and reused —
|
||||
//! never re-created per callback (INV_007).
|
||||
//!
|
||||
//! ## Accumulation
|
||||
//!
|
||||
//! The capture pipeline delivers 10 ms frames (480 samples at 48 kHz →
|
||||
//! 160 samples at 16 kHz). Three 10 ms frames = 30 ms ≈ 32 ms. We
|
||||
//! accumulate 512 samples (32 ms at 16 kHz) before running inference.
|
||||
//! The last probability is held between inference calls so the state
|
||||
//! machine always has a value to work with.
|
||||
//!
|
||||
//! ## Fallback
|
||||
//!
|
||||
//! `try_new` returns `None` when the model file is missing, the ONNX
|
||||
//! Runtime is unavailable, or the platform is not iOS/macOS. The caller
|
||||
//! falls back to `WebRtcFallbackVad`.
|
||||
|
||||
use super::{VadOutput, VoiceActivityDetector};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
/// 16 kHz frame size for Silero VAD v6 (32 ms).
|
||||
pub const SILERO_FRAME_16K: usize = 512;
|
||||
/// Context size prepended to each frame (64 samples at 16 kHz).
|
||||
pub const SILERO_CONTEXT_16K: usize = 64;
|
||||
/// Total input width: context + frame.
|
||||
pub const SILERO_INPUT_WIDTH: usize = SILERO_CONTEXT_16K + SILERO_FRAME_16K;
|
||||
/// LSTM state size: 2 × 1 × 128 = 256 f32 values.
|
||||
pub const SILERO_STATE_SIZE: usize = 256;
|
||||
/// Maximum lag in 10 ms frames before the realtime callback treats
|
||||
/// the Silero worker as stale and falls back to the local WebRTC
|
||||
/// detector for that frame.
|
||||
pub const SILERO_MAX_STALE_FRAMES: u64 = 3;
|
||||
|
||||
/// Silero VAD v6 ONNX backend.
|
||||
///
|
||||
/// Operates at **16 kHz**, accumulating 32 ms frames (512 samples)
|
||||
/// before running inference. The caller is responsible for downsampling
|
||||
/// from 48 kHz before calling `process_10ms`.
|
||||
pub struct SileroOnnxVad {
|
||||
/// LSTM state [2, 1, 128] — persisted across frames.
|
||||
state: Box<[f32; SILERO_STATE_SIZE]>,
|
||||
/// Context ring: last 64 samples of the previous frame.
|
||||
context: Box<[f32; SILERO_CONTEXT_16K]>,
|
||||
/// Accumulation buffer for 16 kHz samples (fills to SILERO_FRAME_16K).
|
||||
accum: Vec<f32>,
|
||||
/// Last speech probability output (held between inference calls).
|
||||
last_probability: f32,
|
||||
/// Model path stored for diagnostics.
|
||||
model_path: String,
|
||||
/// Inner ONNX implementation (platform-specific).
|
||||
inner: SileroInner,
|
||||
}
|
||||
|
||||
enum SileroInner {
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
Onnx(OnnxSession),
|
||||
#[allow(dead_code)]
|
||||
Stub,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
struct OnnxSession {
|
||||
session: ort::session::Session,
|
||||
}
|
||||
|
||||
impl SileroOnnxVad {
|
||||
/// Attempt to load the Silero v6 ONNX model from `model_path`.
|
||||
///
|
||||
/// Returns `None` when the model file is missing, the ONNX Runtime
|
||||
/// is unavailable, or the platform is not iOS/macOS.
|
||||
pub fn try_new(model_path: &str) -> Option<Self> {
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
{
|
||||
Self::try_new_onnx(model_path)
|
||||
}
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||
{
|
||||
let _ = model_path;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
fn try_new_onnx(model_path: &str) -> Option<Self> {
|
||||
use tracing::{error, info};
|
||||
|
||||
if !std::path::Path::new(model_path).exists() {
|
||||
tracing::warn!(
|
||||
target: "chanora_audio",
|
||||
path = model_path,
|
||||
"SileroOnnxVad: model file not found; falling back to WebRtcFallbackVad"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(path) = bundled_onnxruntime_path() {
|
||||
let _ = ort::init_from(path.to_string_lossy()).commit();
|
||||
}
|
||||
|
||||
let session_result = std::panic::catch_unwind(|| {
|
||||
ort::session::Session::builder().and_then(|b| b.commit_from_file(model_path))
|
||||
});
|
||||
|
||||
match session_result {
|
||||
Err(_) => {
|
||||
error!(
|
||||
target: "chanora_audio",
|
||||
path = model_path,
|
||||
"SileroOnnxVad: ONNX Runtime panicked during load; falling back to WebRtcFallbackVad"
|
||||
);
|
||||
None
|
||||
}
|
||||
Ok(Ok(session)) => {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
path = model_path,
|
||||
"SileroOnnxVad v6: model loaded"
|
||||
);
|
||||
Some(Self {
|
||||
state: Box::new([0.0; SILERO_STATE_SIZE]),
|
||||
context: Box::new([0.0; SILERO_CONTEXT_16K]),
|
||||
accum: Vec::with_capacity(SILERO_FRAME_16K),
|
||||
last_probability: 0.0,
|
||||
model_path: model_path.to_owned(),
|
||||
inner: SileroInner::Onnx(OnnxSession { session }),
|
||||
})
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!(
|
||||
target: "chanora_audio",
|
||||
path = model_path,
|
||||
error = %e,
|
||||
"SileroOnnxVad: failed to load model; falling back to WebRtcFallbackVad"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset LSTM state and context (call on voice_leave / session restart).
|
||||
pub fn reset_state(&mut self) {
|
||||
self.state.iter_mut().for_each(|v| *v = 0.0);
|
||||
self.context.iter_mut().for_each(|v| *v = 0.0);
|
||||
self.accum.clear();
|
||||
self.last_probability = 0.0;
|
||||
}
|
||||
|
||||
/// Return the model path for diagnostics.
|
||||
pub fn model_path(&self) -> &str {
|
||||
&self.model_path
|
||||
}
|
||||
|
||||
fn input_with_context(context: &[f32; SILERO_CONTEXT_16K], audio_frame: &[f32]) -> Vec<f32> {
|
||||
let mut input = Vec::with_capacity(SILERO_CONTEXT_16K + audio_frame.len());
|
||||
input.extend_from_slice(context);
|
||||
input.extend_from_slice(audio_frame);
|
||||
input
|
||||
}
|
||||
|
||||
fn update_context_from_frame(&mut self, audio_frame: &[f32]) {
|
||||
let ctx_start = audio_frame.len().saturating_sub(SILERO_CONTEXT_16K);
|
||||
let new_ctx = &audio_frame[ctx_start..];
|
||||
let copy_len = new_ctx.len().min(SILERO_CONTEXT_16K);
|
||||
self.context.fill(0.0);
|
||||
self.context[SILERO_CONTEXT_16K - copy_len..].copy_from_slice(&new_ctx[..copy_len]);
|
||||
}
|
||||
|
||||
/// Run one upstream-style `calc_level` pass over a 32 ms / 512-sample
|
||||
/// 16 kHz frame: concatenate prior context, pass `input/state/sr` to
|
||||
/// ONNX, persist `stateN`, then refresh context from the current frame.
|
||||
/// Updates `last_probability` and returns the new value.
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
fn calc_level(&mut self, audio_frame: &[f32]) -> f32 {
|
||||
use ort::value::Value;
|
||||
use tracing::error;
|
||||
|
||||
let SileroInner::Onnx(ref mut inner) = self.inner else {
|
||||
return self.last_probability;
|
||||
};
|
||||
|
||||
debug_assert_eq!(audio_frame.len(), SILERO_FRAME_16K);
|
||||
|
||||
// Build input: [1, 576] = context (64) + frame (512), matching
|
||||
// snakers4/silero-vad's Rust `calc_level` example.
|
||||
let input_vec = Self::input_with_context(self.context.as_ref(), audio_frame);
|
||||
|
||||
// Build ndarray tensors.
|
||||
use ndarray::{Array, IxDyn};
|
||||
|
||||
let input_arr = Array::from_shape_vec(IxDyn(&[1, SILERO_INPUT_WIDTH]), input_vec);
|
||||
let state_arr = Array::from_shape_vec(IxDyn(&[2, 1, 128]), self.state.to_vec());
|
||||
let sr_arr = Array::from_shape_vec(IxDyn(&[1]), vec![16000_i64]);
|
||||
|
||||
let (input_arr, state_arr, sr_arr) = match (input_arr, state_arr, sr_arr) {
|
||||
(Ok(i), Ok(s), Ok(sr)) => (i, s, sr),
|
||||
_ => return self.last_probability,
|
||||
};
|
||||
|
||||
let input_val = match Value::from_array(input_arr) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!(target: "chanora_audio", error = %e, "SileroOnnxVad: input tensor error");
|
||||
return self.last_probability;
|
||||
}
|
||||
};
|
||||
let state_val = match Value::from_array(state_arr) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!(target: "chanora_audio", error = %e, "SileroOnnxVad: state tensor error");
|
||||
return self.last_probability;
|
||||
}
|
||||
};
|
||||
let sr_val = match Value::from_array(sr_arr) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!(target: "chanora_audio", error = %e, "SileroOnnxVad: sr tensor error");
|
||||
return self.last_probability;
|
||||
}
|
||||
};
|
||||
|
||||
let outputs =
|
||||
match inner
|
||||
.session
|
||||
.run([(&input_val).into(), (&state_val).into(), (&sr_val).into()])
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
error!(target: "chanora_audio", error = %e, "SileroOnnxVad: inference failed");
|
||||
return self.last_probability;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract probability from "output".
|
||||
if let Ok((_, prob_data)) = outputs["output"].try_extract_tensor::<f32>() {
|
||||
if let Some(&p) = prob_data.first() {
|
||||
self.last_probability = p.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Update state from "stateN".
|
||||
if let Ok((shape, state_data)) = outputs["stateN"].try_extract_tensor::<f32>() {
|
||||
let total: usize = shape.iter().map(|&d| d as usize).product();
|
||||
let copy_len = total.min(SILERO_STATE_SIZE);
|
||||
self.state[..copy_len].copy_from_slice(&state_data[..copy_len]);
|
||||
}
|
||||
|
||||
drop(outputs);
|
||||
|
||||
// Match the upstream example: context becomes the last context_size
|
||||
// samples from the current frame after the model call succeeds.
|
||||
self.update_context_from_frame(audio_frame);
|
||||
|
||||
self.last_probability
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn bundled_onnxruntime_path() -> Option<std::path::PathBuf> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let app_dir = exe.parent()?;
|
||||
let framework = app_dir
|
||||
.join("Frameworks")
|
||||
.join("onnxruntime.framework")
|
||||
.join("onnxruntime");
|
||||
framework.exists().then_some(framework)
|
||||
}
|
||||
|
||||
impl VoiceActivityDetector for SileroOnnxVad {
|
||||
/// Accept one 10 ms **16 kHz** f32 mono frame (160 samples).
|
||||
///
|
||||
/// Accumulates samples until a full 32 ms frame (512 samples) is
|
||||
/// ready, then runs inference. Between inference calls the last
|
||||
/// probability is returned unchanged.
|
||||
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput {
|
||||
debug_assert_eq!(
|
||||
samples.len(),
|
||||
super::resampler::OUTPUT_FRAME_10MS,
|
||||
"SileroOnnxVad expects 160 samples (16 kHz 10 ms), got {}",
|
||||
samples.len()
|
||||
);
|
||||
|
||||
self.accum.extend_from_slice(samples);
|
||||
|
||||
if self.accum.len() >= SILERO_FRAME_16K {
|
||||
let audio_frame: Vec<f32> = self.accum[..SILERO_FRAME_16K].to_vec();
|
||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||
{
|
||||
if matches!(self.inner, SileroInner::Onnx(_)) {
|
||||
self.calc_level(&audio_frame);
|
||||
} else {
|
||||
self.update_context_from_frame(&audio_frame);
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
|
||||
{
|
||||
self.update_context_from_frame(&audio_frame);
|
||||
}
|
||||
// Drain the accumulator (keep any overflow for next frame).
|
||||
let overflow: Vec<f32> = self.accum.drain(SILERO_FRAME_16K..).collect();
|
||||
self.accum.clear();
|
||||
self.accum.extend_from_slice(&overflow);
|
||||
}
|
||||
|
||||
VadOutput {
|
||||
probability: self.last_probability,
|
||||
speech: self.last_probability >= 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: ONNX Runtime sessions are thread-safe for inference.
|
||||
// State arrays are owned by this struct and accessed only from
|
||||
// the single capture callback thread.
|
||||
unsafe impl Send for SileroOnnxVad {}
|
||||
|
||||
struct SileroFrameMessage {
|
||||
seq: u64,
|
||||
frame: [f32; super::resampler::INPUT_FRAME_10MS],
|
||||
}
|
||||
|
||||
/// Background Silero worker. The realtime callback only enqueues
|
||||
/// 10 ms frames and reads the latest probability atomically.
|
||||
pub struct SileroOnnxVadWorker {
|
||||
tx: Option<std::sync::mpsc::SyncSender<SileroFrameMessage>>,
|
||||
latest_probability: Arc<AtomicU32>,
|
||||
latest_processed_seq: Arc<AtomicU64>,
|
||||
alive: Arc<AtomicBool>,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl SileroOnnxVadWorker {
|
||||
/// Start a background Silero worker if the model loads.
|
||||
pub fn try_new(model_path: &str) -> Option<Self> {
|
||||
let vad = SileroOnnxVad::try_new(model_path)?;
|
||||
let latest_probability = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
|
||||
let latest_processed_seq = Arc::new(AtomicU64::new(0));
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<SileroFrameMessage>(8);
|
||||
let latest_probability_for_thread = latest_probability.clone();
|
||||
let latest_processed_seq_for_thread = latest_processed_seq.clone();
|
||||
let alive_for_thread = alive.clone();
|
||||
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("chanora-silero-vad".to_string())
|
||||
.spawn(move || {
|
||||
let mut vad = super::Resampled16kHzVad::new(vad);
|
||||
while alive_for_thread.load(Ordering::Relaxed) {
|
||||
let message = match rx.recv() {
|
||||
Ok(message) => message,
|
||||
Err(_) => break,
|
||||
};
|
||||
let output = vad.process_10ms(&message.frame);
|
||||
latest_probability_for_thread.store(
|
||||
output.probability.clamp(0.0, 1.0).to_bits(),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
latest_processed_seq_for_thread.store(message.seq, Ordering::Relaxed);
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
Some(Self {
|
||||
tx: Some(tx),
|
||||
latest_probability,
|
||||
latest_processed_seq,
|
||||
alive,
|
||||
handle: Some(handle),
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort enqueue of a 10 ms frame for background inference.
|
||||
pub fn try_send(&self, seq: u64, frame: &[f32; super::resampler::INPUT_FRAME_10MS]) -> bool {
|
||||
let Some(tx) = &self.tx else {
|
||||
return false;
|
||||
};
|
||||
tx.try_send(SileroFrameMessage { seq, frame: *frame })
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Latest probability published by the background worker.
|
||||
pub fn latest_probability(&self) -> f32 {
|
||||
f32::from_bits(self.latest_probability.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Number of 10 ms frames the worker is behind the capture thread.
|
||||
pub fn lag_frames(&self, capture_seq: u64) -> u64 {
|
||||
capture_seq.saturating_sub(self.latest_processed_seq.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// True when the worker is too far behind to trust its latest
|
||||
/// probability for the current frame.
|
||||
pub fn is_stale(&self, capture_seq: u64) -> bool {
|
||||
self.lag_frames(capture_seq) > SILERO_MAX_STALE_FRAMES
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SileroOnnxVadWorker {
|
||||
fn drop(&mut self) {
|
||||
self.alive.store(false, Ordering::Relaxed);
|
||||
let _ = self.tx.take();
|
||||
let _ = self.handle.take();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_stub_vad() -> SileroOnnxVad {
|
||||
SileroOnnxVad {
|
||||
state: Box::new([0.0; SILERO_STATE_SIZE]),
|
||||
context: Box::new([0.0; SILERO_CONTEXT_16K]),
|
||||
accum: Vec::new(),
|
||||
last_probability: 0.0,
|
||||
model_path: String::new(),
|
||||
inner: SileroInner::Stub,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_new_returns_none_without_model_file() {
|
||||
let result = SileroOnnxVad::try_new("/nonexistent/silero_vad.onnx");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_accumulates_and_holds_zero_probability() {
|
||||
let mut vad = make_stub_vad();
|
||||
let frame = vec![0.0_f32; super::super::resampler::OUTPUT_FRAME_10MS];
|
||||
// Feed 3 frames (30 ms < 32 ms) — no inference yet.
|
||||
for _ in 0..3 {
|
||||
let out = VoiceActivityDetector::process_10ms(&mut vad, &frame);
|
||||
assert_eq!(out.probability, 0.0);
|
||||
}
|
||||
// Feed 1 more frame (40 ms > 32 ms) — accumulator drains.
|
||||
let out = VoiceActivityDetector::process_10ms(&mut vad, &frame);
|
||||
assert_eq!(out.probability, 0.0); // stub stays at 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_state_clears_all() {
|
||||
let mut vad = make_stub_vad();
|
||||
vad.state[0] = 1.0;
|
||||
vad.context[0] = 1.0;
|
||||
vad.last_probability = 0.9;
|
||||
vad.accum.push(0.5);
|
||||
vad.reset_state();
|
||||
assert_eq!(vad.state[0], 0.0);
|
||||
assert_eq!(vad.context[0], 0.0);
|
||||
assert_eq!(vad.last_probability, 0.0);
|
||||
assert!(vad.accum.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulates_correct_number_of_samples() {
|
||||
let mut vad = make_stub_vad();
|
||||
let frame = vec![0.1_f32; super::super::resampler::OUTPUT_FRAME_10MS]; // 160 samples
|
||||
// 3 × 160 = 480 < 512 — not yet full.
|
||||
for _ in 0..3 {
|
||||
VoiceActivityDetector::process_10ms(&mut vad, &frame);
|
||||
}
|
||||
assert_eq!(vad.accum.len(), 480);
|
||||
// 4th frame: 640 > 512 — inference fires, 128 samples remain.
|
||||
VoiceActivityDetector::process_10ms(&mut vad, &frame);
|
||||
assert_eq!(vad.accum.len(), 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_concatenates_context_before_frame_like_upstream_example() {
|
||||
let mut context = [0.0_f32; SILERO_CONTEXT_16K];
|
||||
context[0] = -1.0;
|
||||
context[SILERO_CONTEXT_16K - 1] = 1.0;
|
||||
let frame = vec![0.25_f32; SILERO_FRAME_16K];
|
||||
|
||||
let input = SileroOnnxVad::input_with_context(&context, &frame);
|
||||
|
||||
assert_eq!(input.len(), SILERO_INPUT_WIDTH);
|
||||
assert_eq!(input[0], -1.0);
|
||||
assert_eq!(input[SILERO_CONTEXT_16K - 1], 1.0);
|
||||
assert_eq!(input[SILERO_CONTEXT_16K], 0.25);
|
||||
assert_eq!(input[SILERO_INPUT_WIDTH - 1], 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_tracks_last_64_samples_of_completed_frame() {
|
||||
let mut vad = make_stub_vad();
|
||||
let frame = vec![0.0_f32; super::super::resampler::OUTPUT_FRAME_10MS];
|
||||
for idx in 0..4 {
|
||||
let mut chunk = frame.clone();
|
||||
let chunk_len = chunk.len();
|
||||
for (sample_idx, sample) in chunk.iter_mut().enumerate() {
|
||||
*sample = (idx * chunk_len + sample_idx) as f32;
|
||||
}
|
||||
VoiceActivityDetector::process_10ms(&mut vad, &chunk);
|
||||
}
|
||||
|
||||
let completed_frame: Vec<f32> = (0..SILERO_FRAME_16K).map(|v| v as f32).collect();
|
||||
assert_eq!(
|
||||
vad.context.as_ref(),
|
||||
&completed_frame[SILERO_FRAME_16K - SILERO_CONTEXT_16K..]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Voice-activity state machine for P1 transmit gating.
|
||||
//!
|
||||
//! Implements the full P1 VAD gate policy:
|
||||
//! * backend/model speech decisions — no custom probability thresholds.
|
||||
//! * `open_after_ms` — speech must be detected for this long before
|
||||
//! the gate opens (prevents false opens on transients). Default 40 ms.
|
||||
//! * `hangover_ms` — gate stays open for this long after speech drops
|
||||
//! out of the backend decision (prevents choppy transmit close). Default 500 ms.
|
||||
//! * `min_tx_ms` — minimum transmit duration after gate opens. Default 200 ms.
|
||||
//!
|
||||
//! Pre-roll (first-syllable preservation) is handled in the capture
|
||||
//! pipeline, not here. The state machine only decides whether the gate
|
||||
//! is open or closed.
|
||||
|
||||
/// Shared VAD timing constants and gate state machine.
|
||||
///
|
||||
/// Exposing these values here keeps the audio config and platform
|
||||
/// capture paths aligned without repeating the same magic numbers in
|
||||
/// multiple modules.
|
||||
/// Default confirmation window before the gate opens, in milliseconds.
|
||||
pub const VAD_OPEN_AFTER_MS: u32 = 40;
|
||||
/// Default hangover duration in milliseconds.
|
||||
pub const VAD_HANGOVER_MS: u32 = 500;
|
||||
/// Default minimum transmit duration in milliseconds.
|
||||
pub const VAD_MIN_TX_MS: u32 = 200;
|
||||
|
||||
/// Hangover/open-after/minimum-transmit state machine.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VoiceActivityStateMachine {
|
||||
/// Frames of continuous speech required before gate opens.
|
||||
open_after_frames: u32,
|
||||
hangover_frames: u32,
|
||||
min_tx_frames: u32,
|
||||
active: bool,
|
||||
hangover_remaining: u32,
|
||||
min_tx_remaining: u32,
|
||||
/// Frames of continuous speech seen since last open attempt.
|
||||
open_confirm_frames: u32,
|
||||
/// Frames spent open without a strong speech score. This keeps
|
||||
/// stale or borderline VAD output from holding the mic open forever.
|
||||
weak_hold_frames: u32,
|
||||
}
|
||||
|
||||
impl VoiceActivityStateMachine {
|
||||
/// Create a state machine. Frame duration is 10 ms.
|
||||
pub fn new(open_after_ms: u32, hangover_ms: u32, min_tx_ms: u32) -> Self {
|
||||
Self {
|
||||
open_after_frames: open_after_ms / 10,
|
||||
hangover_frames: hangover_ms / 10,
|
||||
min_tx_frames: min_tx_ms / 10,
|
||||
active: false,
|
||||
hangover_remaining: 0,
|
||||
min_tx_remaining: 0,
|
||||
open_confirm_frames: 0,
|
||||
weak_hold_frames: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update timers without forcing a close. Used by
|
||||
/// live settings changes while audio is already running.
|
||||
pub fn configure(&mut self, open_after_ms: u32, hangover_ms: u32, min_tx_ms: u32) {
|
||||
self.open_after_frames = open_after_ms / 10;
|
||||
self.hangover_frames = hangover_ms / 10;
|
||||
self.min_tx_frames = min_tx_ms / 10;
|
||||
self.hangover_remaining = self.hangover_remaining.min(self.hangover_frames);
|
||||
self.min_tx_remaining = self.min_tx_remaining.min(self.min_tx_frames);
|
||||
}
|
||||
|
||||
/// Advance by one 10 ms backend speech decision and return whether
|
||||
/// transmit should be open for VoiceActivity mode.
|
||||
pub fn update(&mut self, speech: bool) -> bool {
|
||||
if self.active {
|
||||
if self.min_tx_remaining > 0 {
|
||||
self.min_tx_remaining -= 1;
|
||||
}
|
||||
if speech {
|
||||
self.weak_hold_frames = 0;
|
||||
} else {
|
||||
self.weak_hold_frames = self.weak_hold_frames.saturating_add(1);
|
||||
}
|
||||
if self.weak_hold_frames >= self.weak_hold_limit_frames() && self.min_tx_remaining == 0
|
||||
{
|
||||
self.close();
|
||||
return false;
|
||||
}
|
||||
if speech {
|
||||
// Speech still present — reset hangover.
|
||||
self.hangover_remaining = self.hangover_frames;
|
||||
} else if self.hangover_remaining > 0 {
|
||||
self.hangover_remaining -= 1;
|
||||
} else if self.min_tx_remaining == 0 {
|
||||
// Hangover expired and min-tx elapsed — close gate.
|
||||
self.close();
|
||||
}
|
||||
} else {
|
||||
// Gate is closed. Accumulate confirmation frames.
|
||||
if speech {
|
||||
self.open_confirm_frames += 1;
|
||||
if self.open_confirm_frames >= self.open_after_frames.max(1) {
|
||||
// Confirmed speech — open gate.
|
||||
self.active = true;
|
||||
self.hangover_remaining = self.hangover_frames;
|
||||
self.min_tx_remaining = self.min_tx_frames;
|
||||
self.open_confirm_frames = 0;
|
||||
self.weak_hold_frames = 0;
|
||||
}
|
||||
} else {
|
||||
// Speech is no longer detected — reset confirmation.
|
||||
self.open_confirm_frames = 0;
|
||||
}
|
||||
}
|
||||
self.active
|
||||
}
|
||||
|
||||
/// Current active state.
|
||||
pub fn active(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
|
||||
fn weak_hold_limit_frames(&self) -> u32 {
|
||||
(self.hangover_frames + self.min_tx_frames + self.open_after_frames).clamp(30, 100)
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
self.active = false;
|
||||
self.hangover_remaining = 0;
|
||||
self.min_tx_remaining = 0;
|
||||
self.open_confirm_frames = 0;
|
||||
self.weak_hold_frames = 0;
|
||||
}
|
||||
|
||||
/// Reset all state (call on session restart / voice_leave).
|
||||
pub fn reset(&mut self) {
|
||||
self.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VoiceActivityStateMachine {
|
||||
fn default() -> Self {
|
||||
Self::new(VAD_OPEN_AFTER_MS, VAD_HANGOVER_MS, VAD_MIN_TX_MS)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hangover_keeps_gate_open_after_close() {
|
||||
// open_after_ms=0 so gate opens immediately on first frame.
|
||||
let mut sm = VoiceActivityStateMachine::new(0, 30, 0);
|
||||
assert!(sm.update(true));
|
||||
assert!(sm.update(false));
|
||||
assert!(sm.update(false));
|
||||
assert!(sm.update(false));
|
||||
assert!(!sm.update(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_after_requires_confirmation_frames() {
|
||||
// open_after_ms=20 → 2 frames required.
|
||||
let mut sm = VoiceActivityStateMachine::new(20, 0, 0);
|
||||
// First frame: not yet open.
|
||||
assert!(!sm.update(true));
|
||||
// Second frame: now open.
|
||||
assert!(sm.update(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_after_resets_on_silence() {
|
||||
// open_after_ms=20 → 2 frames required.
|
||||
let mut sm = VoiceActivityStateMachine::new(20, 0, 0);
|
||||
assert!(!sm.update(true)); // 1 frame
|
||||
assert!(!sm.update(false)); // silence resets counter
|
||||
assert!(!sm.update(true)); // 1 frame again
|
||||
assert!(sm.update(true)); // 2nd frame → open
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_tx_keeps_gate_open_briefly() {
|
||||
// open_after_ms=0, hangover=0, min_tx=20ms (2 frames).
|
||||
// After opening: min_tx_remaining decrements each frame.
|
||||
// Gate closes on the frame where it reaches 0.
|
||||
let mut sm = VoiceActivityStateMachine::new(0, 0, 20);
|
||||
assert!(sm.update(true)); // opens; min_tx_remaining=2
|
||||
assert!(sm.update(false)); // min_tx_remaining=1; still open
|
||||
assert!(!sm.update(false)); // min_tx_remaining=0; gate closes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_all_state() {
|
||||
let mut sm = VoiceActivityStateMachine::new(0, 100, 0);
|
||||
assert!(sm.update(true)); // open
|
||||
sm.reset();
|
||||
assert!(!sm.active());
|
||||
// After reset, gate should not be open even with hangover pending.
|
||||
assert!(!sm.update(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_uses_p1_spec_values() {
|
||||
let sm = VoiceActivityStateMachine::default();
|
||||
assert_eq!(sm.open_after_frames, VAD_OPEN_AFTER_MS / 10);
|
||||
assert_eq!(sm.hangover_frames, VAD_HANGOVER_MS / 10);
|
||||
assert_eq!(sm.min_tx_frames, VAD_MIN_TX_MS / 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_config_update_shortens_existing_hangover() {
|
||||
let mut sm = VoiceActivityStateMachine::new(0, 1000, 0);
|
||||
assert!(sm.update(true));
|
||||
assert!(sm.update(false));
|
||||
sm.configure(0, 100, 0);
|
||||
for _ in 0..10 {
|
||||
assert!(sm.update(false));
|
||||
}
|
||||
assert!(!sm.update(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_closed_decisions_cannot_hold_gate_forever() {
|
||||
let mut sm = VoiceActivityStateMachine::new(0, 500, 0);
|
||||
assert!(sm.update(true));
|
||||
for _ in 0..49 {
|
||||
assert!(sm.update(false));
|
||||
}
|
||||
assert!(!sm.update(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speech_decision_resets_weak_hold_limit() {
|
||||
let mut sm = VoiceActivityStateMachine::new(0, 500, 0);
|
||||
assert!(sm.update(true));
|
||||
for _ in 0..40 {
|
||||
assert!(sm.update(false));
|
||||
}
|
||||
assert!(sm.update(true));
|
||||
for _ in 0..40 {
|
||||
assert!(sm.update(false));
|
||||
}
|
||||
assert!(sm.active());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/// Diagnostics returned by render downmix helpers.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) struct RenderDownmixStats {
|
||||
/// Peak absolute sample magnitude after i16 conversion.
|
||||
pub peak_i16: i16,
|
||||
/// Samples clipped while applying output gain.
|
||||
pub clipped_samples: u64,
|
||||
}
|
||||
|
||||
/// Downmix interleaved stereo f32 samples into mono i16 samples.
|
||||
///
|
||||
/// The helper is allocation-free and safe for realtime render callbacks.
|
||||
/// If the stereo source is shorter than expected, the remainder of `out`
|
||||
/// is filled with silence.
|
||||
pub(crate) fn downmix_stereo_f32_to_mono_i16(
|
||||
stereo: &[f32],
|
||||
out: &mut [i16],
|
||||
gain: f32,
|
||||
muted: bool,
|
||||
) -> RenderDownmixStats {
|
||||
if muted {
|
||||
out.fill(0);
|
||||
return RenderDownmixStats::default();
|
||||
}
|
||||
|
||||
let available_frames = stereo.len() / 2;
|
||||
if available_frames < out.len() {
|
||||
out.fill(0);
|
||||
}
|
||||
|
||||
let mut peak = 0_u16;
|
||||
let mut clipped_samples = 0_u64;
|
||||
for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) {
|
||||
let mono = (lr[0] + lr[1]) * 0.5 * gain;
|
||||
let clamped = mono.clamp(-1.0, 1.0);
|
||||
if (mono - clamped).abs() > f32::EPSILON {
|
||||
clipped_samples = clipped_samples.saturating_add(1);
|
||||
}
|
||||
let sample = (clamped * i16::MAX as f32) as i16;
|
||||
*dst = sample;
|
||||
peak = peak.max(sample.unsigned_abs());
|
||||
}
|
||||
|
||||
RenderDownmixStats {
|
||||
peak_i16: peak.min(i16::MAX as u16) as i16,
|
||||
clipped_samples,
|
||||
}
|
||||
}
|
||||
|
||||
/// Downmix interleaved stereo f32 samples into mono f32 samples.
|
||||
///
|
||||
/// Used for software-AEC render references and debug WAV taps.
|
||||
#[cfg(any(target_os = "ios", test))]
|
||||
pub(crate) fn downmix_stereo_f32_to_mono_f32(stereo: &[f32], out: &mut [f32]) {
|
||||
let available_frames = stereo.len() / 2;
|
||||
if available_frames < out.len() {
|
||||
out.fill(0.0);
|
||||
}
|
||||
|
||||
for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) {
|
||||
*dst = (lr[0] + lr[1]) * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn downmix_i16_applies_gain_and_reports_clipping() {
|
||||
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
|
||||
let mut out = [0_i16; 3];
|
||||
|
||||
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 2.0, false);
|
||||
|
||||
assert_eq!(out[0], i16::MAX);
|
||||
assert_eq!(out[1], 0);
|
||||
assert_eq!(out[2], -i16::MAX);
|
||||
assert_eq!(stats.peak_i16, i16::MAX);
|
||||
assert_eq!(stats.clipped_samples, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downmix_i16_mutes_output() {
|
||||
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
|
||||
let mut out = [123_i16; 2];
|
||||
|
||||
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 1.0, true);
|
||||
|
||||
assert_eq!(out, [0, 0]);
|
||||
assert_eq!(stats, RenderDownmixStats::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downmix_f32_fills_missing_tail_with_silence() {
|
||||
let stereo = [1.0_f32, -1.0];
|
||||
let mut out = [9.0_f32; 2];
|
||||
|
||||
downmix_stereo_f32_to_mono_f32(&stereo, &mut out);
|
||||
|
||||
assert_eq!(out, [0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
@@ -274,22 +274,7 @@ fn log_file_path() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
// iOS sandbox: write the log to the app's Documents
|
||||
// directory so it persists across launches and can be
|
||||
// pulled via Xcode -> Devices and Simulators -> Download
|
||||
// Container, OR via Files.app on the device (the app
|
||||
// appears under "On My iPhone" once we declare
|
||||
// UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace
|
||||
// in Info.plist — done in a follow-up).
|
||||
//
|
||||
// HOME on iOS resolves to the app sandbox root; Documents
|
||||
// is the standard user-visible subdirectory.
|
||||
let home = std::env::var_os("HOME")?;
|
||||
Some(
|
||||
std::path::PathBuf::from(home)
|
||||
.join("Documents")
|
||||
.join("chanora.log"),
|
||||
)
|
||||
None
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
@@ -508,13 +493,46 @@ pub async fn is_connected() -> bool {
|
||||
|
||||
/// Handle iOS AVAudioSession route changes (SDD-100).
|
||||
#[frb(sync)]
|
||||
pub fn handle_route_change() {
|
||||
let result = runtime().block_on(async { session().ios_handle_route_change().await });
|
||||
pub fn handle_route_change(route: BridgeAudioRoute) {
|
||||
let result =
|
||||
runtime().block_on(async { session().ios_handle_route_change(route.into()).await });
|
||||
if let Err(e) = result {
|
||||
warn!(target: "chanora_bridge", error = %e, "iOS route-change handling failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle iOS AVAudioSession media-services reset (legacy, no route arg).
|
||||
///
|
||||
/// Called by the existing FRB-generated Dart binding. Uses
|
||||
/// `AudioRoute::Unknown` which triggers a route-change recompute.
|
||||
/// The AppDelegate now also calls `handle_media_services_reset_with_route`
|
||||
/// directly after rebuilding the session.
|
||||
#[frb(sync)]
|
||||
pub fn handle_media_services_reset() {
|
||||
let result = runtime().block_on(async {
|
||||
session()
|
||||
.ios_handle_media_services_reset(chanora_audio::AudioRoute::Unknown)
|
||||
.await
|
||||
});
|
||||
if let Err(e) = result {
|
||||
warn!(target: "chanora_bridge", error = %e, "iOS media-services reset handling failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle iOS AVAudioSession media-services reset with the current
|
||||
/// route class. Called by AppDelegate after rebuilding the session.
|
||||
///
|
||||
/// `route_class` is the Swift-side route class string (e.g. "Speaker").
|
||||
#[frb(sync)]
|
||||
pub fn handle_media_services_reset_with_route(route_class: String) {
|
||||
let route = chanora_audio::AudioRoute::from_route_class(&route_class);
|
||||
let result =
|
||||
runtime().block_on(async { session().ios_handle_media_services_reset(route).await });
|
||||
if let Err(e) = result {
|
||||
warn!(target: "chanora_bridge", error = %e, "iOS media-services reset (with route) handling failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle iOS AVAudioSession interruption begin (SDD-101).
|
||||
#[frb(sync)]
|
||||
pub fn handle_interruption_began() {
|
||||
@@ -787,6 +805,331 @@ pub struct BridgeAudioStats {
|
||||
pub ptt_active: bool,
|
||||
}
|
||||
|
||||
/// Bridge route class for P1 audio-processing policy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BridgeAudioRoute {
|
||||
/// Built-in speakerphone.
|
||||
Speaker,
|
||||
/// Built-in receiver/earpiece.
|
||||
Earpiece,
|
||||
/// Wired or USB headset.
|
||||
WiredHeadset,
|
||||
/// Bluetooth HFP duplex route.
|
||||
BluetoothHfp,
|
||||
/// Bluetooth A2DP output-only route.
|
||||
BluetoothA2dp,
|
||||
/// Unknown route.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Bridge iOS voice-processing mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BridgeIosVoiceProcessingMode {
|
||||
/// Shipping VPIO path.
|
||||
PlatformVoiceProcessing,
|
||||
/// Experimental Sonora path.
|
||||
SonoraExperimental,
|
||||
}
|
||||
|
||||
/// Bridge processing backend.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BridgeAudioBackend {
|
||||
/// Platform voice processing.
|
||||
PlatformVoiceProcessing,
|
||||
/// Sonora backend.
|
||||
Sonora,
|
||||
/// WebRTC APM backend.
|
||||
WebrtcApm,
|
||||
/// No-op backend.
|
||||
Noop,
|
||||
}
|
||||
|
||||
/// Bridge VAD backend.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BridgeVadBackend {
|
||||
/// Silero ONNX VAD.
|
||||
SileroOnnx,
|
||||
/// TEN VAD.
|
||||
TenVad,
|
||||
/// WebRTC fallback VAD.
|
||||
WebrtcVad,
|
||||
/// Debug energy VAD.
|
||||
EnergyDebug,
|
||||
/// VAD disabled.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// Bridge effect owner for AEC/NS/AGC.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BridgeEffectOwner {
|
||||
/// Platform-owned effect.
|
||||
Platform,
|
||||
/// Sonora-owned effect.
|
||||
Sonora,
|
||||
/// WebRTC APM-owned effect.
|
||||
WebrtcApm,
|
||||
/// Conservative route-managed setting.
|
||||
Conservative,
|
||||
/// Disabled.
|
||||
Off,
|
||||
}
|
||||
|
||||
/// P1 audio-processing configuration DTO.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BridgeAudioProcessingConfig {
|
||||
/// Route class.
|
||||
pub route: BridgeAudioRoute,
|
||||
/// iOS voice-processing mode.
|
||||
pub ios_mode: BridgeIosVoiceProcessingMode,
|
||||
/// Processing backend.
|
||||
pub processing_backend: BridgeAudioBackend,
|
||||
/// VAD backend.
|
||||
pub vad_backend: BridgeVadBackend,
|
||||
/// AEC owner.
|
||||
pub aec: BridgeEffectOwner,
|
||||
/// Noise suppression owner.
|
||||
pub ns: BridgeEffectOwner,
|
||||
/// AGC owner.
|
||||
pub agc: BridgeEffectOwner,
|
||||
/// High-pass filter enabled.
|
||||
pub hpf_enabled: bool,
|
||||
/// Limiter enabled.
|
||||
pub limiter_enabled: bool,
|
||||
/// VAD hangover in ms.
|
||||
pub vad_hangover_ms: u32,
|
||||
/// VAD pre-roll in ms.
|
||||
pub vad_pre_roll_ms: u32,
|
||||
/// Minimum transmit duration in ms.
|
||||
pub vad_min_tx_ms: u32,
|
||||
/// Debug WAV dump enabled.
|
||||
pub debug_wav_dump_enabled: bool,
|
||||
}
|
||||
|
||||
/// P1 audio-processing stats DTO.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BridgeAudioProcessingStats {
|
||||
/// Input dBFS.
|
||||
pub input_dbfs: f32,
|
||||
/// Render dBFS.
|
||||
pub render_dbfs: f32,
|
||||
/// Processed capture dBFS.
|
||||
pub processed_dbfs: f32,
|
||||
/// Latest VAD probability.
|
||||
pub vad_probability: f32,
|
||||
/// VAD active.
|
||||
pub vad_active: bool,
|
||||
/// Currently transmitting.
|
||||
pub transmitting: bool,
|
||||
/// VAD backend.
|
||||
pub vad_backend: BridgeVadBackend,
|
||||
/// Fallback VAD active.
|
||||
pub vad_fallback_active: bool,
|
||||
/// Processing backend.
|
||||
pub processing_backend: BridgeAudioBackend,
|
||||
/// iOS voice-processing mode.
|
||||
pub ios_voice_processing_mode: BridgeIosVoiceProcessingMode,
|
||||
/// Audio route.
|
||||
pub audio_route: BridgeAudioRoute,
|
||||
/// Actual sample rate.
|
||||
pub actual_sample_rate_hz: u32,
|
||||
/// Actual IO buffer frames.
|
||||
pub actual_io_buffer_frames: u32,
|
||||
/// Input overruns.
|
||||
pub input_overruns: u64,
|
||||
/// Output underruns.
|
||||
pub output_underruns: u64,
|
||||
/// Callback xruns.
|
||||
pub callback_xruns: u64,
|
||||
/// Clipped samples.
|
||||
pub clipped_samples: u64,
|
||||
/// Sonora enabled.
|
||||
pub sonora_enabled: bool,
|
||||
/// Platform voice processing enabled.
|
||||
pub platform_voice_processing_enabled: bool,
|
||||
}
|
||||
|
||||
impl From<BridgeAudioRoute> for chanora_core::AudioRoute {
|
||||
fn from(route: BridgeAudioRoute) -> Self {
|
||||
match route {
|
||||
BridgeAudioRoute::Speaker => Self::Speaker,
|
||||
BridgeAudioRoute::Earpiece => Self::Earpiece,
|
||||
BridgeAudioRoute::WiredHeadset => Self::WiredHeadset,
|
||||
BridgeAudioRoute::BluetoothHfp => Self::BluetoothHfp,
|
||||
BridgeAudioRoute::BluetoothA2dp => Self::BluetoothA2dp,
|
||||
BridgeAudioRoute::Unknown => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chanora_core::AudioRoute> for BridgeAudioRoute {
|
||||
fn from(route: chanora_core::AudioRoute) -> Self {
|
||||
match route {
|
||||
chanora_core::AudioRoute::Speaker => Self::Speaker,
|
||||
chanora_core::AudioRoute::Earpiece => Self::Earpiece,
|
||||
chanora_core::AudioRoute::WiredHeadset => Self::WiredHeadset,
|
||||
chanora_core::AudioRoute::BluetoothHfp => Self::BluetoothHfp,
|
||||
chanora_core::AudioRoute::BluetoothA2dp => Self::BluetoothA2dp,
|
||||
chanora_core::AudioRoute::Unknown => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BridgeIosVoiceProcessingMode> for chanora_core::IosVoiceProcessingMode {
|
||||
fn from(mode: BridgeIosVoiceProcessingMode) -> Self {
|
||||
match mode {
|
||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
||||
BridgeIosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chanora_core::IosVoiceProcessingMode> for BridgeIosVoiceProcessingMode {
|
||||
fn from(mode: chanora_core::IosVoiceProcessingMode) -> Self {
|
||||
match mode {
|
||||
chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => {
|
||||
Self::PlatformVoiceProcessing
|
||||
}
|
||||
chanora_core::IosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BridgeAudioBackend> for chanora_core::AudioBackend {
|
||||
fn from(backend: BridgeAudioBackend) -> Self {
|
||||
match backend {
|
||||
BridgeAudioBackend::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
||||
BridgeAudioBackend::Sonora => Self::Sonora,
|
||||
BridgeAudioBackend::WebrtcApm => Self::WebrtcApm,
|
||||
BridgeAudioBackend::Noop => Self::Noop,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chanora_core::AudioBackend> for BridgeAudioBackend {
|
||||
fn from(backend: chanora_core::AudioBackend) -> Self {
|
||||
match backend {
|
||||
chanora_core::AudioBackend::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
||||
chanora_core::AudioBackend::Sonora => Self::Sonora,
|
||||
chanora_core::AudioBackend::WebrtcApm => Self::WebrtcApm,
|
||||
chanora_core::AudioBackend::Noop => Self::Noop,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BridgeVadBackend> for chanora_core::VadBackend {
|
||||
fn from(backend: BridgeVadBackend) -> Self {
|
||||
match backend {
|
||||
BridgeVadBackend::SileroOnnx => Self::SileroOnnx,
|
||||
BridgeVadBackend::TenVad => Self::TenVad,
|
||||
BridgeVadBackend::WebrtcVad => Self::WebrtcVad,
|
||||
BridgeVadBackend::EnergyDebug => Self::EnergyDebug,
|
||||
BridgeVadBackend::Disabled => Self::Disabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chanora_core::VadBackend> for BridgeVadBackend {
|
||||
fn from(backend: chanora_core::VadBackend) -> Self {
|
||||
match backend {
|
||||
chanora_core::VadBackend::SileroOnnx => Self::SileroOnnx,
|
||||
chanora_core::VadBackend::TenVad => Self::TenVad,
|
||||
chanora_core::VadBackend::WebrtcVad => Self::WebrtcVad,
|
||||
chanora_core::VadBackend::EnergyDebug => Self::EnergyDebug,
|
||||
chanora_core::VadBackend::Disabled => Self::Disabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BridgeEffectOwner> for chanora_core::EffectOwner {
|
||||
fn from(owner: BridgeEffectOwner) -> Self {
|
||||
match owner {
|
||||
BridgeEffectOwner::Platform => Self::Platform,
|
||||
BridgeEffectOwner::Sonora => Self::Sonora,
|
||||
BridgeEffectOwner::WebrtcApm => Self::WebrtcApm,
|
||||
BridgeEffectOwner::Conservative => Self::Conservative,
|
||||
BridgeEffectOwner::Off => Self::Off,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chanora_core::EffectOwner> for BridgeEffectOwner {
|
||||
fn from(owner: chanora_core::EffectOwner) -> Self {
|
||||
match owner {
|
||||
chanora_core::EffectOwner::Platform => Self::Platform,
|
||||
chanora_core::EffectOwner::Sonora => Self::Sonora,
|
||||
chanora_core::EffectOwner::WebrtcApm => Self::WebrtcApm,
|
||||
chanora_core::EffectOwner::Conservative => Self::Conservative,
|
||||
chanora_core::EffectOwner::Off => Self::Off,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BridgeAudioProcessingConfig> for chanora_core::AudioProcessingConfig {
|
||||
fn from(config: BridgeAudioProcessingConfig) -> Self {
|
||||
Self {
|
||||
route: config.route.into(),
|
||||
ios_mode: config.ios_mode.into(),
|
||||
processing_backend: config.processing_backend.into(),
|
||||
vad_backend: config.vad_backend.into(),
|
||||
aec: config.aec.into(),
|
||||
ns: config.ns.into(),
|
||||
agc: config.agc.into(),
|
||||
hpf_enabled: config.hpf_enabled,
|
||||
limiter_enabled: config.limiter_enabled,
|
||||
vad_hangover_ms: config.vad_hangover_ms,
|
||||
vad_pre_roll_ms: config.vad_pre_roll_ms,
|
||||
vad_min_tx_ms: config.vad_min_tx_ms,
|
||||
debug_wav_dump_enabled: config.debug_wav_dump_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chanora_core::AudioProcessingConfig> for BridgeAudioProcessingConfig {
|
||||
fn from(c: chanora_core::AudioProcessingConfig) -> Self {
|
||||
Self {
|
||||
route: c.route.into(),
|
||||
ios_mode: c.ios_mode.into(),
|
||||
processing_backend: c.processing_backend.into(),
|
||||
vad_backend: c.vad_backend.into(),
|
||||
aec: c.aec.into(),
|
||||
ns: c.ns.into(),
|
||||
agc: c.agc.into(),
|
||||
hpf_enabled: c.hpf_enabled,
|
||||
limiter_enabled: c.limiter_enabled,
|
||||
vad_hangover_ms: c.vad_hangover_ms,
|
||||
vad_pre_roll_ms: c.vad_pre_roll_ms,
|
||||
vad_min_tx_ms: c.vad_min_tx_ms,
|
||||
debug_wav_dump_enabled: c.debug_wav_dump_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chanora_core::AudioProcessingStats> for BridgeAudioProcessingStats {
|
||||
fn from(stats: chanora_core::AudioProcessingStats) -> Self {
|
||||
Self {
|
||||
input_dbfs: stats.input_dbfs,
|
||||
render_dbfs: stats.render_dbfs,
|
||||
processed_dbfs: stats.processed_dbfs,
|
||||
vad_probability: stats.vad_probability,
|
||||
vad_active: stats.vad_active,
|
||||
transmitting: stats.transmitting,
|
||||
vad_backend: stats.vad_backend.into(),
|
||||
vad_fallback_active: stats.vad_fallback_active,
|
||||
processing_backend: stats.processing_backend.into(),
|
||||
ios_voice_processing_mode: stats.ios_voice_processing_mode.into(),
|
||||
audio_route: stats.audio_route.into(),
|
||||
actual_sample_rate_hz: stats.actual_sample_rate_hz,
|
||||
actual_io_buffer_frames: stats.actual_io_buffer_frames,
|
||||
input_overruns: stats.input_overruns,
|
||||
output_underruns: stats.output_underruns,
|
||||
callback_xruns: stats.callback_xruns,
|
||||
clipped_samples: stats.clipped_samples,
|
||||
sonora_enabled: stats.sonora_enabled,
|
||||
platform_voice_processing_enabled: stats.platform_voice_processing_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Diagnostics (A.3) ----------
|
||||
|
||||
/// User-initiated diagnostic export. Returns a multi-line text
|
||||
@@ -1335,3 +1678,95 @@ pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
||||
ptt_active: p,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the P1 audio-processing config.
|
||||
pub async fn set_audio_processing_config(
|
||||
config: BridgeAudioProcessingConfig,
|
||||
) -> Result<(), BridgeError> {
|
||||
runtime()
|
||||
.spawn(async move { session().set_audio_processing_config(config.into()).await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("set_audio_processing_config", e))??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the current audio-processing config.
|
||||
///
|
||||
/// Returns the live config as last applied to the audio engine.
|
||||
/// Returns a default config when no session is active.
|
||||
pub async fn get_audio_processing_config() -> Result<BridgeAudioProcessingConfig, BridgeError> {
|
||||
let config = runtime()
|
||||
.spawn(async { session().get_audio_processing_config().await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("get_audio_processing_config", e))??;
|
||||
Ok(config.into())
|
||||
}
|
||||
|
||||
/// Read P1 audio-processing diagnostics.
|
||||
pub async fn audio_processing_stats() -> Result<BridgeAudioProcessingStats, BridgeError> {
|
||||
let stats = runtime()
|
||||
.spawn(async { session().audio_processing_stats().await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("audio_processing_stats", e))??;
|
||||
Ok(stats.into())
|
||||
}
|
||||
|
||||
/// Configure the VAD model path.
|
||||
pub async fn set_vad_model_path(path: String) -> Result<(), BridgeError> {
|
||||
if path.trim().is_empty() {
|
||||
return Err(BridgeError::InvalidCommand(
|
||||
"vad model path must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
runtime()
|
||||
.spawn(async move { session().set_vad_model_path(path).await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("set_vad_model_path", e))??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable or disable audio debug WAV dumping.
|
||||
pub async fn enable_audio_debug_wav_dump(enabled: bool) -> Result<(), BridgeError> {
|
||||
runtime()
|
||||
.spawn(async move { session().set_audio_debug_wav_dump(enabled).await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("enable_audio_debug_wav_dump", e))?
|
||||
.map_err(|e| BridgeError::Unmapped(format!("enable_audio_debug_wav_dump: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Select the iOS voice-processing mode.
|
||||
pub async fn set_ios_voice_processing_mode(
|
||||
mode: BridgeIosVoiceProcessingMode,
|
||||
) -> Result<(), BridgeError> {
|
||||
let config = BridgeAudioProcessingConfig {
|
||||
route: BridgeAudioRoute::Speaker,
|
||||
ios_mode: mode,
|
||||
processing_backend: match mode {
|
||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => {
|
||||
BridgeAudioBackend::PlatformVoiceProcessing
|
||||
}
|
||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::Sonora,
|
||||
},
|
||||
vad_backend: BridgeVadBackend::SileroOnnx,
|
||||
aec: match mode {
|
||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::Sonora,
|
||||
},
|
||||
ns: match mode {
|
||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::Sonora,
|
||||
},
|
||||
agc: match mode {
|
||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::Sonora,
|
||||
},
|
||||
hpf_enabled: true,
|
||||
limiter_enabled: true,
|
||||
vad_hangover_ms: 500,
|
||||
vad_pre_roll_ms: 160,
|
||||
vad_min_tx_ms: 200,
|
||||
debug_wav_dump_enabled: false,
|
||||
};
|
||||
set_audio_processing_config(config).await
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
||||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||
);
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1322894465;
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1835973251;
|
||||
|
||||
// Section: executor
|
||||
|
||||
@@ -82,6 +82,41 @@ fn wire__crate__api__add_bookmark_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__audio_processing_stats_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||
flutter_rust_bridge::for_generated::TaskInfo {
|
||||
debug_name: "audio_processing_stats",
|
||||
port: Some(port_),
|
||||
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||
},
|
||||
move || {
|
||||
let message = unsafe {
|
||||
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||
ptr_,
|
||||
rust_vec_len_,
|
||||
data_len_,
|
||||
)
|
||||
};
|
||||
let mut deserializer =
|
||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||
deserializer.end();
|
||||
move |context| async move {
|
||||
transform_result_sse::<_, crate::BridgeError>(
|
||||
(move || async move {
|
||||
let output_ok = crate::api::audio_processing_stats().await?;
|
||||
Ok(output_ok)
|
||||
})()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__audio_stats_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
@@ -261,6 +296,43 @@ fn wire__crate__api__disconnect_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__enable_audio_debug_wav_dump_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||
flutter_rust_bridge::for_generated::TaskInfo {
|
||||
debug_name: "enable_audio_debug_wav_dump",
|
||||
port: Some(port_),
|
||||
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||
},
|
||||
move || {
|
||||
let message = unsafe {
|
||||
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||
ptr_,
|
||||
rust_vec_len_,
|
||||
data_len_,
|
||||
)
|
||||
};
|
||||
let mut deserializer =
|
||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||
let api_enabled = <bool>::sse_decode(&mut deserializer);
|
||||
deserializer.end();
|
||||
move |context| async move {
|
||||
transform_result_sse::<_, crate::BridgeError>(
|
||||
(move || async move {
|
||||
let output_ok =
|
||||
crate::api::enable_audio_debug_wav_dump(api_enabled).await?;
|
||||
Ok(output_ok)
|
||||
})()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__events_stream_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
@@ -495,6 +567,37 @@ fn wire__crate__api__handle_interruption_ended_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__handle_media_services_reset_impl(
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
|
||||
flutter_rust_bridge::for_generated::TaskInfo {
|
||||
debug_name: "handle_media_services_reset",
|
||||
port: None,
|
||||
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
|
||||
},
|
||||
move || {
|
||||
let message = unsafe {
|
||||
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||
ptr_,
|
||||
rust_vec_len_,
|
||||
data_len_,
|
||||
)
|
||||
};
|
||||
let mut deserializer =
|
||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||
deserializer.end();
|
||||
transform_result_sse::<_, ()>((move || {
|
||||
let output_ok = Result::<_, ()>::Ok({
|
||||
crate::api::handle_media_services_reset();
|
||||
})?;
|
||||
Ok(output_ok)
|
||||
})())
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__handle_route_change_impl(
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
@@ -516,10 +619,11 @@ fn wire__crate__api__handle_route_change_impl(
|
||||
};
|
||||
let mut deserializer =
|
||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||
let api_route = <crate::api::BridgeAudioRoute>::sse_decode(&mut deserializer);
|
||||
deserializer.end();
|
||||
transform_result_sse::<_, ()>((move || {
|
||||
let output_ok = Result::<_, ()>::Ok({
|
||||
crate::api::handle_route_change();
|
||||
crate::api::handle_route_change(api_route);
|
||||
})?;
|
||||
Ok(output_ok)
|
||||
})())
|
||||
@@ -734,6 +838,43 @@ fn wire__crate__api__ptt_descriptor_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__set_audio_processing_config_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||
flutter_rust_bridge::for_generated::TaskInfo {
|
||||
debug_name: "set_audio_processing_config",
|
||||
port: Some(port_),
|
||||
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||
},
|
||||
move || {
|
||||
let message = unsafe {
|
||||
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||
ptr_,
|
||||
rust_vec_len_,
|
||||
data_len_,
|
||||
)
|
||||
};
|
||||
let mut deserializer =
|
||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||
let api_config =
|
||||
<crate::api::BridgeAudioProcessingConfig>::sse_decode(&mut deserializer);
|
||||
deserializer.end();
|
||||
move |context| async move {
|
||||
transform_result_sse::<_, crate::BridgeError>(
|
||||
(move || async move {
|
||||
let output_ok = crate::api::set_audio_processing_config(api_config).await?;
|
||||
Ok(output_ok)
|
||||
})()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__set_hard_mute_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
@@ -806,6 +947,43 @@ fn wire__crate__api__set_input_muted_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__set_ios_voice_processing_mode_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||
flutter_rust_bridge::for_generated::TaskInfo {
|
||||
debug_name: "set_ios_voice_processing_mode",
|
||||
port: Some(port_),
|
||||
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||
},
|
||||
move || {
|
||||
let message = unsafe {
|
||||
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||
ptr_,
|
||||
rust_vec_len_,
|
||||
data_len_,
|
||||
)
|
||||
};
|
||||
let mut deserializer =
|
||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||
let api_mode =
|
||||
<crate::api::BridgeIosVoiceProcessingMode>::sse_decode(&mut deserializer);
|
||||
deserializer.end();
|
||||
move |context| async move {
|
||||
transform_result_sse::<_, crate::BridgeError>(
|
||||
(move || async move {
|
||||
let output_ok = crate::api::set_ios_voice_processing_mode(api_mode).await?;
|
||||
Ok(output_ok)
|
||||
})()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__set_network_state_impl(
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
@@ -1056,6 +1234,42 @@ fn wire__crate__api__set_transmit_mode_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__set_vad_model_path_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||
flutter_rust_bridge::for_generated::TaskInfo {
|
||||
debug_name: "set_vad_model_path",
|
||||
port: Some(port_),
|
||||
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||
},
|
||||
move || {
|
||||
let message = unsafe {
|
||||
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||
ptr_,
|
||||
rust_vec_len_,
|
||||
data_len_,
|
||||
)
|
||||
};
|
||||
let mut deserializer =
|
||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||
let api_path = <String>::sse_decode(&mut deserializer);
|
||||
deserializer.end();
|
||||
move |context| async move {
|
||||
transform_result_sse::<_, crate::BridgeError>(
|
||||
(move || async move {
|
||||
let output_ok = crate::api::set_vad_model_path(api_path).await?;
|
||||
Ok(output_ok)
|
||||
})()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__snapshot_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
@@ -1236,6 +1450,117 @@ impl SseDecode for bool {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgeAudioBackend {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut inner = <i32>::sse_decode(deserializer);
|
||||
return match inner {
|
||||
0 => crate::api::BridgeAudioBackend::PlatformVoiceProcessing,
|
||||
1 => crate::api::BridgeAudioBackend::Sonora,
|
||||
2 => crate::api::BridgeAudioBackend::WebrtcApm,
|
||||
3 => crate::api::BridgeAudioBackend::Noop,
|
||||
_ => unreachable!("Invalid variant for BridgeAudioBackend: {}", inner),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgeAudioProcessingConfig {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut var_route = <crate::api::BridgeAudioRoute>::sse_decode(deserializer);
|
||||
let mut var_iosMode = <crate::api::BridgeIosVoiceProcessingMode>::sse_decode(deserializer);
|
||||
let mut var_processingBackend = <crate::api::BridgeAudioBackend>::sse_decode(deserializer);
|
||||
let mut var_vadBackend = <crate::api::BridgeVadBackend>::sse_decode(deserializer);
|
||||
let mut var_aec = <crate::api::BridgeEffectOwner>::sse_decode(deserializer);
|
||||
let mut var_ns = <crate::api::BridgeEffectOwner>::sse_decode(deserializer);
|
||||
let mut var_agc = <crate::api::BridgeEffectOwner>::sse_decode(deserializer);
|
||||
let mut var_hpfEnabled = <bool>::sse_decode(deserializer);
|
||||
let mut var_limiterEnabled = <bool>::sse_decode(deserializer);
|
||||
let mut var_vadHangoverMs = <u32>::sse_decode(deserializer);
|
||||
let mut var_vadPreRollMs = <u32>::sse_decode(deserializer);
|
||||
let mut var_vadMinTxMs = <u32>::sse_decode(deserializer);
|
||||
let mut var_debugWavDumpEnabled = <bool>::sse_decode(deserializer);
|
||||
return crate::api::BridgeAudioProcessingConfig {
|
||||
route: var_route,
|
||||
ios_mode: var_iosMode,
|
||||
processing_backend: var_processingBackend,
|
||||
vad_backend: var_vadBackend,
|
||||
aec: var_aec,
|
||||
ns: var_ns,
|
||||
agc: var_agc,
|
||||
hpf_enabled: var_hpfEnabled,
|
||||
limiter_enabled: var_limiterEnabled,
|
||||
vad_hangover_ms: var_vadHangoverMs,
|
||||
vad_pre_roll_ms: var_vadPreRollMs,
|
||||
vad_min_tx_ms: var_vadMinTxMs,
|
||||
debug_wav_dump_enabled: var_debugWavDumpEnabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgeAudioProcessingStats {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut var_inputDbfs = <f32>::sse_decode(deserializer);
|
||||
let mut var_renderDbfs = <f32>::sse_decode(deserializer);
|
||||
let mut var_processedDbfs = <f32>::sse_decode(deserializer);
|
||||
let mut var_vadProbability = <f32>::sse_decode(deserializer);
|
||||
let mut var_vadActive = <bool>::sse_decode(deserializer);
|
||||
let mut var_transmitting = <bool>::sse_decode(deserializer);
|
||||
let mut var_vadBackend = <crate::api::BridgeVadBackend>::sse_decode(deserializer);
|
||||
let mut var_vadFallbackActive = <bool>::sse_decode(deserializer);
|
||||
let mut var_processingBackend = <crate::api::BridgeAudioBackend>::sse_decode(deserializer);
|
||||
let mut var_iosVoiceProcessingMode =
|
||||
<crate::api::BridgeIosVoiceProcessingMode>::sse_decode(deserializer);
|
||||
let mut var_audioRoute = <crate::api::BridgeAudioRoute>::sse_decode(deserializer);
|
||||
let mut var_actualSampleRateHz = <u32>::sse_decode(deserializer);
|
||||
let mut var_actualIoBufferFrames = <u32>::sse_decode(deserializer);
|
||||
let mut var_inputOverruns = <u64>::sse_decode(deserializer);
|
||||
let mut var_outputUnderruns = <u64>::sse_decode(deserializer);
|
||||
let mut var_callbackXruns = <u64>::sse_decode(deserializer);
|
||||
let mut var_clippedSamples = <u64>::sse_decode(deserializer);
|
||||
let mut var_sonoraEnabled = <bool>::sse_decode(deserializer);
|
||||
let mut var_platformVoiceProcessingEnabled = <bool>::sse_decode(deserializer);
|
||||
return crate::api::BridgeAudioProcessingStats {
|
||||
input_dbfs: var_inputDbfs,
|
||||
render_dbfs: var_renderDbfs,
|
||||
processed_dbfs: var_processedDbfs,
|
||||
vad_probability: var_vadProbability,
|
||||
vad_active: var_vadActive,
|
||||
transmitting: var_transmitting,
|
||||
vad_backend: var_vadBackend,
|
||||
vad_fallback_active: var_vadFallbackActive,
|
||||
processing_backend: var_processingBackend,
|
||||
ios_voice_processing_mode: var_iosVoiceProcessingMode,
|
||||
audio_route: var_audioRoute,
|
||||
actual_sample_rate_hz: var_actualSampleRateHz,
|
||||
actual_io_buffer_frames: var_actualIoBufferFrames,
|
||||
input_overruns: var_inputOverruns,
|
||||
output_underruns: var_outputUnderruns,
|
||||
callback_xruns: var_callbackXruns,
|
||||
clipped_samples: var_clippedSamples,
|
||||
sonora_enabled: var_sonoraEnabled,
|
||||
platform_voice_processing_enabled: var_platformVoiceProcessingEnabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgeAudioRoute {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut inner = <i32>::sse_decode(deserializer);
|
||||
return match inner {
|
||||
0 => crate::api::BridgeAudioRoute::Speaker,
|
||||
1 => crate::api::BridgeAudioRoute::Earpiece,
|
||||
2 => crate::api::BridgeAudioRoute::WiredHeadset,
|
||||
3 => crate::api::BridgeAudioRoute::BluetoothHfp,
|
||||
4 => crate::api::BridgeAudioRoute::BluetoothA2dp,
|
||||
5 => crate::api::BridgeAudioRoute::Unknown,
|
||||
_ => unreachable!("Invalid variant for BridgeAudioRoute: {}", inner),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgeAudioStats {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
@@ -1275,13 +1600,13 @@ impl SseDecode for crate::api::BridgeChannel {
|
||||
let mut var_parent = <u64>::sse_decode(deserializer);
|
||||
let mut var_name = <String>::sse_decode(deserializer);
|
||||
let mut var_order = <i64>::sse_decode(deserializer);
|
||||
let mut var_has_password = <bool>::sse_decode(deserializer);
|
||||
let mut var_hasPassword = <bool>::sse_decode(deserializer);
|
||||
return crate::api::BridgeChannel {
|
||||
id: var_id,
|
||||
parent: var_parent,
|
||||
name: var_name,
|
||||
order: var_order,
|
||||
has_password: var_has_password,
|
||||
has_password: var_hasPassword,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1292,22 +1617,37 @@ impl SseDecode for crate::api::BridgeClient {
|
||||
let mut var_id = <u64>::sse_decode(deserializer);
|
||||
let mut var_channel = <u64>::sse_decode(deserializer);
|
||||
let mut var_name = <String>::sse_decode(deserializer);
|
||||
let mut var_input_muted = <bool>::sse_decode(deserializer);
|
||||
let mut var_output_muted = <bool>::sse_decode(deserializer);
|
||||
let mut var_is_speaking = <bool>::sse_decode(deserializer);
|
||||
let mut var_inputMuted = <bool>::sse_decode(deserializer);
|
||||
let mut var_outputMuted = <bool>::sse_decode(deserializer);
|
||||
let mut var_isSpeaking = <bool>::sse_decode(deserializer);
|
||||
let mut var_isServerQuery = <bool>::sse_decode(deserializer);
|
||||
return crate::api::BridgeClient {
|
||||
id: var_id,
|
||||
channel: var_channel,
|
||||
name: var_name,
|
||||
input_muted: var_input_muted,
|
||||
output_muted: var_output_muted,
|
||||
is_speaking: var_is_speaking,
|
||||
input_muted: var_inputMuted,
|
||||
output_muted: var_outputMuted,
|
||||
is_speaking: var_isSpeaking,
|
||||
is_server_query: var_isServerQuery,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgeEffectOwner {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut inner = <i32>::sse_decode(deserializer);
|
||||
return match inner {
|
||||
0 => crate::api::BridgeEffectOwner::Platform,
|
||||
1 => crate::api::BridgeEffectOwner::Sonora,
|
||||
2 => crate::api::BridgeEffectOwner::WebrtcApm,
|
||||
3 => crate::api::BridgeEffectOwner::Conservative,
|
||||
4 => crate::api::BridgeEffectOwner::Off,
|
||||
_ => unreachable!("Invalid variant for BridgeEffectOwner: {}", inner),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::BridgeError {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
@@ -1455,6 +1795,21 @@ impl SseDecode for crate::api::BridgeEvent {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgeIosVoiceProcessingMode {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut inner = <i32>::sse_decode(deserializer);
|
||||
return match inner {
|
||||
0 => crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||
1 => crate::api::BridgeIosVoiceProcessingMode::SonoraExperimental,
|
||||
_ => unreachable!(
|
||||
"Invalid variant for BridgeIosVoiceProcessingMode: {}",
|
||||
inner
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgeNetworkState {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
@@ -1516,6 +1871,21 @@ impl SseDecode for crate::api::BridgeTransmitMode {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgeVadBackend {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut inner = <i32>::sse_decode(deserializer);
|
||||
return match inner {
|
||||
0 => crate::api::BridgeVadBackend::SileroOnnx,
|
||||
1 => crate::api::BridgeVadBackend::TenVad,
|
||||
2 => crate::api::BridgeVadBackend::WebrtcVad,
|
||||
3 => crate::api::BridgeVadBackend::EnergyDebug,
|
||||
4 => crate::api::BridgeVadBackend::Disabled,
|
||||
_ => unreachable!("Invalid variant for BridgeVadBackend: {}", inner),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::BridgeVoiceJoinErrorCode {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
@@ -1712,32 +2082,39 @@ fn pde_ffi_dispatcher_primary_impl(
|
||||
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
||||
match func_id {
|
||||
1 => wire__crate__api__add_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||
2 => wire__crate__api__audio_stats_impl(port, ptr, rust_vec_len, data_len),
|
||||
3 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len),
|
||||
4 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
|
||||
5 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||
6 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
|
||||
7 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
|
||||
9 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||
10 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||
11 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||
15 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
|
||||
16 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
||||
17 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
|
||||
19 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
|
||||
20 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
|
||||
21 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
|
||||
22 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||
24 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
|
||||
25 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||
26 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
||||
27 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||
28 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||
29 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||
30 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
||||
31 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||
32 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
|
||||
33 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
|
||||
2 => wire__crate__api__audio_processing_stats_impl(port, ptr, rust_vec_len, data_len),
|
||||
3 => wire__crate__api__audio_stats_impl(port, ptr, rust_vec_len, data_len),
|
||||
4 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len),
|
||||
5 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
|
||||
6 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||
7 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
|
||||
8 => wire__crate__api__enable_audio_debug_wav_dump_impl(port, ptr, rust_vec_len, data_len),
|
||||
9 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
|
||||
11 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||
12 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||
13 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||
18 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
|
||||
19 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
||||
20 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
|
||||
22 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
|
||||
23 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
|
||||
24 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
|
||||
25 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
|
||||
26 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||
27 => {
|
||||
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
|
||||
}
|
||||
29 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
|
||||
30 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||
31 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
||||
32 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||
33 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||
34 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||
35 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
|
||||
36 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
||||
37 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||
38 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
|
||||
39 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -1750,18 +2127,136 @@ fn pde_ffi_dispatcher_sync_impl(
|
||||
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
||||
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
||||
match func_id {
|
||||
8 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
|
||||
12 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
|
||||
13 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
|
||||
14 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
|
||||
18 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
|
||||
23 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
||||
10 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
|
||||
14 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
|
||||
15 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
|
||||
16 => wire__crate__api__handle_media_services_reset_impl(ptr, rust_vec_len, data_len),
|
||||
17 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
|
||||
21 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
|
||||
28 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
// Section: rust2dart
|
||||
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioBackend {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
Self::PlatformVoiceProcessing => 0.into_dart(),
|
||||
Self::Sonora => 1.into_dart(),
|
||||
Self::WebrtcApm => 2.into_dart(),
|
||||
Self::Noop => 3.into_dart(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
|
||||
for crate::api::BridgeAudioBackend
|
||||
{
|
||||
}
|
||||
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioBackend>
|
||||
for crate::api::BridgeAudioBackend
|
||||
{
|
||||
fn into_into_dart(self) -> crate::api::BridgeAudioBackend {
|
||||
self
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioProcessingConfig {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
[
|
||||
self.route.into_into_dart().into_dart(),
|
||||
self.ios_mode.into_into_dart().into_dart(),
|
||||
self.processing_backend.into_into_dart().into_dart(),
|
||||
self.vad_backend.into_into_dart().into_dart(),
|
||||
self.aec.into_into_dart().into_dart(),
|
||||
self.ns.into_into_dart().into_dart(),
|
||||
self.agc.into_into_dart().into_dart(),
|
||||
self.hpf_enabled.into_into_dart().into_dart(),
|
||||
self.limiter_enabled.into_into_dart().into_dart(),
|
||||
self.vad_hangover_ms.into_into_dart().into_dart(),
|
||||
self.vad_pre_roll_ms.into_into_dart().into_dart(),
|
||||
self.vad_min_tx_ms.into_into_dart().into_dart(),
|
||||
self.debug_wav_dump_enabled.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart()
|
||||
}
|
||||
}
|
||||
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
|
||||
for crate::api::BridgeAudioProcessingConfig
|
||||
{
|
||||
}
|
||||
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioProcessingConfig>
|
||||
for crate::api::BridgeAudioProcessingConfig
|
||||
{
|
||||
fn into_into_dart(self) -> crate::api::BridgeAudioProcessingConfig {
|
||||
self
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioProcessingStats {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
[
|
||||
self.input_dbfs.into_into_dart().into_dart(),
|
||||
self.render_dbfs.into_into_dart().into_dart(),
|
||||
self.processed_dbfs.into_into_dart().into_dart(),
|
||||
self.vad_probability.into_into_dart().into_dart(),
|
||||
self.vad_active.into_into_dart().into_dart(),
|
||||
self.transmitting.into_into_dart().into_dart(),
|
||||
self.vad_backend.into_into_dart().into_dart(),
|
||||
self.vad_fallback_active.into_into_dart().into_dart(),
|
||||
self.processing_backend.into_into_dart().into_dart(),
|
||||
self.ios_voice_processing_mode.into_into_dart().into_dart(),
|
||||
self.audio_route.into_into_dart().into_dart(),
|
||||
self.actual_sample_rate_hz.into_into_dart().into_dart(),
|
||||
self.actual_io_buffer_frames.into_into_dart().into_dart(),
|
||||
self.input_overruns.into_into_dart().into_dart(),
|
||||
self.output_underruns.into_into_dart().into_dart(),
|
||||
self.callback_xruns.into_into_dart().into_dart(),
|
||||
self.clipped_samples.into_into_dart().into_dart(),
|
||||
self.sonora_enabled.into_into_dart().into_dart(),
|
||||
self.platform_voice_processing_enabled
|
||||
.into_into_dart()
|
||||
.into_dart(),
|
||||
]
|
||||
.into_dart()
|
||||
}
|
||||
}
|
||||
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
|
||||
for crate::api::BridgeAudioProcessingStats
|
||||
{
|
||||
}
|
||||
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioProcessingStats>
|
||||
for crate::api::BridgeAudioProcessingStats
|
||||
{
|
||||
fn into_into_dart(self) -> crate::api::BridgeAudioProcessingStats {
|
||||
self
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioRoute {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
Self::Speaker => 0.into_dart(),
|
||||
Self::Earpiece => 1.into_dart(),
|
||||
Self::WiredHeadset => 2.into_dart(),
|
||||
Self::BluetoothHfp => 3.into_dart(),
|
||||
Self::BluetoothA2dp => 4.into_dart(),
|
||||
Self::Unknown => 5.into_dart(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeAudioRoute {}
|
||||
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioRoute>
|
||||
for crate::api::BridgeAudioRoute
|
||||
{
|
||||
fn into_into_dart(self) -> crate::api::BridgeAudioRoute {
|
||||
self
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioStats {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
@@ -1841,6 +2336,27 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeClient> for crate::api:
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeEffectOwner {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
Self::Platform => 0.into_dart(),
|
||||
Self::Sonora => 1.into_dart(),
|
||||
Self::WebrtcApm => 2.into_dart(),
|
||||
Self::Conservative => 3.into_dart(),
|
||||
Self::Off => 4.into_dart(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeEffectOwner {}
|
||||
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeEffectOwner>
|
||||
for crate::api::BridgeEffectOwner
|
||||
{
|
||||
fn into_into_dart(self) -> crate::api::BridgeEffectOwner {
|
||||
self
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::BridgeError {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
@@ -1973,6 +2489,27 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeEvent> for crate::api::
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeIosVoiceProcessingMode {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
Self::PlatformVoiceProcessing => 0.into_dart(),
|
||||
Self::SonoraExperimental => 1.into_dart(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
|
||||
for crate::api::BridgeIosVoiceProcessingMode
|
||||
{
|
||||
}
|
||||
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeIosVoiceProcessingMode>
|
||||
for crate::api::BridgeIosVoiceProcessingMode
|
||||
{
|
||||
fn into_into_dart(self) -> crate::api::BridgeIosVoiceProcessingMode {
|
||||
self
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeNetworkState {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
@@ -2060,6 +2597,27 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeTransmitMode>
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeVadBackend {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
Self::SileroOnnx => 0.into_dart(),
|
||||
Self::TenVad => 1.into_dart(),
|
||||
Self::WebrtcVad => 2.into_dart(),
|
||||
Self::EnergyDebug => 3.into_dart(),
|
||||
Self::Disabled => 4.into_dart(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeVadBackend {}
|
||||
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeVadBackend>
|
||||
for crate::api::BridgeVadBackend
|
||||
{
|
||||
fn into_into_dart(self) -> crate::api::BridgeVadBackend {
|
||||
self
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgeVoiceJoinErrorCode {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
@@ -2165,6 +2723,91 @@ impl SseEncode for bool {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgeAudioBackend {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<i32>::sse_encode(
|
||||
match self {
|
||||
crate::api::BridgeAudioBackend::PlatformVoiceProcessing => 0,
|
||||
crate::api::BridgeAudioBackend::Sonora => 1,
|
||||
crate::api::BridgeAudioBackend::WebrtcApm => 2,
|
||||
crate::api::BridgeAudioBackend::Noop => 3,
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
},
|
||||
serializer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgeAudioProcessingConfig {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<crate::api::BridgeAudioRoute>::sse_encode(self.route, serializer);
|
||||
<crate::api::BridgeIosVoiceProcessingMode>::sse_encode(self.ios_mode, serializer);
|
||||
<crate::api::BridgeAudioBackend>::sse_encode(self.processing_backend, serializer);
|
||||
<crate::api::BridgeVadBackend>::sse_encode(self.vad_backend, serializer);
|
||||
<crate::api::BridgeEffectOwner>::sse_encode(self.aec, serializer);
|
||||
<crate::api::BridgeEffectOwner>::sse_encode(self.ns, serializer);
|
||||
<crate::api::BridgeEffectOwner>::sse_encode(self.agc, serializer);
|
||||
<bool>::sse_encode(self.hpf_enabled, serializer);
|
||||
<bool>::sse_encode(self.limiter_enabled, serializer);
|
||||
<u32>::sse_encode(self.vad_hangover_ms, serializer);
|
||||
<u32>::sse_encode(self.vad_pre_roll_ms, serializer);
|
||||
<u32>::sse_encode(self.vad_min_tx_ms, serializer);
|
||||
<bool>::sse_encode(self.debug_wav_dump_enabled, serializer);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgeAudioProcessingStats {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<f32>::sse_encode(self.input_dbfs, serializer);
|
||||
<f32>::sse_encode(self.render_dbfs, serializer);
|
||||
<f32>::sse_encode(self.processed_dbfs, serializer);
|
||||
<f32>::sse_encode(self.vad_probability, serializer);
|
||||
<bool>::sse_encode(self.vad_active, serializer);
|
||||
<bool>::sse_encode(self.transmitting, serializer);
|
||||
<crate::api::BridgeVadBackend>::sse_encode(self.vad_backend, serializer);
|
||||
<bool>::sse_encode(self.vad_fallback_active, serializer);
|
||||
<crate::api::BridgeAudioBackend>::sse_encode(self.processing_backend, serializer);
|
||||
<crate::api::BridgeIosVoiceProcessingMode>::sse_encode(
|
||||
self.ios_voice_processing_mode,
|
||||
serializer,
|
||||
);
|
||||
<crate::api::BridgeAudioRoute>::sse_encode(self.audio_route, serializer);
|
||||
<u32>::sse_encode(self.actual_sample_rate_hz, serializer);
|
||||
<u32>::sse_encode(self.actual_io_buffer_frames, serializer);
|
||||
<u64>::sse_encode(self.input_overruns, serializer);
|
||||
<u64>::sse_encode(self.output_underruns, serializer);
|
||||
<u64>::sse_encode(self.callback_xruns, serializer);
|
||||
<u64>::sse_encode(self.clipped_samples, serializer);
|
||||
<bool>::sse_encode(self.sonora_enabled, serializer);
|
||||
<bool>::sse_encode(self.platform_voice_processing_enabled, serializer);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgeAudioRoute {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<i32>::sse_encode(
|
||||
match self {
|
||||
crate::api::BridgeAudioRoute::Speaker => 0,
|
||||
crate::api::BridgeAudioRoute::Earpiece => 1,
|
||||
crate::api::BridgeAudioRoute::WiredHeadset => 2,
|
||||
crate::api::BridgeAudioRoute::BluetoothHfp => 3,
|
||||
crate::api::BridgeAudioRoute::BluetoothA2dp => 4,
|
||||
crate::api::BridgeAudioRoute::Unknown => 5,
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
},
|
||||
serializer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgeAudioStats {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
@@ -2209,6 +2852,25 @@ impl SseEncode for crate::api::BridgeClient {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgeEffectOwner {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<i32>::sse_encode(
|
||||
match self {
|
||||
crate::api::BridgeEffectOwner::Platform => 0,
|
||||
crate::api::BridgeEffectOwner::Sonora => 1,
|
||||
crate::api::BridgeEffectOwner::WebrtcApm => 2,
|
||||
crate::api::BridgeEffectOwner::Conservative => 3,
|
||||
crate::api::BridgeEffectOwner::Off => 4,
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
},
|
||||
serializer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::BridgeError {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
@@ -2340,6 +3002,22 @@ impl SseEncode for crate::api::BridgeEvent {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgeIosVoiceProcessingMode {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<i32>::sse_encode(
|
||||
match self {
|
||||
crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => 0,
|
||||
crate::api::BridgeIosVoiceProcessingMode::SonoraExperimental => 1,
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
},
|
||||
serializer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgeNetworkState {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
@@ -2404,6 +3082,25 @@ impl SseEncode for crate::api::BridgeTransmitMode {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgeVadBackend {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<i32>::sse_encode(
|
||||
match self {
|
||||
crate::api::BridgeVadBackend::SileroOnnx => 0,
|
||||
crate::api::BridgeVadBackend::TenVad => 1,
|
||||
crate::api::BridgeVadBackend::WebrtcVad => 2,
|
||||
crate::api::BridgeVadBackend::EnergyDebug => 3,
|
||||
crate::api::BridgeVadBackend::Disabled => 4,
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
},
|
||||
serializer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::BridgeVoiceJoinErrorCode {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
|
||||
@@ -536,7 +536,7 @@ struct PttBanCheckVisitor {
|
||||
|
||||
impl PttBanCheckVisitor {
|
||||
fn check(&mut self, name: &str) {
|
||||
if !self.banned && PTT_BANNED_FIELDS.iter().any(|b| *b == name) {
|
||||
if !self.banned && PTT_BANNED_FIELDS.contains(&name) {
|
||||
self.banned = true;
|
||||
}
|
||||
}
|
||||
@@ -761,7 +761,6 @@ mod tests {
|
||||
v.check("key_code"); // banned
|
||||
assert!(v.banned);
|
||||
|
||||
let mut v2 = PttBanCheckVisitor::default();
|
||||
for name in [
|
||||
"scan_code",
|
||||
"virtual_key",
|
||||
@@ -772,7 +771,7 @@ mod tests {
|
||||
"key_press_history",
|
||||
"key_timing",
|
||||
] {
|
||||
v2 = PttBanCheckVisitor::default();
|
||||
let mut v2 = PttBanCheckVisitor::default();
|
||||
v2.check(name);
|
||||
assert!(v2.banned, "expected {name} to be banned");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
{
|
||||
"document_id": "CHANORA_P1_VOICE_AGENT_SPEC",
|
||||
"version": "1.1.0",
|
||||
"language": "en",
|
||||
"audience": "ai_agents",
|
||||
"scope": "P1_voice_only",
|
||||
"mode": "modify_existing_scaffold_do_not_rewrite",
|
||||
"based_on": {
|
||||
"archive": "chanora-product-scaffold-v0.zip",
|
||||
"previous_spec": "chanora_p1_voice_design_from_current_scaffold.json"
|
||||
},
|
||||
"project_context": {
|
||||
"ui": "Flutter",
|
||||
"core": "Rust",
|
||||
"network": "tsclientlib",
|
||||
"audio_crate": "crates/chanora_audio",
|
||||
"bridge_crate": "crates/chanora_bridge",
|
||||
"flutter_app": "apps/chanora_flutter",
|
||||
"phase_1_platform": "iOS",
|
||||
"future_platforms": [
|
||||
"Android",
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
]
|
||||
},
|
||||
"current_scaffold_facts": {
|
||||
"ios_audio_backend_exists": true,
|
||||
"ios_current_backend": "VoiceProcessingIO AudioUnit",
|
||||
"android_audio_backend_exists": true,
|
||||
"android_current_backend": "Oboe",
|
||||
"desktop_audio_backend_exists": true,
|
||||
"desktop_current_backend": "cpal / SDL depending on platform",
|
||||
"current_network_frame": {
|
||||
"sample_rate_hz": 48000,
|
||||
"channels": 1,
|
||||
"frame_ms": 20,
|
||||
"samples": 960,
|
||||
"codec": "Opus VoIP"
|
||||
},
|
||||
"current_effects_api_exists": true,
|
||||
"current_effects_are_real_dsp": false,
|
||||
"current_voice_activity_is_real_vad": false,
|
||||
"current_voice_activity_behavior": "VoiceActivity behaves like Continuous",
|
||||
"current_ios_has_platform_voice_processing": true,
|
||||
"current_ios_platform_processing_likely_includes": [
|
||||
"AEC",
|
||||
"NS",
|
||||
"AGC"
|
||||
],
|
||||
"current_rust_software_ns_exists": false,
|
||||
"current_rust_software_aec_exists": false,
|
||||
"current_rust_software_agc_exists": false
|
||||
},
|
||||
"p1_primary_goals": [
|
||||
"Add real VoiceActivity mode using ML VAD.",
|
||||
"Keep iOS VoiceProcessingIO as default shipping voice-processing path.",
|
||||
"Add optional iOS Sonora mode for experimental raw Rust AEC3/NS/AGC2/HPF.",
|
||||
"Make AEC/NS/AGC route-aware and backend-aware.",
|
||||
"Expose voice processing configuration and stats to Flutter.",
|
||||
"Preserve current 20ms Opus/tsclientlib transport behavior.",
|
||||
"Add internal 10ms processing frames without changing network framing."
|
||||
],
|
||||
"non_goals": [
|
||||
"Do not rewrite P0.",
|
||||
"Do not move PCM processing to Flutter.",
|
||||
"Do not replace tsclientlib.",
|
||||
"Do not require Android/Desktop implementation in Phase 1.",
|
||||
"Do not require CoreML conversion for P1.",
|
||||
"Do not require GPU/NPU execution providers for correctness.",
|
||||
"Do not ship AI noise suppression as P1 default.",
|
||||
"Do not use energy VAD as production transmit gate."
|
||||
],
|
||||
"hard_invariants": {
|
||||
"INV_001": "Flutter MUST NOT process realtime PCM.",
|
||||
"INV_002": "Rust AudioEngine MUST own voice state, VAD state, processing config, route policy, stats, and diagnostics.",
|
||||
"INV_003": "Platform audio layer MUST only provide audio I/O, device/session config, and route detection.",
|
||||
"INV_004": "tsclientlib MUST remain the voice network/session layer.",
|
||||
"INV_005": "Keep existing 20ms Opus frame/network contract in P1.",
|
||||
"INV_006": "Add internal 10ms processing frames by splitting 20ms frames into two 10ms frames.",
|
||||
"INV_007": "Realtime callbacks MUST NOT call Flutter, block on network, perform file I/O, allocate unbounded memory, or run ML inference.",
|
||||
"INV_008": "Rust panics MUST NOT cross FFI/bridge boundaries.",
|
||||
"INV_009": "Never enable platform AEC and Rust AEC simultaneously on the same route.",
|
||||
"INV_010": "Never enable VoiceProcessingIO and Sonora AEC3 simultaneously.",
|
||||
"INV_011": "Software AEC-capable backends MUST receive both capture and render-reference streams.",
|
||||
"INV_012": "Render reference MUST be copied from decoded/mixed remote PCM before playout.",
|
||||
"INV_013": "VAD MUST NOT stop capture or playback; VAD may only gate transmit and update speaking/debug state."
|
||||
},
|
||||
"canonical_audio_model": {
|
||||
"network_frame": {
|
||||
"sample_rate_hz": 48000,
|
||||
"channels": 1,
|
||||
"frame_ms": 20,
|
||||
"samples": 960,
|
||||
"sample_format": "i16_or_f32_internal_conversion",
|
||||
"status": "keep_existing"
|
||||
},
|
||||
"processing_frame": {
|
||||
"sample_rate_hz": 48000,
|
||||
"channels": 1,
|
||||
"frame_ms": 10,
|
||||
"samples": 480,
|
||||
"sample_format": "f32",
|
||||
"status": "new_internal_contract"
|
||||
},
|
||||
"vad_model_input": {
|
||||
"sample_rate_hz": 16000,
|
||||
"channels": 1,
|
||||
"sample_format": "f32",
|
||||
"source": "processed_capture",
|
||||
"resample_from": "48kHz"
|
||||
}
|
||||
},
|
||||
"target_architecture": {
|
||||
"layers": [
|
||||
"Flutter UI",
|
||||
"Typed Bridge / FFI",
|
||||
"Rust Application Core",
|
||||
"tsclientlib Adapter",
|
||||
"Rust AudioEngine",
|
||||
"AudioDevice backend",
|
||||
"AudioProcessor backend",
|
||||
"VoiceActivityDetector backend",
|
||||
"VoiceTransmitStateMachine",
|
||||
"AudioStats",
|
||||
"WavDebugRecorder"
|
||||
],
|
||||
"capture_path": [
|
||||
"Platform mic input",
|
||||
"Platform audio callback",
|
||||
"Minimal realtime-safe push into Rust",
|
||||
"Canonical conversion to 48kHz mono f32",
|
||||
"20ms network frame split into 2x10ms processing frames",
|
||||
"Raw mic debug tap",
|
||||
"AudioProcessor.process_capture",
|
||||
"Processed mic debug tap",
|
||||
"ML VAD worker consumes processed capture",
|
||||
"VoiceActivityStateMachine updates transmit state",
|
||||
"If transmitting, collect 10ms frames into 20ms Opus frame",
|
||||
"tsclientlib / existing send path"
|
||||
],
|
||||
"render_path": [
|
||||
"tsclientlib receive/decode/jitter/mix",
|
||||
"Remote mixer output",
|
||||
"Copy render_reference before playout",
|
||||
"If software AEC backend active, call AudioProcessor.process_render on 10ms slices",
|
||||
"Platform output callback",
|
||||
"Speaker / receiver / headset"
|
||||
]
|
||||
},
|
||||
"modules_to_add": {
|
||||
"crates/chanora_audio/src/frame.rs": "AudioFrame10ms, AudioFrame20ms, split/merge helpers, i16/f32 conversion, dBFS helpers.",
|
||||
"crates/chanora_audio/src/processor.rs": "AudioProcessor trait, AudioBackend enum, backend selector.",
|
||||
"crates/chanora_audio/src/processor/platform.rs": "PlatformVoiceProcessing backend marker/policy.",
|
||||
"crates/chanora_audio/src/processor/sonora.rs": "Optional Sonora backend: HPF, AEC3, NS, AGC2, limiter hook.",
|
||||
"crates/chanora_audio/src/processor/noop.rs": "No-op backend for tests/headset/debug.",
|
||||
"crates/chanora_audio/src/vad/mod.rs": "VoiceActivityDetector trait, VadOutput, VadBackend enum.",
|
||||
"crates/chanora_audio/src/vad/silero_onnx.rs": "Silero ONNX VAD runtime wrapper.",
|
||||
"crates/chanora_audio/src/vad/webrtc.rs": "WebRTC VAD fallback.",
|
||||
"crates/chanora_audio/src/vad/energy_debug.rs": "Debug-only energy VAD.",
|
||||
"crates/chanora_audio/src/voice_activity.rs": "VoiceActivity state machine.",
|
||||
"crates/chanora_audio/src/stats.rs": "Expanded AudioStats schema.",
|
||||
"crates/chanora_audio/src/debug_wav.rs": "Async WAV dump writer.",
|
||||
"crates/chanora_audio/src/route_policy.rs": "AudioRoute enum and route-to-processing policy.",
|
||||
"crates/chanora_audio/src/ios_raw_unit.rs": "Optional raw iOS RemoteIO path for Sonora mode."
|
||||
},
|
||||
"modules_to_modify": {
|
||||
"crates/chanora_audio/src/lib.rs": [
|
||||
"Export AudioProcessingConfig, AudioStats, AudioRoute, AudioBackend, VadBackend.",
|
||||
"Replace no-op effects semantics with backend-aware processing config."
|
||||
],
|
||||
"crates/chanora_audio/src/engine.rs": [
|
||||
"Own AudioProcessingConfig.",
|
||||
"Own AudioProcessor backend.",
|
||||
"Own VoiceActivityDetector backend.",
|
||||
"Own VoiceActivityStateMachine.",
|
||||
"Expose expanded stats.",
|
||||
"Preserve existing 20ms Opus send/receive behavior."
|
||||
],
|
||||
"crates/chanora_audio/src/transmit_selector.rs": [
|
||||
"Remove VoiceActivity == Continuous behavior.",
|
||||
"Accept VoiceActivityStateMachine result for VoiceActivity transmit mode."
|
||||
],
|
||||
"crates/chanora_audio/src/ios_voice_unit.rs": [
|
||||
"Keep as platform VoiceProcessingIO backend.",
|
||||
"Ensure Rust AEC/NS/AGC disabled when this backend is active.",
|
||||
"Feed captured audio into Rust VAD/state pipeline.",
|
||||
"Do not run ML inference in callback.",
|
||||
"Expose render_reference for debug/future software AEC validation."
|
||||
],
|
||||
"crates/chanora_audio/src/android_voice_unit.rs": [
|
||||
"Keep current Oboe backend.",
|
||||
"Future: adapt to same AudioProcessor/VAD config and stats."
|
||||
],
|
||||
"crates/chanora_bridge/src/api.rs": [
|
||||
"Add set_audio_processing_config.",
|
||||
"Add audio_processing_stats.",
|
||||
"Add set_vad_model_path or bundled model discovery.",
|
||||
"Add enable_audio_debug_wav_dump."
|
||||
],
|
||||
"apps/chanora_flutter/ios/Runner/AppDelegate.swift": [
|
||||
"Keep AVAudioSession ownership.",
|
||||
"Add/verify mediaServicesWereReset handling.",
|
||||
"Send detailed route class to Rust.",
|
||||
"Do not send PCM to Flutter."
|
||||
]
|
||||
},
|
||||
"voice_processing_backends": {
|
||||
"platform_voice_processing": {
|
||||
"description": "OS/platform voice processing.",
|
||||
"ios_implementation": "VoiceProcessingIO AudioUnit",
|
||||
"ios_default": true,
|
||||
"owns": [
|
||||
"AEC",
|
||||
"NS",
|
||||
"AGC"
|
||||
],
|
||||
"rust_aec": "disabled",
|
||||
"rust_ns": "disabled_by_default",
|
||||
"rust_agc": "disabled_by_default"
|
||||
},
|
||||
"sonora": {
|
||||
"description": "Optional Rust-native WebRTC-style voice processor.",
|
||||
"ios_status": "experimental_optional",
|
||||
"future_cross_platform_status": "candidate",
|
||||
"owns": [
|
||||
"HPF",
|
||||
"AEC3",
|
||||
"NS",
|
||||
"AGC2",
|
||||
"limiter_hook"
|
||||
],
|
||||
"requires": [
|
||||
"raw_or_near_raw_capture",
|
||||
"render_reference",
|
||||
"48kHz mono 10ms frames"
|
||||
],
|
||||
"must_not_run_with": [
|
||||
"VoiceProcessingIO",
|
||||
"platform AEC"
|
||||
],
|
||||
"shipping_default": false
|
||||
},
|
||||
"webrtc_apm": {
|
||||
"description": "Fallback/candidate WebRTC APM backend.",
|
||||
"status": "candidate_or_fallback",
|
||||
"requires": [
|
||||
"render_reference_for_AEC"
|
||||
]
|
||||
},
|
||||
"noop": {
|
||||
"description": "No processing.",
|
||||
"status": "debug_or_headset_policy"
|
||||
}
|
||||
},
|
||||
"ios_processing_modes": {
|
||||
"platform_default": {
|
||||
"mode_id": "ios_platform_voice_processing",
|
||||
"shipping_default": true,
|
||||
"audio_io": "VoiceProcessingIO AudioUnit",
|
||||
"av_audio_session": {
|
||||
"category": "playAndRecord",
|
||||
"mode": "voiceChat",
|
||||
"preferred_sample_rate_hz": 48000,
|
||||
"preferred_io_buffer_duration_ms": [
|
||||
5,
|
||||
10
|
||||
],
|
||||
"options": [
|
||||
"defaultToSpeaker_when_speaker_route",
|
||||
"allowBluetoothHFP",
|
||||
"allowBluetoothA2DP_only_for_output_policy"
|
||||
]
|
||||
},
|
||||
"processing": {
|
||||
"aec": "platform",
|
||||
"ns": "platform",
|
||||
"agc": "platform",
|
||||
"hpf": "platform_or_safe_rust_optional",
|
||||
"limiter": "safe_rust_optional",
|
||||
"sonora": "disabled"
|
||||
},
|
||||
"vad": {
|
||||
"backend": "silero_vad_onnx",
|
||||
"input_source": "platform_processed_capture"
|
||||
}
|
||||
},
|
||||
"sonora_experimental": {
|
||||
"mode_id": "ios_raw_sonora",
|
||||
"shipping_default": false,
|
||||
"audio_io": "RemoteIO_or_raw_input_output_path",
|
||||
"av_audio_session": {
|
||||
"category": "playAndRecord",
|
||||
"mode": "default_or_measurement_candidate",
|
||||
"avoid": [
|
||||
"voiceChat",
|
||||
"videoChat",
|
||||
"VoiceProcessingIO",
|
||||
"setVoiceProcessingEnabled(true)",
|
||||
"setPrefersEchoCancelledInput(true)"
|
||||
]
|
||||
},
|
||||
"processing": {
|
||||
"backend": "sonora",
|
||||
"order": [
|
||||
"hpf",
|
||||
"aec3",
|
||||
"ns",
|
||||
"agc2",
|
||||
"limiter",
|
||||
"vad_input_tap"
|
||||
],
|
||||
"requires_render_reference": true,
|
||||
"requires_raw_or_near_raw_capture": true
|
||||
},
|
||||
"vad": {
|
||||
"backend": "silero_vad_onnx",
|
||||
"input_source": "sonora_processed_capture"
|
||||
},
|
||||
"fallback": {
|
||||
"on_backend_failure": "switch_to_ios_platform_voice_processing",
|
||||
"on_render_reference_missing": "reject_sonora_aec_activation"
|
||||
}
|
||||
}
|
||||
},
|
||||
"route_policy": {
|
||||
"ios": {
|
||||
"speaker": {
|
||||
"default": "platform_voice_processing",
|
||||
"optional": "sonora_experimental",
|
||||
"aec": "platform_by_default",
|
||||
"ns": "platform_by_default",
|
||||
"agc": "platform_by_default",
|
||||
"vad": "silero_vad_onnx",
|
||||
"sonora_allowed_if": [
|
||||
"raw_io_available",
|
||||
"render_reference_available"
|
||||
]
|
||||
},
|
||||
"earpiece": {
|
||||
"default": "platform_voice_processing",
|
||||
"optional": "sonora_experimental",
|
||||
"vad": "silero_vad_onnx"
|
||||
},
|
||||
"wired_headset": {
|
||||
"default": "noop_or_conservative",
|
||||
"aec": "off",
|
||||
"ns": "conservative_optional",
|
||||
"agc": "conservative_optional",
|
||||
"optional": "sonora_ns_agc_without_aec",
|
||||
"vad": "silero_vad_onnx"
|
||||
},
|
||||
"bluetooth_hfp": {
|
||||
"default": "route_managed",
|
||||
"aec": "app_side_off",
|
||||
"ns": "route_managed_or_conservative",
|
||||
"agc": "route_managed_or_conservative",
|
||||
"sonora": "disabled_by_default",
|
||||
"vad": "silero_vad_onnx"
|
||||
},
|
||||
"bluetooth_a2dp": {
|
||||
"default": "invalid_for_duplex_voice",
|
||||
"transmit_allowed": false,
|
||||
"vad": "disabled"
|
||||
},
|
||||
"unknown": {
|
||||
"default": "safe_fallback",
|
||||
"aec": "off_until_classified",
|
||||
"vad": "silero_vad_onnx"
|
||||
}
|
||||
},
|
||||
"future_platforms": {
|
||||
"android": {
|
||||
"default": "platform_effects_if_available_else_rust_backend",
|
||||
"audio_io": "Oboe",
|
||||
"vad": "silero_vad_onnx"
|
||||
},
|
||||
"windows": {
|
||||
"default": "rust_backend_candidate",
|
||||
"audio_io": "WASAPI_or_CPAL",
|
||||
"vad": "silero_vad_onnx"
|
||||
},
|
||||
"macos": {
|
||||
"default": "platform_or_rust_policy",
|
||||
"audio_io": "CoreAudio_or_CPAL",
|
||||
"vad": "silero_vad_onnx"
|
||||
},
|
||||
"linux": {
|
||||
"default": "rust_backend_candidate",
|
||||
"audio_io": "CPAL_with_ALSA_PulseAudio_PipeWire",
|
||||
"vad": "silero_vad_onnx"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vad_runtime_strategy": {
|
||||
"owner": "Rust AudioEngine",
|
||||
"default_backend": "silero_vad_onnx",
|
||||
"model_artifact": "silero_vad.onnx",
|
||||
"primary_runtime": "onnxruntime_c_api",
|
||||
"rust_binding": "ort_or_onnxruntime_wrapper",
|
||||
"fallback_backend": "webrtc_vad",
|
||||
"debug_backend": "energy_vad",
|
||||
"flutter_onnx_plugins": {
|
||||
"allowed_in_realtime_voice_path": false,
|
||||
"blocked_packages_for_core_path": [
|
||||
"onnxruntime_v2",
|
||||
"fonnx"
|
||||
],
|
||||
"reason": "Flutter must not process realtime PCM or own VAD inference."
|
||||
},
|
||||
"coreml": {
|
||||
"p1_primary": false,
|
||||
"p1_conversion_required": false,
|
||||
"p2_candidate": true,
|
||||
"allowed_p1_use": "CoreML Execution Provider benchmark only, not correctness dependency"
|
||||
},
|
||||
"execution_provider_policy": {
|
||||
"correctness_provider": "CPU",
|
||||
"acceleration_providers_optional": true,
|
||||
"ios_optional": [
|
||||
"CoreML_EP_after_benchmark"
|
||||
],
|
||||
"android_optional": [
|
||||
"NNAPI_after_benchmark"
|
||||
],
|
||||
"windows_optional": [
|
||||
"DirectML_after_benchmark",
|
||||
"CUDA_after_benchmark"
|
||||
],
|
||||
"macos_optional": [
|
||||
"CoreML_EP_after_benchmark"
|
||||
],
|
||||
"linux_optional": [
|
||||
"OpenVINO_after_benchmark",
|
||||
"CUDA_after_benchmark",
|
||||
"ROCm_after_benchmark"
|
||||
]
|
||||
},
|
||||
"runtime_rules": {
|
||||
"create_session_once": true,
|
||||
"reuse_session": true,
|
||||
"run_outside_realtime_callback": true,
|
||||
"do_not_create_session_per_frame": true,
|
||||
"fallback_if_model_missing": true,
|
||||
"fallback_if_inference_lag_exceeds_budget": true,
|
||||
"fallback_if_memory_budget_exceeded": true
|
||||
},
|
||||
"model_input": {
|
||||
"sample_rate_hz": 16000,
|
||||
"channels": 1,
|
||||
"source": "processed_capture",
|
||||
"resample_from_hz": 48000
|
||||
},
|
||||
"defaults": {
|
||||
"open_threshold": 0.55,
|
||||
"close_threshold": 0.35,
|
||||
"open_after_ms": 40,
|
||||
"hangover_ms": 500,
|
||||
"pre_roll_ms": 160,
|
||||
"min_tx_ms": 200,
|
||||
"max_inference_lag_ms": 60
|
||||
},
|
||||
"ios_memory_budget": {
|
||||
"target_extra_rss_mb": 30,
|
||||
"max_acceptable_extra_rss_mb": 50,
|
||||
"fail_threshold_extra_rss_mb": 80,
|
||||
"measurement_required": true,
|
||||
"measurement_tool": "Xcode Instruments"
|
||||
}
|
||||
},
|
||||
"noise_suppression_strategy": {
|
||||
"p1_default": "platform_or_webRTC_style_NS_not_AI_NS",
|
||||
"ios": {
|
||||
"default": "VoiceProcessingIO_NS",
|
||||
"rust_ns": "disabled_by_default",
|
||||
"reason": "Avoid double noise suppression and voice degradation."
|
||||
},
|
||||
"ios_sonora_optional": {
|
||||
"default": "Sonora_NS",
|
||||
"requires": [
|
||||
"raw_io",
|
||||
"sonora_backend_enabled"
|
||||
],
|
||||
"status": "experimental"
|
||||
},
|
||||
"android": {
|
||||
"default": "Android NoiseSuppressor if available",
|
||||
"fallback_candidate": "Sonora_NS_or_WebRTC_APM_NS"
|
||||
},
|
||||
"desktop": {
|
||||
"default_candidate": "Sonora_NS_or_WebRTC_APM_NS"
|
||||
},
|
||||
"not_p1_default": [
|
||||
"RNNoise",
|
||||
"DeepFilterNet",
|
||||
"AI_noise_suppression_default"
|
||||
]
|
||||
},
|
||||
"audio_processing_config_schema": {
|
||||
"schema_version": "p1.audio_processing.v1",
|
||||
"sample_rate_hz": 48000,
|
||||
"network_frame_ms": 20,
|
||||
"processing_frame_ms": 10,
|
||||
"route": "speaker|earpiece|wired_headset|bluetooth_hfp|bluetooth_a2dp|unknown",
|
||||
"ios_mode": "platform_voice_processing|sonora_experimental",
|
||||
"processing_backend": "platform_voice_processing|sonora|webrtc_apm|noop",
|
||||
"vad_backend": "silero_vad_onnx|webrtc_vad|energy_debug|disabled",
|
||||
"aec": "platform|sonora|webrtc_apm|off",
|
||||
"ns": "platform|sonora|webrtc_apm|off|conservative",
|
||||
"agc": "platform|sonora|webrtc_apm|off|conservative",
|
||||
"hpf_enabled": true,
|
||||
"limiter_enabled": true,
|
||||
"vad": {
|
||||
"model": "silero_vad.onnx",
|
||||
"runtime": "onnxruntime_c_api",
|
||||
"input_source": "processed_capture",
|
||||
"model_sample_rate_hz": 16000,
|
||||
"open_threshold": 0.55,
|
||||
"close_threshold": 0.35,
|
||||
"open_after_ms": 40,
|
||||
"hangover_ms": 500,
|
||||
"pre_roll_ms": 160,
|
||||
"min_tx_ms": 200,
|
||||
"max_inference_lag_ms": 60,
|
||||
"fallback_backend": "webrtc_vad"
|
||||
},
|
||||
"debug": {
|
||||
"stats_enabled": true,
|
||||
"wav_dump_enabled": false
|
||||
},
|
||||
"invalid_combinations": [
|
||||
"ios_mode=platform_voice_processing AND processing_backend=sonora",
|
||||
"aec=platform AND aec=sonora",
|
||||
"VoiceProcessingIO enabled AND Sonora AEC3 enabled",
|
||||
"vad_backend=energy_debug in production",
|
||||
"route=bluetooth_a2dp AND transmit_allowed=true"
|
||||
]
|
||||
},
|
||||
"ffi_bridge_api_additions": {
|
||||
"set_audio_processing_config": {
|
||||
"crate": "crates/chanora_bridge/src/api.rs",
|
||||
"input": "BridgeAudioProcessingConfig",
|
||||
"output": "Result<(), BridgeError>"
|
||||
},
|
||||
"audio_processing_stats": {
|
||||
"crate": "crates/chanora_bridge/src/api.rs",
|
||||
"input": "none",
|
||||
"output": "BridgeAudioProcessingStats"
|
||||
},
|
||||
"set_vad_model_path": {
|
||||
"crate": "crates/chanora_bridge/src/api.rs",
|
||||
"input": "String",
|
||||
"output": "Result<(), BridgeError>",
|
||||
"note": "May be replaced by bundled model discovery."
|
||||
},
|
||||
"enable_audio_debug_wav_dump": {
|
||||
"crate": "crates/chanora_bridge/src/api.rs",
|
||||
"input": "bool",
|
||||
"output": "Result<(), BridgeError>"
|
||||
},
|
||||
"set_ios_voice_processing_mode": {
|
||||
"crate": "crates/chanora_bridge/src/api.rs",
|
||||
"input": "platform_voice_processing|sonora_experimental",
|
||||
"output": "Result<(), BridgeError>"
|
||||
}
|
||||
},
|
||||
"runtime_stats_schema": {
|
||||
"existing_keep": [
|
||||
"frames_sent",
|
||||
"frames_received"
|
||||
],
|
||||
"add": {
|
||||
"input_dbfs": "float",
|
||||
"render_dbfs": "float",
|
||||
"processed_dbfs": "float",
|
||||
"vad_probability": "float",
|
||||
"vad_active": "bool",
|
||||
"speaking": "bool",
|
||||
"transmitting": "bool",
|
||||
"vad_backend": "enum",
|
||||
"vad_fallback_active": "bool",
|
||||
"vad_inference_lag_ms": "float",
|
||||
"vad_runtime_memory_estimate_mb": "float_optional",
|
||||
"processing_backend": "enum",
|
||||
"ios_voice_processing_mode": "enum_optional",
|
||||
"audio_route": "enum",
|
||||
"actual_sample_rate_hz": "uint32",
|
||||
"actual_io_buffer_frames": "uint32",
|
||||
"capture_queue_ms": "float",
|
||||
"render_queue_ms": "float",
|
||||
"estimated_echo_delay_ms": "float_optional",
|
||||
"input_overruns": "uint64",
|
||||
"output_underruns": "uint64",
|
||||
"callback_xruns": "uint64",
|
||||
"clipped_samples": "uint64",
|
||||
"sonora_enabled": "bool",
|
||||
"platform_voice_processing_enabled": "bool"
|
||||
}
|
||||
},
|
||||
"debug_wav": {
|
||||
"enabled_by_default": false,
|
||||
"streams": {
|
||||
"raw_mic": "before_audio_processor",
|
||||
"render_reference": "remote_mixer_output_before_playout",
|
||||
"processed_mic": "after_audio_processor"
|
||||
},
|
||||
"rules": [
|
||||
"Never write WAV from realtime callback.",
|
||||
"Use bounded queues to async writer.",
|
||||
"Include route/backend/timestamp in filenames or sidecar metadata."
|
||||
]
|
||||
},
|
||||
"failure_modes": {
|
||||
"silero_model_missing": [
|
||||
"fallback_to_webrtc_vad",
|
||||
"emit_structured_error",
|
||||
"stats.vad_fallback_active=true"
|
||||
],
|
||||
"onnxruntime_unavailable": [
|
||||
"fallback_to_webrtc_vad",
|
||||
"do_not_crash_audio_engine"
|
||||
],
|
||||
"vad_inference_late": [
|
||||
"do_not_block_audio",
|
||||
"use_latest_valid_probability_if_not_stale",
|
||||
"fallback_to_webrtc_vad_if_stale"
|
||||
],
|
||||
"onnx_memory_budget_exceeded": [
|
||||
"fallback_to_webrtc_vad",
|
||||
"mark_ml_vad_unavailable",
|
||||
"emit_structured_error"
|
||||
],
|
||||
"sonora_requested_with_vpio": [
|
||||
"reject_config",
|
||||
"return_invalid_config",
|
||||
"do_not_start_double_processing"
|
||||
],
|
||||
"sonora_aec_without_render_reference": [
|
||||
"reject_sonora_aec_activation",
|
||||
"allow_sonora_ns_agc_only_if_configured",
|
||||
"emit_structured_error"
|
||||
],
|
||||
"route_change": [
|
||||
"update AudioRoute",
|
||||
"recompute AudioProcessingConfig",
|
||||
"reset processor if needed",
|
||||
"reset AEC delay state if software AEC active"
|
||||
],
|
||||
"ios_interruption": [
|
||||
"stop_or_pause_audio_io",
|
||||
"resume_after_interruption_end",
|
||||
"update_stats"
|
||||
],
|
||||
"ios_media_services_reset": [
|
||||
"rebuild_AVAudioSession",
|
||||
"rebuild_AudioUnit",
|
||||
"reapply_config",
|
||||
"update_stats"
|
||||
]
|
||||
},
|
||||
"implementation_phases": {
|
||||
"P1A_foundation": [
|
||||
"Add frame.rs 10ms/20ms types.",
|
||||
"Add AudioProcessingConfig.",
|
||||
"Add expanded AudioStats.",
|
||||
"Add AudioRoute/AudioBackend/VadBackend enums.",
|
||||
"Keep existing 20ms Opus path passing."
|
||||
],
|
||||
"P1B_voice_activity": [
|
||||
"Implement VoiceActivityDetector trait.",
|
||||
"Implement VoiceActivityStateMachine.",
|
||||
"Make TransmitMode::VoiceActivity real VAD-gated mode.",
|
||||
"Preserve PTT and Continuous behavior."
|
||||
],
|
||||
"P1C_silero_onnx_vad": [
|
||||
"Choose Rust ONNX Runtime binding.",
|
||||
"Bundle or locate silero_vad.onnx.",
|
||||
"Create ONNX session once and reuse it.",
|
||||
"Implement 48kHz-to-16kHz VAD adapter.",
|
||||
"Run inference off callback thread.",
|
||||
"Implement WebRTC VAD fallback."
|
||||
],
|
||||
"P1D_ios_platform_default": [
|
||||
"Keep VoiceProcessingIO default.",
|
||||
"Route VPIO processed capture to Rust VAD pipeline.",
|
||||
"Expose route/backend/VAD stats.",
|
||||
"Add mediaServicesWereReset handling if missing."
|
||||
],
|
||||
"P1E_ios_sonora_optional": [
|
||||
"Add ios_raw_unit.rs RemoteIO/raw path.",
|
||||
"Add processor/sonora.rs backend.",
|
||||
"Wire render_reference to Sonora process_render.",
|
||||
"Wire raw capture to Sonora process_capture.",
|
||||
"Reject Sonora when VPIO active.",
|
||||
"Mark Sonora experimental and disabled by default."
|
||||
],
|
||||
"P1F_diagnostics": [
|
||||
"Add raw_mic/render_reference/processed_mic WAV dump.",
|
||||
"Expose audio_processing_stats to Flutter debug UI.",
|
||||
"No Flutter PCM."
|
||||
],
|
||||
"P1G_acceptance": [
|
||||
"Speaker echo test VPIO default.",
|
||||
"Speaker echo test Sonora optional.",
|
||||
"Double-talk test VPIO vs Sonora.",
|
||||
"VoiceActivity first-syllable test.",
|
||||
"Quiet-room false-open test.",
|
||||
"Keyboard-noise false-open test.",
|
||||
"Route switching test.",
|
||||
"Interruption recovery test.",
|
||||
"Media services reset recovery test.",
|
||||
"30-minute memory/thermal test with ONNX Runtime."
|
||||
]
|
||||
},
|
||||
"acceptance_criteria": {
|
||||
"vad": {
|
||||
"VAD_001": "VoiceActivity no longer behaves as Continuous.",
|
||||
"VAD_002": "Silero ONNX VAD active by default in P1 builds where runtime/model are available.",
|
||||
"VAD_003": "WebRTC VAD fallback works if Silero/ONNX Runtime is unavailable, late, or over memory budget.",
|
||||
"VAD_004": "First syllable preserved using pre-roll.",
|
||||
"VAD_005": "Hangover prevents choppy transmit close.",
|
||||
"VAD_006": "ML inference never runs inside realtime callback."
|
||||
},
|
||||
"ios_platform": {
|
||||
"IOSP_001": "VoiceProcessingIO remains default iOS shipping backend.",
|
||||
"IOSP_002": "Platform AEC/NS/AGC active on speaker/earpiece policy.",
|
||||
"IOSP_003": "Rust/Sonora AEC/NS/AGC disabled by default when VPIO active.",
|
||||
"IOSP_004": "Route/interruption/media-services-reset handled without app restart."
|
||||
},
|
||||
"ios_sonora": {
|
||||
"IOSS_001": "Sonora backend exists as optional experimental mode.",
|
||||
"IOSS_002": "Sonora mode uses raw or near-raw iOS I/O, not VoiceProcessingIO.",
|
||||
"IOSS_003": "Sonora AEC3 receives render_reference before playout.",
|
||||
"IOSS_004": "Config rejects VoiceProcessingIO + Sonora AEC3 double-processing.",
|
||||
"IOSS_005": "Sonora can be compared against VPIO using same stats and WAV dumps."
|
||||
},
|
||||
"noise_suppression": {
|
||||
"NS_001": "iOS default uses platform NS through VoiceProcessingIO.",
|
||||
"NS_002": "Rust NS is not double-enabled on iOS platform path.",
|
||||
"NS_003": "Sonora NS is available only in Sonora experimental mode.",
|
||||
"NS_004": "AI noise suppression is not P1 default."
|
||||
},
|
||||
"diagnostics": {
|
||||
"DIAG_001": "Stats expose route, backend, iOS mode, VAD probability, VAD backend, fallback state, sample rate, buffer size, xruns.",
|
||||
"DIAG_002": "Debug WAV dump supports raw_mic, render_reference, processed_mic.",
|
||||
"DIAG_003": "WAV writer never runs on realtime callback."
|
||||
},
|
||||
"regression": {
|
||||
"REG_001": "PTT mode remains functionally unchanged except diagnostics.",
|
||||
"REG_002": "Continuous mode remains functionally unchanged except diagnostics.",
|
||||
"REG_003": "Existing voice_join/voice_leave lifecycle remains public lifecycle.",
|
||||
"REG_004": "Existing tsclientlib receive/decode/mix path remains active."
|
||||
}
|
||||
},
|
||||
"agent_execution_rules": {
|
||||
"AGENT_001": "Modify existing scaffold; do not introduce a parallel audio engine.",
|
||||
"AGENT_002": "Start in crates/chanora_audio; bridge and Flutter changes expose config/stats only.",
|
||||
"AGENT_003": "Preserve 20ms Opus network framing.",
|
||||
"AGENT_004": "Introduce 10ms processing frames internally only.",
|
||||
"AGENT_005": "Do not enable Sonora on iOS default path.",
|
||||
"AGENT_006": "Do not enable Rust AEC when platform AEC is active.",
|
||||
"AGENT_007": "Do not run ML inference in realtime callback.",
|
||||
"AGENT_008": "Do not use Flutter ONNX plugins in the core realtime path.",
|
||||
"AGENT_009": "Every implementation PR should reference requirement or acceptance IDs.",
|
||||
"AGENT_010": "If uncertain, preserve P0 lifecycle, tsclientlib transport, and cross-platform AudioEngine boundary."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# iOS release build
|
||||
|
||||
This document records the credential-free iOS P1 release path and the signing handoff for TestFlight/App Store builds.
|
||||
|
||||
## Unsigned verification build
|
||||
|
||||
Run from the repository root on macOS:
|
||||
|
||||
```bash
|
||||
flutter --version
|
||||
./tools/build-ios.sh --no-codesign
|
||||
```
|
||||
|
||||
Expected unsigned output:
|
||||
|
||||
```text
|
||||
apps/chanora_flutter/build/ios/iphoneos/Runner.app/
|
||||
```
|
||||
|
||||
## Store export configuration
|
||||
|
||||
The App Store export template lives at:
|
||||
|
||||
```text
|
||||
apps/chanora_flutter/ios/ExportOptions/AppStore.plist
|
||||
```
|
||||
|
||||
Use it after Apple signing assets are available:
|
||||
|
||||
```bash
|
||||
./tools/build-ios.sh --export-method app-store --export-options-plist apps/chanora_flutter/ios/ExportOptions/AppStore.plist
|
||||
```
|
||||
|
||||
Required signing assets:
|
||||
|
||||
- Apple Developer team access for `app.chanora.chanoraFlutter`
|
||||
- App Store distribution certificate or automatic signing permission
|
||||
- App Store provisioning profile if automatic signing is not used
|
||||
- Xcode 26 or later for uploads on or after 2026-04-28
|
||||
|
||||
## Verification checklist
|
||||
|
||||
- `flutter test` passes in `apps/chanora_flutter`
|
||||
- `flutter analyze` passes in `apps/chanora_flutter`
|
||||
- `flutter build ios --release --no-codesign` succeeds
|
||||
- Signed App Store export succeeds once credentials are installed
|
||||
- App Store metadata does not imply TeamSpeak affiliation
|
||||
+13
-1
@@ -33,6 +33,7 @@ VERSION="v0.2.0-beta.1"
|
||||
SKIP_CODESIGN=0
|
||||
REGEN=0
|
||||
EXPORT_METHOD="development"
|
||||
EXPORT_OPTIONS_PLIST=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -40,6 +41,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--no-codesign) SKIP_CODESIGN=1; shift;;
|
||||
--regenerate-bindings) REGEN=1; shift;;
|
||||
--export-method) EXPORT_METHOD="$2"; shift 2;;
|
||||
--export-options-plist) EXPORT_OPTIONS_PLIST="$2"; shift 2;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2;;
|
||||
esac
|
||||
done
|
||||
@@ -154,7 +156,17 @@ if [[ $SKIP_CODESIGN -eq 1 ]]; then
|
||||
APP_PATH="$FLUTTER_APP/build/ios/iphoneos/Runner.app"
|
||||
echo " Unsigned .app: $APP_PATH"
|
||||
else
|
||||
flutter build ipa --release --export-method "$EXPORT_METHOD"
|
||||
if [[ -n "$EXPORT_OPTIONS_PLIST" ]]; then
|
||||
if [[ -f "$REPO_ROOT/$EXPORT_OPTIONS_PLIST" ]]; then
|
||||
EXPORT_OPTIONS_PLIST="$REPO_ROOT/$EXPORT_OPTIONS_PLIST"
|
||||
elif [[ ! -f "$EXPORT_OPTIONS_PLIST" ]]; then
|
||||
echo " ERROR: export options plist not found: $EXPORT_OPTIONS_PLIST" >&2
|
||||
exit 1
|
||||
fi
|
||||
flutter build ipa --release --export-method "$EXPORT_METHOD" --export-options-plist "$EXPORT_OPTIONS_PLIST"
|
||||
else
|
||||
flutter build ipa --release --export-method "$EXPORT_METHOD"
|
||||
fi
|
||||
IPA_PATH="$FLUTTER_APP/build/ios/ipa/chanora_flutter.ipa"
|
||||
if [[ ! -f "$IPA_PATH" ]]; then
|
||||
echo " ERROR: expected $IPA_PATH, did not find one." >&2
|
||||
|
||||
Reference in New Issue
Block a user