feat(voice): add iOS VAD runtime support

This commit is contained in:
Edison Jwa
2026-05-21 20:51:45 +09:00
parent 171baf6e41
commit 6af4ecab0f
73 changed files with 11529 additions and 1249 deletions
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>
+2 -1
View File
@@ -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
+15 -2
View File
@@ -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;
+124 -30
View File
@@ -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)
}
}
+3 -5
View File
@@ -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>
+127 -12
View File
@@ -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"
+16
View File
@@ -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"
}
+13
View File
@@ -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
View File
@@ -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;
}
}
+403 -3
View File
@@ -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),
),
],
),
),
);
}
}
+75 -61
View File
@@ -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,
),
),
);
}
+8
View File
@@ -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:
+6 -4
View File
@@ -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
+39 -28
View File
@@ -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,
);
}
+62 -49
View File
@@ -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,
);
}
+206 -17
View File
@@ -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);
});
}