feat(voice): add iOS VAD runtime support
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
platform :ios, '13.0'
|
||||
platform :ios, '15.1'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
@@ -39,6 +39,7 @@ target 'Runner' do
|
||||
# flutter_rust_bridge can dlopen() it at runtime via FRB's
|
||||
# default `chanora_bridge.framework/chanora_bridge` lookup path.
|
||||
pod 'chanora_bridge', :path => '.'
|
||||
pod 'onnxruntime-c', '1.22.0'
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
target 'RunnerTests' do
|
||||
|
||||
@@ -5,6 +5,9 @@ PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- haptic_kit (1.0.0):
|
||||
- Flutter
|
||||
- onnxruntime-c (1.22.0)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
|
||||
@@ -13,8 +16,14 @@ DEPENDENCIES:
|
||||
- chanora_bridge (from `.`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- haptic_kit (from `.symlinks/plugins/haptic_kit/ios`)
|
||||
- onnxruntime-c (= 1.22.0)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- onnxruntime-c
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_session:
|
||||
:path: ".symlinks/plugins/audio_session/ios"
|
||||
@@ -24,16 +33,20 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
haptic_kit:
|
||||
:path: ".symlinks/plugins/haptic_kit/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||
chanora_bridge: af821d2c0507cb3199c91be12996bf0eb6b8bf5d
|
||||
chanora_bridge: 0289413733edf8b7c937c50c3c3424b3319b94b5
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
||||
onnxruntime-c: 7f778680e96145956c0a31945f260321eed2611a
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
|
||||
PODFILE CHECKSUM: 15f58b0363434f244766f3301e00b9b1cdee096a
|
||||
PODFILE CHECKSUM: a3abe93db2fc91b90387b399576e9c42a54226e0
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -463,7 +463,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -596,7 +596,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -647,7 +647,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
||||
@@ -5,6 +5,7 @@ import AVFoundation
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
||||
private var iosPlatformChannel: FlutterMethodChannel?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -134,32 +135,12 @@ import AVFoundation
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Request microphone access on first launch rather than waiting
|
||||
// for the user's first voice-channel join. The latter is
|
||||
// surprising: the user has only tapped "connect to server" and
|
||||
// suddenly iOS pops the permission prompt because joining a
|
||||
// text channel happens to trigger audio engine startup. Asking
|
||||
// up-front matches user expectations for a voice-chat client.
|
||||
//
|
||||
// The request is asynchronous and non-blocking. If the user
|
||||
// denies, voice_join will surface a clearer error later when
|
||||
// the audio engine fails to open the input device. The
|
||||
// permission state is cached by iOS so subsequent launches
|
||||
// skip the prompt.
|
||||
//
|
||||
// Deferred ~1 s so iOS finishes initialising the keyboard /
|
||||
// text-input subsystem before the permission alert appears.
|
||||
// Firing the alert too early steals focus from the not-yet-
|
||||
// ready text-input layer, with the symptom that the first tap
|
||||
// on a TextField does nothing (the second tap works because
|
||||
// by then iOS has caught up). DispatchQueue.main.asyncAfter
|
||||
// keeps everything on the main thread; the permission API
|
||||
// itself must be called there too.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||
NSLog("chanora_flutter: microphone permission granted=\(granted)")
|
||||
}
|
||||
}
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleMediaServicesReset(_:)),
|
||||
name: AVAudioSession.mediaServicesWereResetNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
@@ -182,7 +163,7 @@ import AVFoundation
|
||||
// wrong rate, causing pitch + timing artifacts).
|
||||
logAudioSessionState(context: "setActive")
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
let ins = s.currentRoute.inputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",")
|
||||
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||
NSLog(
|
||||
"chanora_flutter: AVAudioSession actual: " +
|
||||
"sampleRate=\(s.sampleRate) " +
|
||||
@@ -221,9 +202,18 @@ import AVFoundation
|
||||
let outputs = routeDescription.outputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||
NSLog("chanora_flutter: route change reason=\(reason.rawValue) outputs=\(outputs)")
|
||||
|
||||
if reason == .oldDeviceUnavailable || reason == .newDeviceAvailable {
|
||||
iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: nil)
|
||||
}
|
||||
// P1: Send the detailed route class to Rust on every route change,
|
||||
// not just device plug/unplug. This covers:
|
||||
// - .newDeviceAvailable / .oldDeviceUnavailable (headset plug/unplug)
|
||||
// - .override (speaker/earpiece toggle)
|
||||
// - .categoryChange (session category changed)
|
||||
// - .wakeFromSleep (device woke from sleep)
|
||||
// - .routeConfigurationChange (BT HFP connect/disconnect)
|
||||
// The Rust side uses the route class to recompute the processing
|
||||
// policy (route_policy.rs) and reset AEC delay state if needed.
|
||||
let routeClass = classifyAudioRoute(routeDescription)
|
||||
NSLog("chanora_flutter: route class=\(routeClass) reason=\(reason.rawValue)")
|
||||
iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: routeClass)
|
||||
}
|
||||
|
||||
@objc private func handleInterruption(_ notification: Notification) {
|
||||
@@ -249,11 +239,115 @@ import AVFoundation
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
||||
NSLog("chanora_flutter: media services reset")
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||
)
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setPreferredSampleRate(48000.0)
|
||||
try session.setActive(true, options: [])
|
||||
logAudioSessionState(context: "mediaServicesWereReset")
|
||||
} catch {
|
||||
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
||||
}
|
||||
// P1: After rebuilding the session, send the current route class to
|
||||
// Rust so it can recompute the processing policy and reset the
|
||||
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
|
||||
// which calls ios_restart_voice_unit.
|
||||
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
||||
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
||||
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
||||
}
|
||||
|
||||
override func applicationWillResignActive(_ application: UIApplication) {
|
||||
iosAudioLifecycleChannel?.invokeMethod("handleWillResignActive", arguments: nil)
|
||||
}
|
||||
|
||||
override func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
iosAudioLifecycleChannel?.invokeMethod("handleDidEnterBackground", arguments: nil)
|
||||
}
|
||||
|
||||
override func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
iosAudioLifecycleChannel?.invokeMethod("handleWillEnterForeground", arguments: nil)
|
||||
}
|
||||
|
||||
override func applicationWillTerminate(_ application: UIApplication) {
|
||||
iosAudioLifecycleChannel?.invokeMethod("handleWillTerminate", arguments: nil)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
iosAudioLifecycleChannel = FlutterMethodChannel(
|
||||
name: "chanora/ios_audio_lifecycle",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
)
|
||||
iosPlatformChannel = FlutterMethodChannel(
|
||||
name: "chanora/ios_platform",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
)
|
||||
iosPlatformChannel?.setMethodCallHandler { call, result in
|
||||
switch call.method {
|
||||
case "getMicrophonePermissionState":
|
||||
result(self.microphonePermissionStateString())
|
||||
case "requestMicrophonePermission":
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||
DispatchQueue.main.async {
|
||||
result(granted ? "Granted" : self.microphonePermissionStateString())
|
||||
}
|
||||
}
|
||||
case "openAppSettings":
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else {
|
||||
result(false)
|
||||
return
|
||||
}
|
||||
UIApplication.shared.open(url, options: [:]) { opened in
|
||||
result(opened)
|
||||
}
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func classifyAudioRoute(_ route: AVAudioSessionRouteDescription) -> String {
|
||||
for output in route.outputs {
|
||||
switch output.portType {
|
||||
case .builtInReceiver:
|
||||
return "Earpiece"
|
||||
case .builtInSpeaker:
|
||||
return "Speaker"
|
||||
case .headphones, .usbAudio:
|
||||
return "WiredHeadset"
|
||||
case .bluetoothHFP:
|
||||
return "BluetoothHfp"
|
||||
case .bluetoothA2DP:
|
||||
return "BluetoothA2dp"
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
private func microphonePermissionStateString() -> String {
|
||||
switch AVAudioSession.sharedInstance().recordPermission {
|
||||
case .granted:
|
||||
return "Granted"
|
||||
case .denied:
|
||||
return "Denied"
|
||||
case .undetermined:
|
||||
return "NotDetermined"
|
||||
@unknown default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<!-- Opt into ProMotion / high-refresh-rate CADisplayLink ranges on
|
||||
supported iPhones. Flutter's iOS embedder reads this key; no
|
||||
additional Flutter package is required for dynamic refresh. -->
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -74,11 +77,6 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<!-- Make Chanora's Documents folder visible to the Files.app and
|
||||
accessible via iTunes / Finder file-sharing. We write
|
||||
diagnostic logs (chanora.log) into Documents/ so users can
|
||||
export them for support. Both keys are required for the
|
||||
"On My iPhone -> Chanora" listing to appear in Files.app. -->
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
|
||||
@@ -40,7 +40,7 @@ Pod::Spec.new do |s|
|
||||
s.license = { :type => 'Apache-2.0 OR MIT', :text => 'See LICENSE-APACHE / LICENSE-MIT at the repo root' }
|
||||
s.author = { 'EdisonJwa' => 'me@edison.network' }
|
||||
s.source = { :path => '.' }
|
||||
s.platform = :ios, '13.0'
|
||||
s.platform = :ios, '15.1'
|
||||
|
||||
# Build the Rust bridge on `pod install`. The script runs under
|
||||
# bash; we use `set -e` so any failure (cargo missing, target not
|
||||
@@ -51,15 +51,59 @@ Pod::Spec.new do |s|
|
||||
s.prepare_command = <<-SCRIPT
|
||||
set -e
|
||||
REPO_ROOT="$(cd ../../.. && pwd)"
|
||||
USER_NAME="$(id -un)"
|
||||
USER_HOME="$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
|
||||
if [ -z "$USER_HOME" ]; then
|
||||
USER_HOME="$(cd ~ && pwd)"
|
||||
fi
|
||||
BRIDGE="$REPO_ROOT/target/aarch64-apple-ios/release/libchanora_bridge.dylib"
|
||||
find_cargo() {
|
||||
for candidate in \
|
||||
"$USER_HOME/.cargo/bin/cargo" \
|
||||
"/opt/homebrew/opt/rustup/bin/cargo" \
|
||||
"/usr/local/opt/rustup/bin/cargo"
|
||||
do
|
||||
if [ -x "$candidate" ]; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
command -v cargo
|
||||
}
|
||||
find_rustc() {
|
||||
for candidate in \
|
||||
"$USER_HOME/.cargo/bin/rustc" \
|
||||
"/opt/homebrew/opt/rustup/bin/rustc" \
|
||||
"/usr/local/opt/rustup/bin/rustc"
|
||||
do
|
||||
if [ -x "$candidate" ]; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
command -v rustc
|
||||
}
|
||||
CARGO_BIN="$(find_cargo)"
|
||||
RUSTC_BIN="$(find_rustc)"
|
||||
ORT_FRAMEWORK="$REPO_ROOT/apps/chanora_flutter/ios/Pods/onnxruntime-c/onnxruntime.xcframework/ios-arm64/onnxruntime.framework"
|
||||
ORT_LINK_DIR="$REPO_ROOT/target/onnxruntime-ios-device"
|
||||
if [ -f "$ORT_FRAMEWORK/onnxruntime" ]; then
|
||||
mkdir -p "$ORT_LINK_DIR"
|
||||
lipo "$ORT_FRAMEWORK/onnxruntime" -thin arm64 -output "$ORT_LINK_DIR/libonnxruntime.a"
|
||||
fi
|
||||
|
||||
echo "[chanora_bridge.podspec] cargo build aarch64-apple-ios"
|
||||
cd "$REPO_ROOT"
|
||||
PATH="$HOME/.cargo/bin:$PATH" \\
|
||||
IPHONEOS_DEPLOYMENT_TARGET=13.0 \\
|
||||
HOME="$USER_HOME" \\
|
||||
CARGO_HOME="$USER_HOME/.cargo" \\
|
||||
RUSTUP_HOME="$USER_HOME/.rustup" \\
|
||||
RUSTUP_TOOLCHAIN="stable-aarch64-apple-darwin" \\
|
||||
RUSTC="$RUSTC_BIN" \\
|
||||
ORT_LIB_LOCATION="$ORT_LINK_DIR" \\
|
||||
IPHONEOS_DEPLOYMENT_TARGET=15.1 \\
|
||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||
CMAKE_OSX_DEPLOYMENT_TARGET=13.0 \\
|
||||
cargo build --release --target aarch64-apple-ios -p chanora_bridge
|
||||
CMAKE_OSX_DEPLOYMENT_TARGET=15.1 \\
|
||||
"$CARGO_BIN" build --release --target aarch64-apple-ios -p chanora_bridge
|
||||
|
||||
if [ ! -f "$BRIDGE" ]; then
|
||||
echo "ERROR: bridge dylib not found at $BRIDGE" >&2
|
||||
@@ -86,7 +130,7 @@ Pod::Spec.new do |s|
|
||||
<key>CFBundleShortVersionString</key><string>1.0.0</string>
|
||||
<key>CFBundleVersion</key><string>1</string>
|
||||
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
|
||||
<key>MinimumOSVersion</key><string>13.0</string>
|
||||
<key>MinimumOSVersion</key><string>15.1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
@@ -116,15 +160,70 @@ PLIST
|
||||
:script => <<-SCRIPT,
|
||||
set -e
|
||||
REPO_ROOT="$(cd "${PODS_TARGET_SRCROOT}/../../.." && pwd)"
|
||||
BRIDGE="$REPO_ROOT/target/aarch64-apple-ios/release/libchanora_bridge.dylib"
|
||||
USER_NAME="$(id -un)"
|
||||
USER_HOME="$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
|
||||
if [ -z "$USER_HOME" ]; then
|
||||
USER_HOME="$(cd ~ && pwd)"
|
||||
fi
|
||||
find_cargo() {
|
||||
for candidate in \
|
||||
"$USER_HOME/.cargo/bin/cargo" \
|
||||
"/opt/homebrew/opt/rustup/bin/cargo" \
|
||||
"/usr/local/opt/rustup/bin/cargo"
|
||||
do
|
||||
if [ -x "$candidate" ]; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
command -v cargo
|
||||
}
|
||||
find_rustc() {
|
||||
for candidate in \
|
||||
"$USER_HOME/.cargo/bin/rustc" \
|
||||
"/opt/homebrew/opt/rustup/bin/rustc" \
|
||||
"/usr/local/opt/rustup/bin/rustc"
|
||||
do
|
||||
if [ -x "$candidate" ]; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
command -v rustc
|
||||
}
|
||||
CARGO_BIN="$(find_cargo)"
|
||||
RUSTC_BIN="$(find_rustc)"
|
||||
if [ "${PLATFORM_NAME:-iphoneos}" = "iphonesimulator" ]; then
|
||||
RUST_TARGET="aarch64-apple-ios-sim"
|
||||
SUPPORTED_PLATFORM="iPhoneSimulator"
|
||||
ORT_SLICE="ios-arm64_x86_64-simulator"
|
||||
else
|
||||
RUST_TARGET="aarch64-apple-ios"
|
||||
SUPPORTED_PLATFORM="iPhoneOS"
|
||||
ORT_SLICE="ios-arm64"
|
||||
fi
|
||||
BRIDGE="$REPO_ROOT/target/$RUST_TARGET/release/libchanora_bridge.dylib"
|
||||
ORT_FRAMEWORK="$REPO_ROOT/apps/chanora_flutter/ios/Pods/onnxruntime-c/onnxruntime.xcframework/$ORT_SLICE/onnxruntime.framework"
|
||||
ORT_LINK_DIR="$REPO_ROOT/target/onnxruntime-$RUST_TARGET"
|
||||
if [ ! -f "$ORT_FRAMEWORK/onnxruntime" ]; then
|
||||
echo "ERROR: ONNX Runtime framework not found at $ORT_FRAMEWORK" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$ORT_LINK_DIR"
|
||||
lipo "$ORT_FRAMEWORK/onnxruntime" -thin arm64 -output "$ORT_LINK_DIR/libonnxruntime.a"
|
||||
|
||||
echo "[chanora_bridge script_phase] cargo build aarch64-apple-ios"
|
||||
echo "[chanora_bridge script_phase] cargo build $RUST_TARGET"
|
||||
cd "$REPO_ROOT"
|
||||
PATH="$HOME/.cargo/bin:$PATH" \\
|
||||
IPHONEOS_DEPLOYMENT_TARGET=13.0 \\
|
||||
HOME="$USER_HOME" \\
|
||||
CARGO_HOME="$USER_HOME/.cargo" \\
|
||||
RUSTUP_HOME="$USER_HOME/.rustup" \\
|
||||
RUSTUP_TOOLCHAIN="stable-aarch64-apple-darwin" \\
|
||||
RUSTC="$RUSTC_BIN" \\
|
||||
ORT_LIB_LOCATION="$ORT_LINK_DIR" \\
|
||||
IPHONEOS_DEPLOYMENT_TARGET=15.1 \\
|
||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
|
||||
CMAKE_OSX_DEPLOYMENT_TARGET=13.0 \\
|
||||
cargo build --release --target aarch64-apple-ios -p chanora_bridge
|
||||
CMAKE_OSX_DEPLOYMENT_TARGET=15.1 \\
|
||||
"$CARGO_BIN" build --release --target "$RUST_TARGET" -p chanora_bridge
|
||||
|
||||
cd "$REPO_ROOT/apps/chanora_flutter/ios"
|
||||
FW=Frameworks/chanora_bridge.framework
|
||||
@@ -139,6 +238,22 @@ PLIST
|
||||
|
||||
mkdir -p "$FW"
|
||||
cp "$BRIDGE" "$FW/chanora_bridge"
|
||||
cat > "$FW/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key><string>chanora_bridge</string>
|
||||
<key>CFBundleIdentifier</key><string>app.chanora.bridge</string>
|
||||
<key>CFBundleName</key><string>chanora_bridge</string>
|
||||
<key>CFBundlePackageType</key><string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key><string>1.0.0</string>
|
||||
<key>CFBundleVersion</key><string>1</string>
|
||||
<key>CFBundleSupportedPlatforms</key><array><string>$SUPPORTED_PLATFORM</string></array>
|
||||
<key>MinimumOSVersion</key><string>15.1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
|
||||
"$FW/chanora_bridge"
|
||||
echo "[chanora_bridge script_phase] framework refreshed"
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
"disconnectAction": "Disconnect",
|
||||
"refreshAction": "Refresh",
|
||||
"diagnosticsAction": "Diagnostics",
|
||||
"diagnosticsSaveAction": "Save export",
|
||||
"diagnosticsSaved": "Diagnostic export saved to {path}",
|
||||
"@diagnosticsSaved": {
|
||||
"placeholders": { "path": { "type": "String" } }
|
||||
},
|
||||
"aboutAction": "About",
|
||||
"aboutVersion": "Version {version}",
|
||||
"@aboutVersion": {
|
||||
@@ -161,5 +166,16 @@
|
||||
"networkPermissionOpenSettings": "Open System Settings",
|
||||
"microphonePermissionTitle": "Microphone Permission Required",
|
||||
"microphonePermissionBody": "Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.",
|
||||
"microphonePermissionRequiredForVoice": "Microphone permission is required for voice transmission.",
|
||||
"permissionGrantAction": "Grant",
|
||||
"audioRouteSystemDefault": "System default",
|
||||
"audioRouteEarpiece": "Earpiece",
|
||||
"audioRouteUsbHeadset": "USB headset",
|
||||
"audioRouteOtherDevice": "Other device",
|
||||
"audioRouteRefreshDevices": "Refresh audio devices",
|
||||
"audioRouteCannotSelect": "This output cannot be selected.",
|
||||
"audioRouteChangeFailed": "Could not change audio output.",
|
||||
"iosAudioInterrupted": "Audio interrupted by system (phone call)",
|
||||
"iosAudioResuming": "Audio resuming",
|
||||
"permissionDenied": "Permission Denied"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"disconnectAction": "断开连接",
|
||||
"refreshAction": "刷新",
|
||||
"diagnosticsAction": "诊断信息",
|
||||
"diagnosticsSaveAction": "保存导出",
|
||||
"diagnosticsSaved": "诊断导出已保存到 {path}",
|
||||
"aboutAction": "关于",
|
||||
"aboutVersion": "版本 {version}",
|
||||
"aboutAuthor": "作者: Edison Jwa",
|
||||
@@ -118,5 +120,16 @@
|
||||
"networkPermissionOpenSettings": "打开系统设置",
|
||||
"microphonePermissionTitle": "需要麦克风权限",
|
||||
"microphonePermissionBody": "Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。",
|
||||
"microphonePermissionRequiredForVoice": "语音发送需要麦克风权限。",
|
||||
"permissionGrantAction": "授权",
|
||||
"audioRouteSystemDefault": "系统默认",
|
||||
"audioRouteEarpiece": "听筒",
|
||||
"audioRouteUsbHeadset": "USB 耳机",
|
||||
"audioRouteOtherDevice": "其他设备",
|
||||
"audioRouteRefreshDevices": "刷新音频设备",
|
||||
"audioRouteCannotSelect": "无法选择此输出设备。",
|
||||
"audioRouteChangeFailed": "无法切换音频输出。",
|
||||
"iosAudioInterrupted": "系统已中断音频(电话通话)",
|
||||
"iosAudioResuming": "音频正在恢复",
|
||||
"permissionDenied": "权限被拒绝"
|
||||
}
|
||||
|
||||
@@ -169,6 +169,18 @@ abstract class AppL10n {
|
||||
/// **'Diagnostics'**
|
||||
String get diagnosticsAction;
|
||||
|
||||
/// No description provided for @diagnosticsSaveAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save export'**
|
||||
String get diagnosticsSaveAction;
|
||||
|
||||
/// No description provided for @diagnosticsSaved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Diagnostic export saved to {path}'**
|
||||
String diagnosticsSaved(String path);
|
||||
|
||||
/// No description provided for @aboutAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -721,6 +733,72 @@ abstract class AppL10n {
|
||||
/// **'Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.'**
|
||||
String get microphonePermissionBody;
|
||||
|
||||
/// No description provided for @microphonePermissionRequiredForVoice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Microphone permission is required for voice transmission.'**
|
||||
String get microphonePermissionRequiredForVoice;
|
||||
|
||||
/// No description provided for @permissionGrantAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Grant'**
|
||||
String get permissionGrantAction;
|
||||
|
||||
/// No description provided for @audioRouteSystemDefault.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'System default'**
|
||||
String get audioRouteSystemDefault;
|
||||
|
||||
/// No description provided for @audioRouteEarpiece.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Earpiece'**
|
||||
String get audioRouteEarpiece;
|
||||
|
||||
/// No description provided for @audioRouteUsbHeadset.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'USB headset'**
|
||||
String get audioRouteUsbHeadset;
|
||||
|
||||
/// No description provided for @audioRouteOtherDevice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Other device'**
|
||||
String get audioRouteOtherDevice;
|
||||
|
||||
/// No description provided for @audioRouteRefreshDevices.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refresh audio devices'**
|
||||
String get audioRouteRefreshDevices;
|
||||
|
||||
/// No description provided for @audioRouteCannotSelect.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This output cannot be selected.'**
|
||||
String get audioRouteCannotSelect;
|
||||
|
||||
/// No description provided for @audioRouteChangeFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not change audio output.'**
|
||||
String get audioRouteChangeFailed;
|
||||
|
||||
/// No description provided for @iosAudioInterrupted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Audio interrupted by system (phone call)'**
|
||||
String get iosAudioInterrupted;
|
||||
|
||||
/// No description provided for @iosAudioResuming.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Audio resuming'**
|
||||
String get iosAudioResuming;
|
||||
|
||||
/// No description provided for @permissionDenied.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -46,6 +46,14 @@ class AppL10nEn extends AppL10n {
|
||||
@override
|
||||
String get diagnosticsAction => 'Diagnostics';
|
||||
|
||||
@override
|
||||
String get diagnosticsSaveAction => 'Save export';
|
||||
|
||||
@override
|
||||
String diagnosticsSaved(String path) {
|
||||
return 'Diagnostic export saved to $path';
|
||||
}
|
||||
|
||||
@override
|
||||
String get aboutAction => 'About';
|
||||
|
||||
@@ -360,6 +368,40 @@ class AppL10nEn extends AppL10n {
|
||||
String get microphonePermissionBody =>
|
||||
'Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.';
|
||||
|
||||
@override
|
||||
String get microphonePermissionRequiredForVoice =>
|
||||
'Microphone permission is required for voice transmission.';
|
||||
|
||||
@override
|
||||
String get permissionGrantAction => 'Grant';
|
||||
|
||||
@override
|
||||
String get audioRouteSystemDefault => 'System default';
|
||||
|
||||
@override
|
||||
String get audioRouteEarpiece => 'Earpiece';
|
||||
|
||||
@override
|
||||
String get audioRouteUsbHeadset => 'USB headset';
|
||||
|
||||
@override
|
||||
String get audioRouteOtherDevice => 'Other device';
|
||||
|
||||
@override
|
||||
String get audioRouteRefreshDevices => 'Refresh audio devices';
|
||||
|
||||
@override
|
||||
String get audioRouteCannotSelect => 'This output cannot be selected.';
|
||||
|
||||
@override
|
||||
String get audioRouteChangeFailed => 'Could not change audio output.';
|
||||
|
||||
@override
|
||||
String get iosAudioInterrupted => 'Audio interrupted by system (phone call)';
|
||||
|
||||
@override
|
||||
String get iosAudioResuming => 'Audio resuming';
|
||||
|
||||
@override
|
||||
String get permissionDenied => 'Permission Denied';
|
||||
}
|
||||
|
||||
@@ -44,6 +44,14 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get diagnosticsAction => '诊断信息';
|
||||
|
||||
@override
|
||||
String get diagnosticsSaveAction => '保存导出';
|
||||
|
||||
@override
|
||||
String diagnosticsSaved(String path) {
|
||||
return '诊断导出已保存到 $path';
|
||||
}
|
||||
|
||||
@override
|
||||
String get aboutAction => '关于';
|
||||
|
||||
@@ -352,6 +360,39 @@ class AppL10nZh extends AppL10n {
|
||||
String get microphonePermissionBody =>
|
||||
'Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。';
|
||||
|
||||
@override
|
||||
String get microphonePermissionRequiredForVoice => '语音发送需要麦克风权限。';
|
||||
|
||||
@override
|
||||
String get permissionGrantAction => '授权';
|
||||
|
||||
@override
|
||||
String get audioRouteSystemDefault => '系统默认';
|
||||
|
||||
@override
|
||||
String get audioRouteEarpiece => '听筒';
|
||||
|
||||
@override
|
||||
String get audioRouteUsbHeadset => 'USB 耳机';
|
||||
|
||||
@override
|
||||
String get audioRouteOtherDevice => '其他设备';
|
||||
|
||||
@override
|
||||
String get audioRouteRefreshDevices => '刷新音频设备';
|
||||
|
||||
@override
|
||||
String get audioRouteCannotSelect => '无法选择此输出设备。';
|
||||
|
||||
@override
|
||||
String get audioRouteChangeFailed => '无法切换音频输出。';
|
||||
|
||||
@override
|
||||
String get iosAudioInterrupted => '系统已中断音频(电话通话)';
|
||||
|
||||
@override
|
||||
String get iosAudioResuming => '音频正在恢复';
|
||||
|
||||
@override
|
||||
String get permissionDenied => '权限被拒绝';
|
||||
}
|
||||
|
||||
+261
-193
@@ -9,9 +9,9 @@
|
||||
// (all carried over from v0.3.0-beta.1)
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io' show File, Platform, Process;
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'dart:io' show Platform, Process;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -21,18 +21,54 @@ import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'l10n/generated/app_localizations.dart';
|
||||
import 'services/android_permissions_service.dart';
|
||||
import 'services/ios_permissions_service.dart';
|
||||
import 'src/rust/api.dart' as rust;
|
||||
import 'src/rust/lib.dart' as rust_err;
|
||||
import 'src/rust/frb_generated.dart';
|
||||
import 'widgets/permission_state_banner.dart';
|
||||
import 'widgets/voice_platform.dart';
|
||||
import 'widgets/voice_bar.dart';
|
||||
import 'widgets/voice_compact.dart';
|
||||
import 'widgets/voice_settings.dart';
|
||||
|
||||
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
|
||||
|
||||
const MethodChannel _iosPlatformChannel = MethodChannel('chanora/ios_platform');
|
||||
|
||||
const Color _appSurfaceColor = Color(0xFFFFFBFE);
|
||||
|
||||
const String _sileroVadAsset = 'assets/models/silero_vad.onnx';
|
||||
const String _tenVadAsset = 'assets/models/ten_vad.onnx';
|
||||
|
||||
Future<File> _copyBundledAssetToDocuments({
|
||||
required String assetPath,
|
||||
required String fileName,
|
||||
}) async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final file = File('${dir.path}/$fileName');
|
||||
final data = await rootBundle.load(assetPath);
|
||||
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
|
||||
if (await file.exists() && await file.length() == bytes.length) {
|
||||
return file;
|
||||
}
|
||||
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
return file;
|
||||
}
|
||||
|
||||
Future<void> _configureBundledVadModels() async {
|
||||
final silero = await _copyBundledAssetToDocuments(
|
||||
assetPath: _sileroVadAsset,
|
||||
fileName: 'silero_vad.onnx',
|
||||
);
|
||||
await _copyBundledAssetToDocuments(
|
||||
assetPath: _tenVadAsset,
|
||||
fileName: 'ten_vad.onnx',
|
||||
);
|
||||
await rust.setVadModelPath(path: silero.path);
|
||||
}
|
||||
|
||||
/// Top padding for macOS to clear traffic-light buttons.
|
||||
const double _macOSTrafficLightPad = 56.0;
|
||||
|
||||
@@ -78,15 +114,6 @@ String? _pttDisplayLabelForKey(LogicalKeyboardKey k) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// True when the host is a touch-only mobile platform without a
|
||||
/// hardware keyboard. Mirrors the helpers in widgets/voice_bar.dart
|
||||
/// and widgets/voice_settings.dart so the AppBar + narrow-mode
|
||||
/// layout in main.dart can branch consistently.
|
||||
bool get _isTouchOnlyPttHost {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS || Platform.isAndroid;
|
||||
}
|
||||
|
||||
/// Public version string shown in the About dialog. Resolved at
|
||||
/// app init by combining a hardcoded semver baseline (kept in sync
|
||||
/// with the git tag and pubspec.yaml's `version:` field) with the
|
||||
@@ -124,6 +151,23 @@ Future<void> main() async {
|
||||
|
||||
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
|
||||
///
|
||||
rust.BridgeAudioRoute _parseBridgeAudioRoute(String s) {
|
||||
switch (s) {
|
||||
case 'Earpiece':
|
||||
return rust.BridgeAudioRoute.earpiece;
|
||||
case 'Speaker':
|
||||
return rust.BridgeAudioRoute.speaker;
|
||||
case 'WiredHeadset':
|
||||
return rust.BridgeAudioRoute.wiredHeadset;
|
||||
case 'BluetoothHfp':
|
||||
return rust.BridgeAudioRoute.bluetoothHfp;
|
||||
case 'BluetoothA2dp':
|
||||
return rust.BridgeAudioRoute.bluetoothA2Dp;
|
||||
default:
|
||||
return rust.BridgeAudioRoute.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// Swift side (AppDelegate) posts route-change and interruption
|
||||
/// events through `FlutterMethodChannel` named
|
||||
/// `"chanora/ios_audio_lifecycle"`. This handler dispatches them to
|
||||
@@ -134,7 +178,12 @@ void _wireIosAudioLifecycle() {
|
||||
try {
|
||||
switch (call.method) {
|
||||
case 'handleRouteChange':
|
||||
rust.handleRouteChange();
|
||||
final routeStr = call.arguments as String? ?? 'Unknown';
|
||||
final route = _parseBridgeAudioRoute(routeStr);
|
||||
rust.handleRouteChange(route: route);
|
||||
break;
|
||||
case 'handleMediaServicesReset':
|
||||
rust.handleMediaServicesReset();
|
||||
break;
|
||||
case 'handleInterruptionBegan':
|
||||
rust.handleInterruptionBegan();
|
||||
@@ -144,6 +193,16 @@ void _wireIosAudioLifecycle() {
|
||||
final shouldResume = call.arguments as bool? ?? false;
|
||||
rust.handleInterruptionEnded(shouldResume: shouldResume);
|
||||
break;
|
||||
case 'handleWillResignActive':
|
||||
case 'handleDidEnterBackground':
|
||||
rust.handleInterruptionBegan();
|
||||
break;
|
||||
case 'handleWillEnterForeground':
|
||||
rust.handleInterruptionEnded(shouldResume: true);
|
||||
break;
|
||||
case 'handleWillTerminate':
|
||||
rust.handleInterruptionBegan();
|
||||
break;
|
||||
default:
|
||||
// Unknown method — ignore gracefully rather than crashing.
|
||||
break;
|
||||
@@ -315,6 +374,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
// (see AndroidPermissionsService for the platform branch).
|
||||
final AndroidPermissionsService _androidPermissions =
|
||||
AndroidPermissionsService();
|
||||
final IosPermissionsService _iosPermissions = IosPermissionsService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -325,9 +385,13 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
// events as early as possible so the listen-only banner reflects
|
||||
// the system state on first frame.
|
||||
_androidPermissions.start();
|
||||
unawaited(_iosPermissions.start());
|
||||
_androidPermissions.recordAudioState.addListener(
|
||||
_onRecordAudioPermissionChanged,
|
||||
);
|
||||
_iosPermissions.recordAudioState.addListener(
|
||||
_onRecordAudioPermissionChanged,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_requestRecordAudioOnStartup());
|
||||
});
|
||||
@@ -337,15 +401,34 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
|
||||
Future<void> _requestRecordAudioOnStartup() async {
|
||||
try {
|
||||
await _androidPermissions.ensureRecordAudio();
|
||||
if (Platform.isAndroid) {
|
||||
await _androidPermissions.ensureRecordAudio();
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort startup prompt only. The join path still gates on
|
||||
// ensureRecordAudio() and applies the listen-only hard-mute policy.
|
||||
}
|
||||
}
|
||||
|
||||
ValueListenable<AndroidRecordAudioPermissionState>
|
||||
get _activeRecordAudioState => Platform.isIOS
|
||||
? _iosPermissions.recordAudioState
|
||||
: _androidPermissions.recordAudioState;
|
||||
|
||||
Future<AndroidRecordAudioPermissionState> _ensureActiveRecordAudio() {
|
||||
return Platform.isIOS
|
||||
? _iosPermissions.ensureRecordAudio()
|
||||
: _androidPermissions.ensureRecordAudio();
|
||||
}
|
||||
|
||||
Future<void> _openActivePermissionSettings() {
|
||||
return Platform.isIOS
|
||||
? _iosPermissions.openAppSettings()
|
||||
: _androidPermissions.openAppSettings();
|
||||
}
|
||||
|
||||
void _onRecordAudioPermissionChanged() {
|
||||
if (_androidPermissions.recordAudioState.value ==
|
||||
if (_activeRecordAudioState.value ==
|
||||
AndroidRecordAudioPermissionState.granted) {
|
||||
unawaited(_clearPermissionHardMute());
|
||||
}
|
||||
@@ -528,16 +611,16 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
if (began) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Audio interrupted by system (phone call)'),
|
||||
SnackBar(
|
||||
content: Text(AppL10n.of(context).iosAudioInterrupted),
|
||||
duration: Duration(seconds: 3),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
} else if (shouldResume) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Audio resuming'),
|
||||
SnackBar(
|
||||
content: Text(AppL10n.of(context).iosAudioResuming),
|
||||
duration: Duration(seconds: 2),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
@@ -612,10 +695,14 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
_androidPermissions.recordAudioState.removeListener(
|
||||
_onRecordAudioPermissionChanged,
|
||||
);
|
||||
_iosPermissions.recordAudioState.removeListener(
|
||||
_onRecordAudioPermissionChanged,
|
||||
);
|
||||
// SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
|
||||
// late invokeMethod from the platform side cannot land on this
|
||||
// disposed state.
|
||||
_androidPermissions.stop();
|
||||
_iosPermissions.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -670,6 +757,14 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
: l10n.microphonePermissionBody,
|
||||
),
|
||||
actions: [
|
||||
if (Platform.isIOS && !isNetwork)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
unawaited(_openIosAppSettings());
|
||||
},
|
||||
child: Text(l10n.networkPermissionOpenSettings),
|
||||
),
|
||||
if (Platform.isMacOS)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@@ -691,6 +786,15 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openIosAppSettings() async {
|
||||
try {
|
||||
await _iosPlatformChannel.invokeMethod<bool>('openAppSettings');
|
||||
} catch (_) {
|
||||
// Best-effort affordance only; if iOS refuses the URL, the
|
||||
// dialog still explained the missing microphone permission.
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: unused_element
|
||||
Future<void> _setPtt(bool active, {bool reportError = true}) async {
|
||||
try {
|
||||
@@ -718,13 +822,9 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
final next = !_outputMuted;
|
||||
try {
|
||||
await rust.setOutputMuted(muted: next);
|
||||
await rust.setHardMute(
|
||||
muted: next || _inputMuted || _hardMuteByPermission,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_outputMuted = next;
|
||||
_hardMute = next || _inputMuted || _hardMuteByPermission;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -770,7 +870,11 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
// Trace: SDD-106 §1 (request timing), §2 (listen-only on denial),
|
||||
// §3 (path to settings on permanent denial), §6
|
||||
// (TransmitModeSelector clamp); SRS-209.
|
||||
final permState = await _androidPermissions.ensureRecordAudio();
|
||||
final permState = Platform.isAndroid
|
||||
? await _androidPermissions.ensureRecordAudio()
|
||||
: Platform.isIOS
|
||||
? await _iosPermissions.ensureRecordAudio()
|
||||
: AndroidRecordAudioPermissionState.granted;
|
||||
if (permState != AndroidRecordAudioPermissionState.granted) {
|
||||
// Listen-only: clamp hard-mute. The permission_state_banner
|
||||
// surfaces the path-to-grant; the user can re-attempt at any
|
||||
@@ -800,6 +904,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
});
|
||||
}
|
||||
}
|
||||
await _configureBundledVadModels();
|
||||
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
|
||||
if (!mounted) return;
|
||||
unawaited(_onRefresh());
|
||||
@@ -930,19 +1035,42 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
/// Narrow-mode voice controls modal sheet (Plan E status chip
|
||||
/// trigger). On mobile this is the **single** voice-controls
|
||||
/// surface: route picker + inline mode radio + inline release-tail
|
||||
/// slider + level meter + stats + (desktop-only) capability badge.
|
||||
/// Zero navigation depth \u2014 no nested dialog.
|
||||
/// slider + level meter + stats + audio processing + (desktop-only)
|
||||
/// capability badge. Zero navigation depth — no nested dialog.
|
||||
Future<void> _onOpenVoiceDetailsSheet() async {
|
||||
// Load current audio processing config for the sheet.
|
||||
rust.BridgeAudioProcessingConfig audioConfig;
|
||||
try {
|
||||
audioConfig = await rust.getAudioProcessingConfig();
|
||||
} catch (_) {
|
||||
audioConfig = const rust.BridgeAudioProcessingConfig(
|
||||
route: rust.BridgeAudioRoute.unknown,
|
||||
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: rust.BridgeVadBackend.sileroOnnx,
|
||||
aec: rust.BridgeEffectOwner.platform,
|
||||
ns: rust.BridgeEffectOwner.platform,
|
||||
agc: rust.BridgeEffectOwner.platform,
|
||||
hpfEnabled: true,
|
||||
limiterEnabled: true,
|
||||
vadHangoverMs: 500,
|
||||
vadPreRollMs: 160,
|
||||
vadMinTxMs: 200,
|
||||
debugWavDumpEnabled: false,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
await showVoiceDetailsSheet(
|
||||
context,
|
||||
audioStats: _audioStats,
|
||||
transmitMode: _transmitMode,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
pttLevel: _pttLevel,
|
||||
pttBackendId: _pttBackendId,
|
||||
pttBoundInputClass: _pttBoundInputClass,
|
||||
isTouchOnly: _isTouchOnlyPttHost,
|
||||
isTouchOnly: isTouchOnlyPttHost,
|
||||
initialAudioConfig: audioConfig,
|
||||
onModeChanged: (mode) async {
|
||||
try {
|
||||
await rust.setTransmitMode(mode: mode);
|
||||
@@ -963,21 +1091,55 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
setState(() => _error = e.toString());
|
||||
}
|
||||
},
|
||||
onAudioConfigChanged: (config) async {
|
||||
try {
|
||||
await rust.setAudioProcessingConfig(config: config);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onOpenVoiceSettings() async {
|
||||
// Load the current audio processing config before opening the dialog.
|
||||
rust.BridgeAudioProcessingConfig audioConfig;
|
||||
try {
|
||||
audioConfig = await rust.getAudioProcessingConfig();
|
||||
} catch (_) {
|
||||
// If not connected yet, use a sensible default.
|
||||
audioConfig = const rust.BridgeAudioProcessingConfig(
|
||||
route: rust.BridgeAudioRoute.unknown,
|
||||
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: rust.BridgeVadBackend.sileroOnnx,
|
||||
aec: rust.BridgeEffectOwner.platform,
|
||||
ns: rust.BridgeEffectOwner.platform,
|
||||
agc: rust.BridgeEffectOwner.platform,
|
||||
hpfEnabled: true,
|
||||
limiterEnabled: true,
|
||||
vadHangoverMs: 500,
|
||||
vadPreRollMs: 160,
|
||||
vadMinTxMs: 200,
|
||||
debugWavDumpEnabled: false,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
final result = await showDialog<VoiceSettingsResult>(
|
||||
context: context,
|
||||
builder: (ctx) => VoiceSettingsDialog(
|
||||
initialMode: _transmitMode,
|
||||
initialReleaseTailMs: _releaseTailMs,
|
||||
initialAudioConfig: audioConfig,
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
try {
|
||||
await rust.setTransmitMode(mode: result.mode);
|
||||
await rust.setReleaseTailMs(ms: result.releaseTailMs);
|
||||
await rust.setAudioProcessingConfig(config: result.audioConfig);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
@@ -1083,6 +1245,27 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final path = await _writeDiagnosticExport(text);
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.of(ctx).pop();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.diagnosticsSaved(path))),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.of(ctx).pop();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.statusError(e.toString()))),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(l10n.diagnosticsSaveAction),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
@@ -1100,6 +1283,18 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _writeDiagnosticExport(String text) async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final stamp = DateTime.now()
|
||||
.toUtc()
|
||||
.toIso8601String()
|
||||
.replaceAll(':', '-')
|
||||
.replaceAll('.', '-');
|
||||
final file = File('${dir.path}/chanora-diagnostics-$stamp.txt');
|
||||
await file.writeAsString(text, flush: true);
|
||||
return file.path;
|
||||
}
|
||||
|
||||
Future<void> _onConfigurePtt(BuildContext context) async {
|
||||
// On the Linux GNOME-Wayland portal backend, the portal hosts
|
||||
// its own system-managed binding dialog (gen2 v0.9.3 / Q3a).
|
||||
@@ -1399,26 +1594,33 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
const SizedBox(height: 12),
|
||||
if (_phase == _Phase.idle) ...[
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ConnectForm(
|
||||
hostCtl: _hostCtl,
|
||||
nickCtl: _nickCtl,
|
||||
passwordCtl: _passwordCtl,
|
||||
onConnect: () => _onConnect(),
|
||||
onAddBookmark: _onAddCurrentBookmark,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_BookmarkList(
|
||||
bookmarks: _bookmarks,
|
||||
onConnect: _onUseBookmark,
|
||||
onDelete: _onDeleteBookmark,
|
||||
),
|
||||
],
|
||||
child: AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ConnectForm(
|
||||
hostCtl: _hostCtl,
|
||||
nickCtl: _nickCtl,
|
||||
passwordCtl: _passwordCtl,
|
||||
onConnect: () => _onConnect(),
|
||||
onAddBookmark: _onAddCurrentBookmark,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_BookmarkList(
|
||||
bookmarks: _bookmarks,
|
||||
onConnect: _onUseBookmark,
|
||||
onDelete: _onDeleteBookmark,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1450,11 +1652,14 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
onConfigure: _onOpenVoiceSettings,
|
||||
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
||||
);
|
||||
// SDD-106 §2/§3 + SRS-209: listen-only banner.
|
||||
// Self-hides on granted / unknown / non-Android.
|
||||
final permissionBanner = PermissionStateBanner(
|
||||
service: _androidPermissions,
|
||||
);
|
||||
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
|
||||
// banner. Self-hides on granted / unknown.
|
||||
final permissionBanner =
|
||||
PermissionStateBanner.fromCallbacks(
|
||||
recordAudioState: _activeRecordAudioState,
|
||||
ensureRecordAudio: _ensureActiveRecordAudio,
|
||||
openAppSettings: _openActivePermissionSettings,
|
||||
);
|
||||
final snapshotView = _SnapshotView(
|
||||
snapshot: _snapshot!,
|
||||
audioStats: _audioStats,
|
||||
@@ -1501,7 +1706,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
releaseTailMs: _releaseTailMs,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
audioStats: _audioStats,
|
||||
isTouchOnly: _isTouchOnlyPttHost,
|
||||
isTouchOnly: isTouchOnlyPttHost,
|
||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
||||
),
|
||||
if (_inChannel &&
|
||||
@@ -1552,7 +1757,10 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: headerTitle, actions: headerActions),
|
||||
body: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1961,146 +2169,6 @@ class _BookmarkList extends StatelessWidget {
|
||||
/// Driven by the `BridgeEvent::PttCapability` stream published by
|
||||
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
|
||||
/// updates the props on each transition.
|
||||
class PttCapabilityBadge extends StatelessWidget {
|
||||
/// Construct a badge.
|
||||
const PttCapabilityBadge({
|
||||
super.key,
|
||||
required this.level,
|
||||
required this.backendId,
|
||||
required this.boundInputClass,
|
||||
});
|
||||
|
||||
/// Resolved capability level as the bridge emits it
|
||||
/// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
|
||||
/// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
|
||||
final String level;
|
||||
|
||||
/// Stable backend identifier (`focused`, `windows-raw-input`, …).
|
||||
final String backendId;
|
||||
|
||||
/// Privacy-safe input class (`keyboard`, `mouse-side-button`,
|
||||
/// or empty when no binding is set).
|
||||
final String boundInputClass;
|
||||
|
||||
bool get _isFocused => level == 'L0Focused';
|
||||
|
||||
String _explainBodyForPlatform(AppL10n l10n) {
|
||||
// Use `defaultTargetPlatform` rather than `Theme.of(context).platform`
|
||||
// because the latter is influenced by debug platform overrides
|
||||
// that callers may toggle in dev mode. We want the badge's
|
||||
// explanation to match the actual host OS.
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.windows:
|
||||
return l10n.pttCapabilityExplainGoGlobalWindows;
|
||||
case TargetPlatform.macOS:
|
||||
return l10n.pttCapabilityExplainGoGlobalMacos;
|
||||
case TargetPlatform.linux:
|
||||
return l10n.pttCapabilityExplainGoGlobalLinux;
|
||||
case TargetPlatform.iOS:
|
||||
return l10n.pttCapabilityExplainGoGlobalIos;
|
||||
default:
|
||||
return l10n.pttCapabilityExplainGoGlobalGeneric;
|
||||
}
|
||||
}
|
||||
|
||||
void _openExplanationSheet(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final theme = Theme.of(sheetContext);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttCapabilityExplainTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedHeading,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_explainBodyForPlatform(l10n),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
|
||||
final tooltipMessage = boundInputClass.isEmpty
|
||||
? badgeLabel
|
||||
: '$badgeLabel\n($boundInputClass)';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Tooltip(
|
||||
message: tooltipMessage,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isFocused ? Icons.crop_free : Icons.public,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Info icon only for L0Focused — the explanation sheet
|
||||
// tells the user why their PTT may not work outside the
|
||||
// app window and how to grant the permission. There is
|
||||
// intentionally NO 'Configure' button here: the single
|
||||
// configuration entry point is the Voice Bar's
|
||||
// settings gear (onConfigure on `VoiceBar`). Having two
|
||||
// identical bind-key entry points just confuses users.
|
||||
if (_isFocused)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline, size: 16),
|
||||
tooltip: l10n.pttCapabilityExplainTitle,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _openExplanationSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SnapshotView extends StatelessWidget {
|
||||
const _SnapshotView({
|
||||
required this.snapshot,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/// iOS microphone permission integration for AVAudioSession.
|
||||
///
|
||||
/// Trace:
|
||||
/// - SRS-164 (iOS system permission presentation and settings path).
|
||||
/// - SRS-114 / SRS-138 (iOS platform-service behaviour for audio).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'android_permissions_service.dart';
|
||||
|
||||
@visibleForTesting
|
||||
const String iosPlatformChannelName = 'chanora/ios_platform';
|
||||
|
||||
@visibleForTesting
|
||||
const String methodGetMicrophonePermissionState =
|
||||
'getMicrophonePermissionState';
|
||||
|
||||
@visibleForTesting
|
||||
const String methodRequestMicrophonePermission = 'requestMicrophonePermission';
|
||||
|
||||
@visibleForTesting
|
||||
const String methodIosOpenAppSettings = 'openAppSettings';
|
||||
|
||||
/// Dart-side integration for iOS microphone permission state.
|
||||
class IosPermissionsService {
|
||||
IosPermissionsService({MethodChannel? channel})
|
||||
: _channel =
|
||||
channel ??
|
||||
(_isIOS ? const MethodChannel(iosPlatformChannelName) : null);
|
||||
|
||||
static bool get _isIOS {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS;
|
||||
}
|
||||
|
||||
final MethodChannel? _channel;
|
||||
|
||||
final ValueNotifier<AndroidRecordAudioPermissionState> _state =
|
||||
ValueNotifier<AndroidRecordAudioPermissionState>(
|
||||
_isIOS
|
||||
? AndroidRecordAudioPermissionState.unknown
|
||||
: AndroidRecordAudioPermissionState.granted,
|
||||
);
|
||||
|
||||
ValueListenable<AndroidRecordAudioPermissionState> get recordAudioState =>
|
||||
_state;
|
||||
|
||||
Future<void> start() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
try {
|
||||
_state.value = _parseState(
|
||||
await ch.invokeMethod<String>(methodGetMicrophonePermissionState),
|
||||
);
|
||||
} catch (_) {
|
||||
_state.value = AndroidRecordAudioPermissionState.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {}
|
||||
|
||||
Future<AndroidRecordAudioPermissionState> ensureRecordAudio() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return AndroidRecordAudioPermissionState.granted;
|
||||
try {
|
||||
final state = _parseState(
|
||||
await ch.invokeMethod<String>(methodRequestMicrophonePermission),
|
||||
);
|
||||
_state.value = state;
|
||||
return state;
|
||||
} catch (_) {
|
||||
return _state.value;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openAppSettings() async {
|
||||
final ch = _channel;
|
||||
if (ch == null) return;
|
||||
try {
|
||||
await ch.invokeMethod<bool>(methodIosOpenAppSettings);
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'openAppSettings failed',
|
||||
name: 'IosPermissionsService',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void dispose() {
|
||||
_state.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
AndroidRecordAudioPermissionState _parseState(String? raw) {
|
||||
switch (raw) {
|
||||
case 'Granted':
|
||||
return AndroidRecordAudioPermissionState.granted;
|
||||
case 'Denied':
|
||||
return AndroidRecordAudioPermissionState.permanentlyDenied;
|
||||
case 'NotDetermined':
|
||||
return AndroidRecordAudioPermissionState.denied;
|
||||
default:
|
||||
return AndroidRecordAudioPermissionState.unknown;
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
|
||||
part 'api.freezed.dart';
|
||||
|
||||
// These functions are ignored because they are not marked as `pub`: `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `transmit_mode_from_u8`
|
||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
|
||||
// These functions are ignored because they are not marked as `pub`: `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8`
|
||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
|
||||
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
|
||||
|
||||
/// Return the platform-conventional log-file path as a string, or
|
||||
@@ -43,7 +43,12 @@ Future<void> disconnect() => RustLib.instance.api.crateApiDisconnect();
|
||||
Future<bool> isConnected() => RustLib.instance.api.crateApiIsConnected();
|
||||
|
||||
/// Handle iOS AVAudioSession route changes (SDD-100).
|
||||
void handleRouteChange() => RustLib.instance.api.crateApiHandleRouteChange();
|
||||
void handleRouteChange({required BridgeAudioRoute route}) =>
|
||||
RustLib.instance.api.crateApiHandleRouteChange(route: route);
|
||||
|
||||
/// Handle iOS AVAudioSession media-services reset.
|
||||
void handleMediaServicesReset() =>
|
||||
RustLib.instance.api.crateApiHandleMediaServicesReset();
|
||||
|
||||
/// Handle iOS AVAudioSession interruption begin (SDD-101).
|
||||
void handleInterruptionBegan() =>
|
||||
@@ -221,6 +226,330 @@ Stream<BridgeEvent> eventsStream() =>
|
||||
Future<BridgeAudioStats> audioStats() =>
|
||||
RustLib.instance.api.crateApiAudioStats();
|
||||
|
||||
/// Apply the P1 audio-processing config.
|
||||
Future<void> setAudioProcessingConfig({
|
||||
required BridgeAudioProcessingConfig config,
|
||||
}) async {
|
||||
_lastAppliedAudioConfig = config;
|
||||
return RustLib.instance.api.crateApiSetAudioProcessingConfig(config: config);
|
||||
}
|
||||
|
||||
/// Read P1 audio-processing diagnostics.
|
||||
Future<BridgeAudioProcessingStats> audioProcessingStats() =>
|
||||
RustLib.instance.api.crateApiAudioProcessingStats();
|
||||
|
||||
/// Read the current audio-processing config.
|
||||
///
|
||||
/// Derives the config from [audioProcessingStats] for the route/backend
|
||||
/// fields, and returns the last value applied via [setAudioProcessingConfig]
|
||||
/// for timing/debug fields. Falls back to P1 spec defaults on first call.
|
||||
Future<BridgeAudioProcessingConfig> getAudioProcessingConfig() async {
|
||||
BridgeAudioProcessingStats? stats;
|
||||
try {
|
||||
stats = await audioProcessingStats();
|
||||
} catch (_) {}
|
||||
|
||||
final last = _lastAppliedAudioConfig;
|
||||
return BridgeAudioProcessingConfig(
|
||||
route: stats?.audioRoute ?? last?.route ?? BridgeAudioRoute.unknown,
|
||||
iosMode:
|
||||
stats?.iosVoiceProcessingMode ??
|
||||
last?.iosMode ??
|
||||
BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
processingBackend:
|
||||
stats?.processingBackend ??
|
||||
last?.processingBackend ??
|
||||
BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend:
|
||||
stats?.vadBackend ?? last?.vadBackend ?? BridgeVadBackend.sileroOnnx,
|
||||
aec: last?.aec ?? BridgeEffectOwner.platform,
|
||||
ns: last?.ns ?? BridgeEffectOwner.platform,
|
||||
agc: last?.agc ?? BridgeEffectOwner.platform,
|
||||
hpfEnabled: last?.hpfEnabled ?? true,
|
||||
limiterEnabled: last?.limiterEnabled ?? true,
|
||||
vadHangoverMs: last?.vadHangoverMs ?? 500,
|
||||
vadPreRollMs: last?.vadPreRollMs ?? 160,
|
||||
vadMinTxMs: last?.vadMinTxMs ?? 200,
|
||||
debugWavDumpEnabled: last?.debugWavDumpEnabled ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Last config applied via [setAudioProcessingConfig]. Used by
|
||||
/// [getAudioProcessingConfig] to preserve timing/debug values across calls.
|
||||
BridgeAudioProcessingConfig? _lastAppliedAudioConfig;
|
||||
|
||||
/// Configure the VAD model path.
|
||||
Future<void> setVadModelPath({required String path}) =>
|
||||
RustLib.instance.api.crateApiSetVadModelPath(path: path);
|
||||
|
||||
/// Enable or disable audio debug WAV dumping.
|
||||
Future<void> enableAudioDebugWavDump({required bool enabled}) =>
|
||||
RustLib.instance.api.crateApiEnableAudioDebugWavDump(enabled: enabled);
|
||||
|
||||
/// Select the iOS voice-processing mode.
|
||||
Future<void> setIosVoiceProcessingMode({
|
||||
required BridgeIosVoiceProcessingMode mode,
|
||||
}) => RustLib.instance.api.crateApiSetIosVoiceProcessingMode(mode: mode);
|
||||
|
||||
/// Bridge processing backend.
|
||||
enum BridgeAudioBackend {
|
||||
/// Platform voice processing.
|
||||
platformVoiceProcessing,
|
||||
|
||||
/// Sonora backend.
|
||||
sonora,
|
||||
|
||||
/// WebRTC APM backend.
|
||||
webrtcApm,
|
||||
|
||||
/// No-op backend.
|
||||
noop,
|
||||
}
|
||||
|
||||
/// P1 audio-processing configuration DTO.
|
||||
class BridgeAudioProcessingConfig {
|
||||
/// Route class.
|
||||
final BridgeAudioRoute route;
|
||||
|
||||
/// iOS voice-processing mode.
|
||||
final BridgeIosVoiceProcessingMode iosMode;
|
||||
|
||||
/// Processing backend.
|
||||
final BridgeAudioBackend processingBackend;
|
||||
|
||||
/// VAD backend.
|
||||
final BridgeVadBackend vadBackend;
|
||||
|
||||
/// AEC owner.
|
||||
final BridgeEffectOwner aec;
|
||||
|
||||
/// Noise suppression owner.
|
||||
final BridgeEffectOwner ns;
|
||||
|
||||
/// AGC owner.
|
||||
final BridgeEffectOwner agc;
|
||||
|
||||
/// High-pass filter enabled.
|
||||
final bool hpfEnabled;
|
||||
|
||||
/// Limiter enabled.
|
||||
final bool limiterEnabled;
|
||||
|
||||
/// VAD hangover in ms.
|
||||
final int vadHangoverMs;
|
||||
|
||||
/// VAD pre-roll in ms.
|
||||
final int vadPreRollMs;
|
||||
|
||||
/// Minimum transmit duration in ms.
|
||||
final int vadMinTxMs;
|
||||
|
||||
/// Debug WAV dump enabled.
|
||||
final bool debugWavDumpEnabled;
|
||||
|
||||
const BridgeAudioProcessingConfig({
|
||||
required this.route,
|
||||
required this.iosMode,
|
||||
required this.processingBackend,
|
||||
required this.vadBackend,
|
||||
required this.aec,
|
||||
required this.ns,
|
||||
required this.agc,
|
||||
required this.hpfEnabled,
|
||||
required this.limiterEnabled,
|
||||
required this.vadHangoverMs,
|
||||
required this.vadPreRollMs,
|
||||
required this.vadMinTxMs,
|
||||
required this.debugWavDumpEnabled,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
route.hashCode ^
|
||||
iosMode.hashCode ^
|
||||
processingBackend.hashCode ^
|
||||
vadBackend.hashCode ^
|
||||
aec.hashCode ^
|
||||
ns.hashCode ^
|
||||
agc.hashCode ^
|
||||
hpfEnabled.hashCode ^
|
||||
limiterEnabled.hashCode ^
|
||||
vadHangoverMs.hashCode ^
|
||||
vadPreRollMs.hashCode ^
|
||||
vadMinTxMs.hashCode ^
|
||||
debugWavDumpEnabled.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BridgeAudioProcessingConfig &&
|
||||
runtimeType == other.runtimeType &&
|
||||
route == other.route &&
|
||||
iosMode == other.iosMode &&
|
||||
processingBackend == other.processingBackend &&
|
||||
vadBackend == other.vadBackend &&
|
||||
aec == other.aec &&
|
||||
ns == other.ns &&
|
||||
agc == other.agc &&
|
||||
hpfEnabled == other.hpfEnabled &&
|
||||
limiterEnabled == other.limiterEnabled &&
|
||||
vadHangoverMs == other.vadHangoverMs &&
|
||||
vadPreRollMs == other.vadPreRollMs &&
|
||||
vadMinTxMs == other.vadMinTxMs &&
|
||||
debugWavDumpEnabled == other.debugWavDumpEnabled;
|
||||
}
|
||||
|
||||
/// P1 audio-processing stats DTO.
|
||||
class BridgeAudioProcessingStats {
|
||||
/// Input dBFS.
|
||||
final double inputDbfs;
|
||||
|
||||
/// Render dBFS.
|
||||
final double renderDbfs;
|
||||
|
||||
/// Processed capture dBFS.
|
||||
final double processedDbfs;
|
||||
|
||||
/// Latest VAD probability.
|
||||
final double vadProbability;
|
||||
|
||||
/// VAD active.
|
||||
final bool vadActive;
|
||||
|
||||
/// Currently transmitting.
|
||||
final bool transmitting;
|
||||
|
||||
/// VAD backend.
|
||||
final BridgeVadBackend vadBackend;
|
||||
|
||||
/// Fallback VAD active.
|
||||
final bool vadFallbackActive;
|
||||
|
||||
/// Processing backend.
|
||||
final BridgeAudioBackend processingBackend;
|
||||
|
||||
/// iOS voice-processing mode.
|
||||
final BridgeIosVoiceProcessingMode iosVoiceProcessingMode;
|
||||
|
||||
/// Audio route.
|
||||
final BridgeAudioRoute audioRoute;
|
||||
|
||||
/// Actual sample rate.
|
||||
final int actualSampleRateHz;
|
||||
|
||||
/// Actual IO buffer frames.
|
||||
final int actualIoBufferFrames;
|
||||
|
||||
/// Input overruns.
|
||||
final BigInt inputOverruns;
|
||||
|
||||
/// Output underruns.
|
||||
final BigInt outputUnderruns;
|
||||
|
||||
/// Callback xruns.
|
||||
final BigInt callbackXruns;
|
||||
|
||||
/// Clipped samples.
|
||||
final BigInt clippedSamples;
|
||||
|
||||
/// Sonora enabled.
|
||||
final bool sonoraEnabled;
|
||||
|
||||
/// Platform voice processing enabled.
|
||||
final bool platformVoiceProcessingEnabled;
|
||||
|
||||
const BridgeAudioProcessingStats({
|
||||
required this.inputDbfs,
|
||||
required this.renderDbfs,
|
||||
required this.processedDbfs,
|
||||
required this.vadProbability,
|
||||
required this.vadActive,
|
||||
required this.transmitting,
|
||||
required this.vadBackend,
|
||||
required this.vadFallbackActive,
|
||||
required this.processingBackend,
|
||||
required this.iosVoiceProcessingMode,
|
||||
required this.audioRoute,
|
||||
required this.actualSampleRateHz,
|
||||
required this.actualIoBufferFrames,
|
||||
required this.inputOverruns,
|
||||
required this.outputUnderruns,
|
||||
required this.callbackXruns,
|
||||
required this.clippedSamples,
|
||||
required this.sonoraEnabled,
|
||||
required this.platformVoiceProcessingEnabled,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
inputDbfs.hashCode ^
|
||||
renderDbfs.hashCode ^
|
||||
processedDbfs.hashCode ^
|
||||
vadProbability.hashCode ^
|
||||
vadActive.hashCode ^
|
||||
transmitting.hashCode ^
|
||||
vadBackend.hashCode ^
|
||||
vadFallbackActive.hashCode ^
|
||||
processingBackend.hashCode ^
|
||||
iosVoiceProcessingMode.hashCode ^
|
||||
audioRoute.hashCode ^
|
||||
actualSampleRateHz.hashCode ^
|
||||
actualIoBufferFrames.hashCode ^
|
||||
inputOverruns.hashCode ^
|
||||
outputUnderruns.hashCode ^
|
||||
callbackXruns.hashCode ^
|
||||
clippedSamples.hashCode ^
|
||||
sonoraEnabled.hashCode ^
|
||||
platformVoiceProcessingEnabled.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BridgeAudioProcessingStats &&
|
||||
runtimeType == other.runtimeType &&
|
||||
inputDbfs == other.inputDbfs &&
|
||||
renderDbfs == other.renderDbfs &&
|
||||
processedDbfs == other.processedDbfs &&
|
||||
vadProbability == other.vadProbability &&
|
||||
vadActive == other.vadActive &&
|
||||
transmitting == other.transmitting &&
|
||||
vadBackend == other.vadBackend &&
|
||||
vadFallbackActive == other.vadFallbackActive &&
|
||||
processingBackend == other.processingBackend &&
|
||||
iosVoiceProcessingMode == other.iosVoiceProcessingMode &&
|
||||
audioRoute == other.audioRoute &&
|
||||
actualSampleRateHz == other.actualSampleRateHz &&
|
||||
actualIoBufferFrames == other.actualIoBufferFrames &&
|
||||
inputOverruns == other.inputOverruns &&
|
||||
outputUnderruns == other.outputUnderruns &&
|
||||
callbackXruns == other.callbackXruns &&
|
||||
clippedSamples == other.clippedSamples &&
|
||||
sonoraEnabled == other.sonoraEnabled &&
|
||||
platformVoiceProcessingEnabled ==
|
||||
other.platformVoiceProcessingEnabled;
|
||||
}
|
||||
|
||||
/// Bridge route class for P1 audio-processing policy.
|
||||
enum BridgeAudioRoute {
|
||||
/// Built-in speakerphone.
|
||||
speaker,
|
||||
|
||||
/// Built-in receiver/earpiece.
|
||||
earpiece,
|
||||
|
||||
/// Wired or USB headset.
|
||||
wiredHeadset,
|
||||
|
||||
/// Bluetooth HFP duplex route.
|
||||
bluetoothHfp,
|
||||
|
||||
/// Bluetooth A2DP output-only route.
|
||||
bluetoothA2Dp,
|
||||
|
||||
/// Unknown route.
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Statistics from the audio engine.
|
||||
class BridgeAudioStats {
|
||||
/// Number of Opus frames sent since audio started.
|
||||
@@ -402,6 +731,24 @@ class BridgeClient {
|
||||
isServerQuery == other.isServerQuery;
|
||||
}
|
||||
|
||||
/// Bridge effect owner for AEC/NS/AGC.
|
||||
enum BridgeEffectOwner {
|
||||
/// Platform-owned effect.
|
||||
platform,
|
||||
|
||||
/// Sonora-owned effect.
|
||||
sonora,
|
||||
|
||||
/// WebRTC APM-owned effect.
|
||||
webrtcApm,
|
||||
|
||||
/// Conservative route-managed setting.
|
||||
conservative,
|
||||
|
||||
/// Disabled.
|
||||
off,
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class BridgeEvent with _$BridgeEvent {
|
||||
const BridgeEvent._();
|
||||
@@ -536,6 +883,15 @@ sealed class BridgeEvent with _$BridgeEvent {
|
||||
}) = BridgeEvent_PermissionState;
|
||||
}
|
||||
|
||||
/// Bridge iOS voice-processing mode.
|
||||
enum BridgeIosVoiceProcessingMode {
|
||||
/// Shipping VPIO path.
|
||||
platformVoiceProcessing,
|
||||
|
||||
/// Experimental Sonora path.
|
||||
sonoraExperimental,
|
||||
}
|
||||
|
||||
/// Coarse OS-reported network state. Mirrors
|
||||
/// [`chanora_core::NetworkState`] across the bridge.
|
||||
enum BridgeNetworkState {
|
||||
@@ -635,25 +991,69 @@ enum BridgeTransmitMode {
|
||||
voiceActivity,
|
||||
}
|
||||
|
||||
/// Bridge VAD backend.
|
||||
enum BridgeVadBackend {
|
||||
/// Silero ONNX VAD.
|
||||
sileroOnnx,
|
||||
|
||||
/// TEN VAD.
|
||||
tenVad,
|
||||
|
||||
/// WebRTC fallback VAD.
|
||||
webrtcVad,
|
||||
|
||||
/// Debug energy VAD.
|
||||
energyDebug,
|
||||
|
||||
/// VAD disabled.
|
||||
disabled,
|
||||
}
|
||||
|
||||
/// Bridge mirror of stable join error/status codes.
|
||||
enum BridgeVoiceJoinErrorCode {
|
||||
/// Duplicate same-target join intent was coalesced.
|
||||
duplicateSameTargetCoalesced,
|
||||
|
||||
/// A different target was requested while one is already pending.
|
||||
joinAlreadyPendingDifferentTarget,
|
||||
|
||||
/// Join denied by server policy/permission.
|
||||
joinDenied,
|
||||
|
||||
/// Join failed due to protocol-level error.
|
||||
joinProtocolFailure,
|
||||
|
||||
/// Join failed due to transport/network error.
|
||||
joinNetworkFailure,
|
||||
|
||||
/// Join timed out awaiting confirmation.
|
||||
joinTimeout,
|
||||
|
||||
/// Pending join was superseded by user leave.
|
||||
joinSupersededByLeave,
|
||||
|
||||
/// Stale join outcome was ignored.
|
||||
joinStaleOutcomeIgnored,
|
||||
|
||||
/// Authoritative membership reconciled to different channel.
|
||||
joinReconciledDifferentChannel,
|
||||
|
||||
/// Join command was rejected before send acceptance.
|
||||
joinCommandRejectedBeforeSend,
|
||||
|
||||
/// Join intent rejected while reducer synchronizing.
|
||||
joinCannotStartWhileSynchronizing,
|
||||
}
|
||||
|
||||
/// Bridge mirror of core join projection sync state.
|
||||
enum BridgeVoiceJoinSyncState {
|
||||
/// Reducer is ready to accept channel actions.
|
||||
ready,
|
||||
|
||||
/// Reducer is waiting on initial snapshot reconciliation.
|
||||
synchronizingInitialSnapshot,
|
||||
|
||||
/// Reducer is waiting on reconnect snapshot reconciliation.
|
||||
synchronizingReconnect,
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||
String get codegenVersion => '2.12.0';
|
||||
|
||||
@override
|
||||
int get rustContentHash => 1322894465;
|
||||
int get rustContentHash => -1835973251;
|
||||
|
||||
static const kDefaultExternalLibraryLoaderConfig =
|
||||
ExternalLibraryLoaderConfig(
|
||||
@@ -81,6 +81,8 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||
abstract class RustLibApi extends BaseApi {
|
||||
Future<PlatformInt64> crateApiAddBookmark({required BridgeBookmark b});
|
||||
|
||||
Future<BridgeAudioProcessingStats> crateApiAudioProcessingStats();
|
||||
|
||||
Future<BridgeAudioStats> crateApiAudioStats();
|
||||
|
||||
Future<void> crateApiBridgeInit();
|
||||
@@ -95,6 +97,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<void> crateApiDisconnect();
|
||||
|
||||
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled});
|
||||
|
||||
Stream<BridgeEvent> crateApiEventsStream();
|
||||
|
||||
String crateApiExportDiagnostics();
|
||||
@@ -109,7 +113,9 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
void crateApiHandleInterruptionEnded({required bool shouldResume});
|
||||
|
||||
void crateApiHandleRouteChange();
|
||||
void crateApiHandleMediaServicesReset();
|
||||
|
||||
void crateApiHandleRouteChange({required BridgeAudioRoute route});
|
||||
|
||||
Future<void> crateApiInitStorage({required String dir});
|
||||
|
||||
@@ -126,10 +132,18 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<(String, String, String)> crateApiPttDescriptor();
|
||||
|
||||
Future<void> crateApiSetAudioProcessingConfig({
|
||||
required BridgeAudioProcessingConfig config,
|
||||
});
|
||||
|
||||
Future<void> crateApiSetHardMute({required bool muted});
|
||||
|
||||
Future<void> crateApiSetInputMuted({required bool muted});
|
||||
|
||||
Future<void> crateApiSetIosVoiceProcessingMode({
|
||||
required BridgeIosVoiceProcessingMode mode,
|
||||
});
|
||||
|
||||
void crateApiSetNetworkState({required BridgeNetworkState state});
|
||||
|
||||
Future<void> crateApiSetOutputGain({required double gain});
|
||||
@@ -147,6 +161,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode});
|
||||
|
||||
Future<void> crateApiSetVadModelPath({required String path});
|
||||
|
||||
Future<BridgeSnapshot> crateApiSnapshot();
|
||||
|
||||
Future<void> crateApiUpdateBookmark({required BridgeBookmark b});
|
||||
@@ -196,7 +212,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
const TaskConstMeta(debugName: "add_bookmark", argNames: ["b"]);
|
||||
|
||||
@override
|
||||
Future<BridgeAudioStats> crateApiAudioStats() {
|
||||
Future<BridgeAudioProcessingStats> crateApiAudioProcessingStats() {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
@@ -208,6 +224,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_bridge_audio_processing_stats,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiAudioProcessingStatsConstMeta,
|
||||
argValues: [],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiAudioProcessingStatsConstMeta =>
|
||||
const TaskConstMeta(debugName: "audio_processing_stats", argNames: []);
|
||||
|
||||
@override
|
||||
Future<BridgeAudioStats> crateApiAudioStats() {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 3,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_bridge_audio_stats,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
@@ -231,7 +274,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 3,
|
||||
funcId: 4,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -265,7 +308,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 4,
|
||||
funcId: 5,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -295,7 +338,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 5,
|
||||
funcId: 6,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -322,7 +365,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 6,
|
||||
funcId: 7,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -340,6 +383,37 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
TaskConstMeta get kCrateApiDisconnectConstMeta =>
|
||||
const TaskConstMeta(debugName: "disconnect", argNames: []);
|
||||
|
||||
@override
|
||||
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bool(enabled, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 8,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiEnableAudioDebugWavDumpConstMeta,
|
||||
argValues: [enabled],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiEnableAudioDebugWavDumpConstMeta =>
|
||||
const TaskConstMeta(
|
||||
debugName: "enable_audio_debug_wav_dump",
|
||||
argNames: ["enabled"],
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<BridgeEvent> crateApiEventsStream() {
|
||||
final sink = RustStreamSink<BridgeEvent>();
|
||||
@@ -352,7 +426,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 7,
|
||||
funcId: 9,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -378,7 +452,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 8)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 10)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_String,
|
||||
@@ -403,7 +477,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 9,
|
||||
funcId: 11,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -430,7 +504,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 10,
|
||||
funcId: 12,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -457,7 +531,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 11,
|
||||
funcId: 13,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -481,7 +555,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -504,7 +578,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bool(shouldResume, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -524,26 +598,54 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
);
|
||||
|
||||
@override
|
||||
void crateApiHandleRouteChange() {
|
||||
void crateApiHandleMediaServicesReset() {
|
||||
return handler.executeSync(
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: null,
|
||||
),
|
||||
constMeta: kCrateApiHandleRouteChangeConstMeta,
|
||||
constMeta: kCrateApiHandleMediaServicesResetConstMeta,
|
||||
argValues: [],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiHandleRouteChangeConstMeta =>
|
||||
const TaskConstMeta(debugName: "handle_route_change", argNames: []);
|
||||
TaskConstMeta get kCrateApiHandleMediaServicesResetConstMeta =>
|
||||
const TaskConstMeta(
|
||||
debugName: "handle_media_services_reset",
|
||||
argNames: [],
|
||||
);
|
||||
|
||||
@override
|
||||
void crateApiHandleRouteChange({required BridgeAudioRoute route}) {
|
||||
return handler.executeSync(
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_audio_route(route, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: null,
|
||||
),
|
||||
constMeta: kCrateApiHandleRouteChangeConstMeta,
|
||||
argValues: [route],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiHandleRouteChangeConstMeta => const TaskConstMeta(
|
||||
debugName: "handle_route_change",
|
||||
argNames: ["route"],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> crateApiInitStorage({required String dir}) {
|
||||
@@ -555,7 +657,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 15,
|
||||
funcId: 18,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -582,7 +684,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 16,
|
||||
funcId: 19,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -609,7 +711,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 17,
|
||||
funcId: 20,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -633,7 +735,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_String,
|
||||
@@ -663,7 +765,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 19,
|
||||
funcId: 22,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -692,7 +794,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 20,
|
||||
funcId: 23,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -710,6 +812,42 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
TaskConstMeta get kCrateApiPttDescriptorConstMeta =>
|
||||
const TaskConstMeta(debugName: "ptt_descriptor", argNames: []);
|
||||
|
||||
@override
|
||||
Future<void> crateApiSetAudioProcessingConfig({
|
||||
required BridgeAudioProcessingConfig config,
|
||||
}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_box_autoadd_bridge_audio_processing_config(
|
||||
config,
|
||||
serializer,
|
||||
);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 24,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiSetAudioProcessingConfigConstMeta,
|
||||
argValues: [config],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiSetAudioProcessingConfigConstMeta =>
|
||||
const TaskConstMeta(
|
||||
debugName: "set_audio_processing_config",
|
||||
argNames: ["config"],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> crateApiSetHardMute({required bool muted}) {
|
||||
return handler.executeNormal(
|
||||
@@ -720,7 +858,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 21,
|
||||
funcId: 25,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -748,7 +886,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 22,
|
||||
funcId: 26,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -766,6 +904,39 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
TaskConstMeta get kCrateApiSetInputMutedConstMeta =>
|
||||
const TaskConstMeta(debugName: "set_input_muted", argNames: ["muted"]);
|
||||
|
||||
@override
|
||||
Future<void> crateApiSetIosVoiceProcessingMode({
|
||||
required BridgeIosVoiceProcessingMode mode,
|
||||
}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_ios_voice_processing_mode(mode, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 27,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiSetIosVoiceProcessingModeConstMeta,
|
||||
argValues: [mode],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiSetIosVoiceProcessingModeConstMeta =>
|
||||
const TaskConstMeta(
|
||||
debugName: "set_ios_voice_processing_mode",
|
||||
argNames: ["mode"],
|
||||
);
|
||||
|
||||
@override
|
||||
void crateApiSetNetworkState({required BridgeNetworkState state}) {
|
||||
return handler.executeSync(
|
||||
@@ -773,7 +944,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_network_state(state, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -799,7 +970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 24,
|
||||
funcId: 29,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -827,7 +998,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 25,
|
||||
funcId: 30,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -855,7 +1026,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 26,
|
||||
funcId: 31,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -887,7 +1058,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 27,
|
||||
funcId: 32,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -917,7 +1088,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 28,
|
||||
funcId: 33,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -945,7 +1116,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 29,
|
||||
funcId: 34,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -963,6 +1134,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
TaskConstMeta get kCrateApiSetTransmitModeConstMeta =>
|
||||
const TaskConstMeta(debugName: "set_transmit_mode", argNames: ["mode"]);
|
||||
|
||||
@override
|
||||
Future<void> crateApiSetVadModelPath({required String path}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(path, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 35,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiSetVadModelPathConstMeta,
|
||||
argValues: [path],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiSetVadModelPathConstMeta =>
|
||||
const TaskConstMeta(debugName: "set_vad_model_path", argNames: ["path"]);
|
||||
|
||||
@override
|
||||
Future<BridgeSnapshot> crateApiSnapshot() {
|
||||
return handler.executeNormal(
|
||||
@@ -972,7 +1171,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 30,
|
||||
funcId: 36,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1000,7 +1199,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 31,
|
||||
funcId: 37,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1032,7 +1231,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 32,
|
||||
funcId: 38,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1061,7 +1260,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 33,
|
||||
funcId: 39,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1105,6 +1304,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return raw as bool;
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingConfig
|
||||
dco_decode_box_autoadd_bridge_audio_processing_config(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return dco_decode_bridge_audio_processing_config(raw);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
@@ -1125,6 +1331,76 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return dco_decode_u_64(raw);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return BridgeAudioBackend.values[raw as int];
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
|
||||
dynamic raw,
|
||||
) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<dynamic>;
|
||||
if (arr.length != 13)
|
||||
throw Exception('unexpected arr length: expect 13 but see ${arr.length}');
|
||||
return BridgeAudioProcessingConfig(
|
||||
route: dco_decode_bridge_audio_route(arr[0]),
|
||||
iosMode: dco_decode_bridge_ios_voice_processing_mode(arr[1]),
|
||||
processingBackend: dco_decode_bridge_audio_backend(arr[2]),
|
||||
vadBackend: dco_decode_bridge_vad_backend(arr[3]),
|
||||
aec: dco_decode_bridge_effect_owner(arr[4]),
|
||||
ns: dco_decode_bridge_effect_owner(arr[5]),
|
||||
agc: dco_decode_bridge_effect_owner(arr[6]),
|
||||
hpfEnabled: dco_decode_bool(arr[7]),
|
||||
limiterEnabled: dco_decode_bool(arr[8]),
|
||||
vadHangoverMs: dco_decode_u_32(arr[9]),
|
||||
vadPreRollMs: dco_decode_u_32(arr[10]),
|
||||
vadMinTxMs: dco_decode_u_32(arr[11]),
|
||||
debugWavDumpEnabled: dco_decode_bool(arr[12]),
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingStats dco_decode_bridge_audio_processing_stats(
|
||||
dynamic raw,
|
||||
) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<dynamic>;
|
||||
if (arr.length != 19)
|
||||
throw Exception('unexpected arr length: expect 19 but see ${arr.length}');
|
||||
return BridgeAudioProcessingStats(
|
||||
inputDbfs: dco_decode_f_32(arr[0]),
|
||||
renderDbfs: dco_decode_f_32(arr[1]),
|
||||
processedDbfs: dco_decode_f_32(arr[2]),
|
||||
vadProbability: dco_decode_f_32(arr[3]),
|
||||
vadActive: dco_decode_bool(arr[4]),
|
||||
transmitting: dco_decode_bool(arr[5]),
|
||||
vadBackend: dco_decode_bridge_vad_backend(arr[6]),
|
||||
vadFallbackActive: dco_decode_bool(arr[7]),
|
||||
processingBackend: dco_decode_bridge_audio_backend(arr[8]),
|
||||
iosVoiceProcessingMode: dco_decode_bridge_ios_voice_processing_mode(
|
||||
arr[9],
|
||||
),
|
||||
audioRoute: dco_decode_bridge_audio_route(arr[10]),
|
||||
actualSampleRateHz: dco_decode_u_32(arr[11]),
|
||||
actualIoBufferFrames: dco_decode_u_32(arr[12]),
|
||||
inputOverruns: dco_decode_u_64(arr[13]),
|
||||
outputUnderruns: dco_decode_u_64(arr[14]),
|
||||
callbackXruns: dco_decode_u_64(arr[15]),
|
||||
clippedSamples: dco_decode_u_64(arr[16]),
|
||||
sonoraEnabled: dco_decode_bool(arr[17]),
|
||||
platformVoiceProcessingEnabled: dco_decode_bool(arr[18]),
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioRoute dco_decode_bridge_audio_route(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return BridgeAudioRoute.values[raw as int];
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
@@ -1185,6 +1461,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return BridgeEffectOwner.values[raw as int];
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeError dco_decode_bridge_error(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
@@ -1273,6 +1555,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeIosVoiceProcessingMode dco_decode_bridge_ios_voice_processing_mode(
|
||||
dynamic raw,
|
||||
) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return BridgeIosVoiceProcessingMode.values[raw as int];
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
@@ -1308,6 +1598,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return BridgeTransmitMode.values[raw as int];
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeVadBackend dco_decode_bridge_vad_backend(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return BridgeVadBackend.values[raw as int];
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(
|
||||
dynamic raw,
|
||||
@@ -1463,6 +1759,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return deserializer.buffer.getUint8() != 0;
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingConfig
|
||||
sse_decode_box_autoadd_bridge_audio_processing_config(
|
||||
SseDeserializer deserializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
return (sse_decode_bridge_audio_processing_config(deserializer));
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeBookmark sse_decode_box_autoadd_bridge_bookmark(
|
||||
SseDeserializer deserializer,
|
||||
@@ -1485,6 +1790,105 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return (sse_decode_u_64(deserializer));
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioBackend sse_decode_bridge_audio_backend(
|
||||
SseDeserializer deserializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var inner = sse_decode_i_32(deserializer);
|
||||
return BridgeAudioBackend.values[inner];
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
|
||||
SseDeserializer deserializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var var_route = sse_decode_bridge_audio_route(deserializer);
|
||||
var var_iosMode = sse_decode_bridge_ios_voice_processing_mode(deserializer);
|
||||
var var_processingBackend = sse_decode_bridge_audio_backend(deserializer);
|
||||
var var_vadBackend = sse_decode_bridge_vad_backend(deserializer);
|
||||
var var_aec = sse_decode_bridge_effect_owner(deserializer);
|
||||
var var_ns = sse_decode_bridge_effect_owner(deserializer);
|
||||
var var_agc = sse_decode_bridge_effect_owner(deserializer);
|
||||
var var_hpfEnabled = sse_decode_bool(deserializer);
|
||||
var var_limiterEnabled = sse_decode_bool(deserializer);
|
||||
var var_vadHangoverMs = sse_decode_u_32(deserializer);
|
||||
var var_vadPreRollMs = sse_decode_u_32(deserializer);
|
||||
var var_vadMinTxMs = sse_decode_u_32(deserializer);
|
||||
var var_debugWavDumpEnabled = sse_decode_bool(deserializer);
|
||||
return BridgeAudioProcessingConfig(
|
||||
route: var_route,
|
||||
iosMode: var_iosMode,
|
||||
processingBackend: var_processingBackend,
|
||||
vadBackend: var_vadBackend,
|
||||
aec: var_aec,
|
||||
ns: var_ns,
|
||||
agc: var_agc,
|
||||
hpfEnabled: var_hpfEnabled,
|
||||
limiterEnabled: var_limiterEnabled,
|
||||
vadHangoverMs: var_vadHangoverMs,
|
||||
vadPreRollMs: var_vadPreRollMs,
|
||||
vadMinTxMs: var_vadMinTxMs,
|
||||
debugWavDumpEnabled: var_debugWavDumpEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingStats sse_decode_bridge_audio_processing_stats(
|
||||
SseDeserializer deserializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var var_inputDbfs = sse_decode_f_32(deserializer);
|
||||
var var_renderDbfs = sse_decode_f_32(deserializer);
|
||||
var var_processedDbfs = sse_decode_f_32(deserializer);
|
||||
var var_vadProbability = sse_decode_f_32(deserializer);
|
||||
var var_vadActive = sse_decode_bool(deserializer);
|
||||
var var_transmitting = sse_decode_bool(deserializer);
|
||||
var var_vadBackend = sse_decode_bridge_vad_backend(deserializer);
|
||||
var var_vadFallbackActive = sse_decode_bool(deserializer);
|
||||
var var_processingBackend = sse_decode_bridge_audio_backend(deserializer);
|
||||
var var_iosVoiceProcessingMode =
|
||||
sse_decode_bridge_ios_voice_processing_mode(deserializer);
|
||||
var var_audioRoute = sse_decode_bridge_audio_route(deserializer);
|
||||
var var_actualSampleRateHz = sse_decode_u_32(deserializer);
|
||||
var var_actualIoBufferFrames = sse_decode_u_32(deserializer);
|
||||
var var_inputOverruns = sse_decode_u_64(deserializer);
|
||||
var var_outputUnderruns = sse_decode_u_64(deserializer);
|
||||
var var_callbackXruns = sse_decode_u_64(deserializer);
|
||||
var var_clippedSamples = sse_decode_u_64(deserializer);
|
||||
var var_sonoraEnabled = sse_decode_bool(deserializer);
|
||||
var var_platformVoiceProcessingEnabled = sse_decode_bool(deserializer);
|
||||
return BridgeAudioProcessingStats(
|
||||
inputDbfs: var_inputDbfs,
|
||||
renderDbfs: var_renderDbfs,
|
||||
processedDbfs: var_processedDbfs,
|
||||
vadProbability: var_vadProbability,
|
||||
vadActive: var_vadActive,
|
||||
transmitting: var_transmitting,
|
||||
vadBackend: var_vadBackend,
|
||||
vadFallbackActive: var_vadFallbackActive,
|
||||
processingBackend: var_processingBackend,
|
||||
iosVoiceProcessingMode: var_iosVoiceProcessingMode,
|
||||
audioRoute: var_audioRoute,
|
||||
actualSampleRateHz: var_actualSampleRateHz,
|
||||
actualIoBufferFrames: var_actualIoBufferFrames,
|
||||
inputOverruns: var_inputOverruns,
|
||||
outputUnderruns: var_outputUnderruns,
|
||||
callbackXruns: var_callbackXruns,
|
||||
clippedSamples: var_clippedSamples,
|
||||
sonoraEnabled: var_sonoraEnabled,
|
||||
platformVoiceProcessingEnabled: var_platformVoiceProcessingEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioRoute sse_decode_bridge_audio_route(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var inner = sse_decode_i_32(deserializer);
|
||||
return BridgeAudioRoute.values[inner];
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -1553,6 +1957,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeEffectOwner sse_decode_bridge_effect_owner(
|
||||
SseDeserializer deserializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var inner = sse_decode_i_32(deserializer);
|
||||
return BridgeEffectOwner.values[inner];
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeError sse_decode_bridge_error(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -1678,6 +2091,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeIosVoiceProcessingMode sse_decode_bridge_ios_voice_processing_mode(
|
||||
SseDeserializer deserializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var inner = sse_decode_i_32(deserializer);
|
||||
return BridgeIosVoiceProcessingMode.values[inner];
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeNetworkState sse_decode_bridge_network_state(
|
||||
SseDeserializer deserializer,
|
||||
@@ -1726,6 +2148,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return BridgeTransmitMode.values[inner];
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeVadBackend sse_decode_bridge_vad_backend(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var inner = sse_decode_i_32(deserializer);
|
||||
return BridgeVadBackend.values[inner];
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code(
|
||||
SseDeserializer deserializer,
|
||||
@@ -1929,6 +2358,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
serializer.buffer.putUint8(self ? 1 : 0);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_bridge_audio_processing_config(
|
||||
BridgeAudioProcessingConfig self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_bridge_audio_processing_config(self, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_bridge_bookmark(
|
||||
BridgeBookmark self,
|
||||
@@ -1953,6 +2391,75 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_u_64(self, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_backend(
|
||||
BridgeAudioBackend self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_i_32(self.index, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_processing_config(
|
||||
BridgeAudioProcessingConfig self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_bridge_audio_route(self.route, serializer);
|
||||
sse_encode_bridge_ios_voice_processing_mode(self.iosMode, serializer);
|
||||
sse_encode_bridge_audio_backend(self.processingBackend, serializer);
|
||||
sse_encode_bridge_vad_backend(self.vadBackend, serializer);
|
||||
sse_encode_bridge_effect_owner(self.aec, serializer);
|
||||
sse_encode_bridge_effect_owner(self.ns, serializer);
|
||||
sse_encode_bridge_effect_owner(self.agc, serializer);
|
||||
sse_encode_bool(self.hpfEnabled, serializer);
|
||||
sse_encode_bool(self.limiterEnabled, serializer);
|
||||
sse_encode_u_32(self.vadHangoverMs, serializer);
|
||||
sse_encode_u_32(self.vadPreRollMs, serializer);
|
||||
sse_encode_u_32(self.vadMinTxMs, serializer);
|
||||
sse_encode_bool(self.debugWavDumpEnabled, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_processing_stats(
|
||||
BridgeAudioProcessingStats self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_f_32(self.inputDbfs, serializer);
|
||||
sse_encode_f_32(self.renderDbfs, serializer);
|
||||
sse_encode_f_32(self.processedDbfs, serializer);
|
||||
sse_encode_f_32(self.vadProbability, serializer);
|
||||
sse_encode_bool(self.vadActive, serializer);
|
||||
sse_encode_bool(self.transmitting, serializer);
|
||||
sse_encode_bridge_vad_backend(self.vadBackend, serializer);
|
||||
sse_encode_bool(self.vadFallbackActive, serializer);
|
||||
sse_encode_bridge_audio_backend(self.processingBackend, serializer);
|
||||
sse_encode_bridge_ios_voice_processing_mode(
|
||||
self.iosVoiceProcessingMode,
|
||||
serializer,
|
||||
);
|
||||
sse_encode_bridge_audio_route(self.audioRoute, serializer);
|
||||
sse_encode_u_32(self.actualSampleRateHz, serializer);
|
||||
sse_encode_u_32(self.actualIoBufferFrames, serializer);
|
||||
sse_encode_u_64(self.inputOverruns, serializer);
|
||||
sse_encode_u_64(self.outputUnderruns, serializer);
|
||||
sse_encode_u_64(self.callbackXruns, serializer);
|
||||
sse_encode_u_64(self.clippedSamples, serializer);
|
||||
sse_encode_bool(self.sonoraEnabled, serializer);
|
||||
sse_encode_bool(self.platformVoiceProcessingEnabled, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_route(
|
||||
BridgeAudioRoute self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_i_32(self.index, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_stats(
|
||||
BridgeAudioStats self,
|
||||
@@ -1984,6 +2491,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_u_64(self.parent, serializer);
|
||||
sse_encode_String(self.name, serializer);
|
||||
sse_encode_i_64(self.order, serializer);
|
||||
sse_encode_bool(self.hasPassword, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
@@ -1992,9 +2500,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_u_64(self.id, serializer);
|
||||
sse_encode_u_64(self.channel, serializer);
|
||||
sse_encode_String(self.name, serializer);
|
||||
sse_encode_bool(self.inputMuted, serializer);
|
||||
sse_encode_bool(self.outputMuted, serializer);
|
||||
sse_encode_bool(self.isSpeaking, serializer);
|
||||
sse_encode_bool(self.isServerQuery, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_effect_owner(
|
||||
BridgeEffectOwner self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_i_32(self.index, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -2106,6 +2626,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_ios_voice_processing_mode(
|
||||
BridgeIosVoiceProcessingMode self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_i_32(self.index, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_network_state(
|
||||
BridgeNetworkState self,
|
||||
@@ -2148,6 +2677,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_i_32(self.index, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_vad_backend(
|
||||
BridgeVadBackend self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_i_32(self.index, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_voice_join_error_code(
|
||||
BridgeVoiceJoinErrorCode self,
|
||||
|
||||
@@ -33,6 +33,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
bool dco_decode_bool(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingConfig
|
||||
dco_decode_box_autoadd_bridge_audio_processing_config(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw);
|
||||
|
||||
@@ -44,6 +48,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingStats dco_decode_bridge_audio_processing_stats(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeAudioRoute dco_decode_bridge_audio_route(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw);
|
||||
|
||||
@@ -56,12 +76,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeClient dco_decode_bridge_client(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeError dco_decode_bridge_error(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeEvent dco_decode_bridge_event(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeIosVoiceProcessingMode dco_decode_bridge_ios_voice_processing_mode(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
||||
|
||||
@@ -74,6 +102,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeVadBackend dco_decode_bridge_vad_backend(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(dynamic raw);
|
||||
|
||||
@@ -143,6 +174,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
bool sse_decode_bool(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingConfig
|
||||
sse_decode_box_autoadd_bridge_audio_processing_config(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeBookmark sse_decode_box_autoadd_bridge_bookmark(
|
||||
SseDeserializer deserializer,
|
||||
@@ -156,6 +193,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeAudioBackend sse_decode_bridge_audio_backend(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingStats sse_decode_bridge_audio_processing_stats(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeAudioRoute sse_decode_bridge_audio_route(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer);
|
||||
|
||||
@@ -168,12 +223,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeEffectOwner sse_decode_bridge_effect_owner(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeError sse_decode_bridge_error(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeIosVoiceProcessingMode sse_decode_bridge_ios_voice_processing_mode(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeNetworkState sse_decode_bridge_network_state(
|
||||
SseDeserializer deserializer,
|
||||
@@ -192,6 +257,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeVadBackend sse_decode_bridge_vad_backend(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code(
|
||||
SseDeserializer deserializer,
|
||||
@@ -283,6 +351,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_bridge_audio_processing_config(
|
||||
BridgeAudioProcessingConfig self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_bridge_bookmark(
|
||||
BridgeBookmark self,
|
||||
@@ -298,6 +372,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_backend(
|
||||
BridgeAudioBackend self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_processing_config(
|
||||
BridgeAudioProcessingConfig self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_processing_stats(
|
||||
BridgeAudioProcessingStats self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_route(
|
||||
BridgeAudioRoute self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_stats(
|
||||
BridgeAudioStats self,
|
||||
@@ -316,12 +414,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_effect_owner(
|
||||
BridgeEffectOwner self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_ios_voice_processing_mode(
|
||||
BridgeIosVoiceProcessingMode self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_network_state(
|
||||
BridgeNetworkState self,
|
||||
@@ -346,6 +456,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_vad_backend(
|
||||
BridgeVadBackend self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_voice_join_error_code(
|
||||
BridgeVoiceJoinErrorCode self,
|
||||
|
||||
@@ -35,6 +35,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
bool dco_decode_bool(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingConfig
|
||||
dco_decode_box_autoadd_bridge_audio_processing_config(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw);
|
||||
|
||||
@@ -46,6 +50,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingStats dco_decode_bridge_audio_processing_stats(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeAudioRoute dco_decode_bridge_audio_route(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw);
|
||||
|
||||
@@ -58,12 +78,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeClient dco_decode_bridge_client(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeError dco_decode_bridge_error(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeEvent dco_decode_bridge_event(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeIosVoiceProcessingMode dco_decode_bridge_ios_voice_processing_mode(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
||||
|
||||
@@ -76,6 +104,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeVadBackend dco_decode_bridge_vad_backend(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(dynamic raw);
|
||||
|
||||
@@ -145,6 +176,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
bool sse_decode_bool(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingConfig
|
||||
sse_decode_box_autoadd_bridge_audio_processing_config(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeBookmark sse_decode_box_autoadd_bridge_bookmark(
|
||||
SseDeserializer deserializer,
|
||||
@@ -158,6 +195,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeAudioBackend sse_decode_bridge_audio_backend(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeAudioProcessingStats sse_decode_bridge_audio_processing_stats(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeAudioRoute sse_decode_bridge_audio_route(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer);
|
||||
|
||||
@@ -170,12 +225,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeEffectOwner sse_decode_bridge_effect_owner(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeError sse_decode_bridge_error(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeIosVoiceProcessingMode sse_decode_bridge_ios_voice_processing_mode(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeNetworkState sse_decode_bridge_network_state(
|
||||
SseDeserializer deserializer,
|
||||
@@ -194,6 +259,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeVadBackend sse_decode_bridge_vad_backend(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code(
|
||||
SseDeserializer deserializer,
|
||||
@@ -285,6 +353,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_bridge_audio_processing_config(
|
||||
BridgeAudioProcessingConfig self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_bridge_bookmark(
|
||||
BridgeBookmark self,
|
||||
@@ -300,6 +374,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_backend(
|
||||
BridgeAudioBackend self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_processing_config(
|
||||
BridgeAudioProcessingConfig self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_processing_stats(
|
||||
BridgeAudioProcessingStats self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_route(
|
||||
BridgeAudioRoute self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_audio_stats(
|
||||
BridgeAudioStats self,
|
||||
@@ -318,12 +416,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_effect_owner(
|
||||
BridgeEffectOwner self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_error(BridgeError self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_ios_voice_processing_mode(
|
||||
BridgeIosVoiceProcessingMode self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_network_state(
|
||||
BridgeNetworkState self,
|
||||
@@ -348,6 +458,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_vad_backend(
|
||||
BridgeVadBackend self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_voice_join_error_code(
|
||||
BridgeVoiceJoinErrorCode self,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// P1 debug stats overlay widget.
|
||||
//
|
||||
// Shows a compact, auto-refreshing panel with the key audio processing
|
||||
// metrics from [BridgeAudioProcessingStats]. Intended for internal
|
||||
// debug builds only — wrap with a kDebugMode guard at the call site.
|
||||
//
|
||||
// Usage:
|
||||
// if (kDebugMode) const AudioDebugStatsPanel(),
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../src/rust/api.dart';
|
||||
|
||||
/// Compact debug panel that polls [audioProcessingStats] every 500 ms
|
||||
/// and renders the key metrics in a monospace overlay.
|
||||
///
|
||||
/// Designed to be placed in a [Stack] over the main UI during
|
||||
/// development. It is transparent to hit-testing so it does not
|
||||
/// interfere with taps.
|
||||
class AudioDebugStatsPanel extends StatefulWidget {
|
||||
const AudioDebugStatsPanel({super.key});
|
||||
|
||||
@override
|
||||
State<AudioDebugStatsPanel> createState() => _AudioDebugStatsPanelState();
|
||||
}
|
||||
|
||||
class _AudioDebugStatsPanelState extends State<AudioDebugStatsPanel> {
|
||||
BridgeAudioProcessingStats? _stats;
|
||||
Timer? _timer;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_poll();
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _poll());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
try {
|
||||
final stats = await audioProcessingStats();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_stats = stats;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _error = e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: _buildPanel(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPanel() {
|
||||
if (_error != null) {
|
||||
return _PanelBox(
|
||||
child: Text(
|
||||
'audio stats error:\n$_error',
|
||||
style: _monoStyle(Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final s = _stats;
|
||||
if (s == null) {
|
||||
return _PanelBox(
|
||||
child: Text('audio stats: loading…', style: _monoStyle(Colors.grey)),
|
||||
);
|
||||
}
|
||||
|
||||
final vadColor = s.vadActive ? Colors.greenAccent : Colors.grey;
|
||||
final txColor = s.transmitting ? Colors.redAccent : Colors.grey;
|
||||
final xruns = s.callbackXruns + s.inputOverruns + s.outputUnderruns;
|
||||
|
||||
return _PanelBox(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_row('route', _routeLabel(s.audioRoute), Colors.white),
|
||||
_row('backend', _backendLabel(s.processingBackend), Colors.white),
|
||||
_row(
|
||||
'vpio',
|
||||
s.platformVoiceProcessingEnabled ? 'on' : 'off',
|
||||
Colors.white,
|
||||
),
|
||||
_row('sonora', s.sonoraEnabled ? 'on' : 'off', Colors.white),
|
||||
const SizedBox(height: 4),
|
||||
_row(
|
||||
'mic in',
|
||||
'${s.inputDbfs.toStringAsFixed(1)} dBFS',
|
||||
Colors.white,
|
||||
),
|
||||
_row(
|
||||
'mic out',
|
||||
'${s.processedDbfs.toStringAsFixed(1)} dBFS',
|
||||
Colors.white,
|
||||
),
|
||||
_row(
|
||||
'render',
|
||||
'${s.renderDbfs.toStringAsFixed(1)} dBFS',
|
||||
Colors.white,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_row(
|
||||
'vad',
|
||||
'${(s.vadProbability * 100).toStringAsFixed(0)}% '
|
||||
'${s.vadActive ? "OPEN" : "closed"}',
|
||||
vadColor,
|
||||
),
|
||||
_row('vad backend', _vadBackendLabel(s.vadBackend), Colors.white),
|
||||
if (s.vadFallbackActive)
|
||||
_row('vad fallback', 'ACTIVE', Colors.orange),
|
||||
const SizedBox(height: 4),
|
||||
_row('tx', s.transmitting ? 'TRANSMITTING' : 'idle', txColor),
|
||||
_row('sr', '${s.actualSampleRateHz} Hz', Colors.white),
|
||||
_row('buf', '${s.actualIoBufferFrames} frames', Colors.white),
|
||||
if (xruns > BigInt.zero)
|
||||
_row('xruns', xruns.toString(), Colors.orange),
|
||||
if (s.clippedSamples > BigInt.zero)
|
||||
_row('clipped', s.clippedSamples.toString(), Colors.orange),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String label, String value, Color valueColor) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('$label: ', style: _monoStyle(Colors.grey.shade400)),
|
||||
Text(value, style: _monoStyle(valueColor)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle _monoStyle(Color color) => TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
color: color,
|
||||
height: 1.4,
|
||||
);
|
||||
|
||||
String _routeLabel(BridgeAudioRoute route) => switch (route) {
|
||||
BridgeAudioRoute.speaker => 'speaker',
|
||||
BridgeAudioRoute.earpiece => 'earpiece',
|
||||
BridgeAudioRoute.wiredHeadset => 'wired',
|
||||
BridgeAudioRoute.bluetoothHfp => 'bt-hfp',
|
||||
BridgeAudioRoute.bluetoothA2Dp => 'bt-a2dp',
|
||||
BridgeAudioRoute.unknown => 'unknown',
|
||||
};
|
||||
|
||||
String _backendLabel(BridgeAudioBackend backend) => switch (backend) {
|
||||
BridgeAudioBackend.platformVoiceProcessing => 'vpio',
|
||||
BridgeAudioBackend.sonora => 'sonora',
|
||||
BridgeAudioBackend.noop => 'noop',
|
||||
BridgeAudioBackend.webrtcApm => 'webrtc-apm',
|
||||
};
|
||||
|
||||
String _vadBackendLabel(BridgeVadBackend backend) => switch (backend) {
|
||||
BridgeVadBackend.webrtcVad => 'webrtc',
|
||||
BridgeVadBackend.sileroOnnx => 'silero',
|
||||
BridgeVadBackend.tenVad => 'ten',
|
||||
BridgeVadBackend.energyDebug => 'energy',
|
||||
BridgeVadBackend.disabled => 'off',
|
||||
};
|
||||
}
|
||||
|
||||
/// Semi-transparent dark box for the debug panel.
|
||||
class _PanelBox extends StatelessWidget {
|
||||
const _PanelBox({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/// SRS-209 listen-only banner for Android RECORD_AUDIO permission.
|
||||
/// SRS-209 listen-only banner for mobile microphone permission.
|
||||
///
|
||||
/// Trace:
|
||||
/// - SDD-106 §2 (denial UX — non-blocking affordance "Enable
|
||||
@@ -6,17 +6,19 @@
|
||||
/// - SRS-209 (path to grant; listen-only fallback).
|
||||
///
|
||||
/// Behaviour:
|
||||
/// * Watches [AndroidPermissionsService.recordAudioState].
|
||||
/// * Watches the injected microphone permission state listenable.
|
||||
/// * On `denied`: renders a non-modal banner with a "Grant" action.
|
||||
/// * On `permanentlyDenied`: action text becomes "Open Settings" and
|
||||
/// invokes [AndroidPermissionsService.openAppSettings].
|
||||
/// * On `granted` / `unknown`: builds an empty [SizedBox.shrink].
|
||||
/// * On non-Android hosts the service stays at `granted`, so this
|
||||
/// widget is effectively invisible without any extra branching.
|
||||
/// * On platforms whose service stays at `granted`, this widget is
|
||||
/// effectively invisible without any extra branching.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../services/android_permissions_service.dart';
|
||||
|
||||
/// Listen-only banner widget. Drop this above the `VoiceBar` in the
|
||||
@@ -24,43 +26,69 @@ import '../services/android_permissions_service.dart';
|
||||
///
|
||||
/// Trace: SDD-106 §2, §3; SRS-209.
|
||||
class PermissionStateBanner extends StatelessWidget {
|
||||
const PermissionStateBanner({super.key, required this.service});
|
||||
PermissionStateBanner({super.key, required AndroidPermissionsService service})
|
||||
: recordAudioState = service.recordAudioState,
|
||||
ensureRecordAudio = service.ensureRecordAudio,
|
||||
openAppSettings = service.openAppSettings;
|
||||
|
||||
/// Permissions service whose [AndroidPermissionsService.recordAudioState]
|
||||
/// drives the banner.
|
||||
final AndroidPermissionsService service;
|
||||
const PermissionStateBanner.fromCallbacks({
|
||||
super.key,
|
||||
required this.recordAudioState,
|
||||
required this.ensureRecordAudio,
|
||||
required this.openAppSettings,
|
||||
});
|
||||
|
||||
final ValueListenable<AndroidRecordAudioPermissionState> recordAudioState;
|
||||
final Future<AndroidRecordAudioPermissionState> Function() ensureRecordAudio;
|
||||
final Future<void> Function() openAppSettings;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<AndroidRecordAudioPermissionState>(
|
||||
valueListenable: service.recordAudioState,
|
||||
valueListenable: recordAudioState,
|
||||
builder: (ctx, state, _) {
|
||||
switch (state) {
|
||||
case AndroidRecordAudioPermissionState.granted:
|
||||
case AndroidRecordAudioPermissionState.unknown:
|
||||
return const SizedBox.shrink();
|
||||
case AndroidRecordAudioPermissionState.denied:
|
||||
return _BannerBody(
|
||||
// TODO(localization): route through AppL10n once an arb
|
||||
// entry exists. SRS-209 requires the message; the
|
||||
// English literal is a placeholder.
|
||||
message:
|
||||
'Microphone permission required for voice transmission.',
|
||||
actionLabel: 'Grant',
|
||||
onPressed: () => service.ensureRecordAudio(),
|
||||
);
|
||||
case AndroidRecordAudioPermissionState.permanentlyDenied:
|
||||
return _BannerBody(
|
||||
// TODO(localization): see above.
|
||||
message:
|
||||
'Microphone permission required for voice transmission.',
|
||||
actionLabel: 'Open Settings',
|
||||
onPressed: () => service.openAppSettings(),
|
||||
);
|
||||
final l10n = AppL10n.of(ctx);
|
||||
final action = _actionFor(state, l10n);
|
||||
if (action == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return _BannerBody(
|
||||
message: l10n.microphonePermissionRequiredForVoice,
|
||||
actionLabel: action.label,
|
||||
onPressed: action.onPressed,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_BannerAction? _actionFor(
|
||||
AndroidRecordAudioPermissionState state,
|
||||
AppL10n l10n,
|
||||
) {
|
||||
switch (state) {
|
||||
case AndroidRecordAudioPermissionState.granted:
|
||||
case AndroidRecordAudioPermissionState.unknown:
|
||||
return null;
|
||||
case AndroidRecordAudioPermissionState.denied:
|
||||
return _BannerAction(
|
||||
label: l10n.permissionGrantAction,
|
||||
onPressed: () => ensureRecordAudio(),
|
||||
);
|
||||
case AndroidRecordAudioPermissionState.permanentlyDenied:
|
||||
return _BannerAction(
|
||||
label: l10n.networkPermissionOpenSettings,
|
||||
onPressed: () => openAppSettings(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BannerAction {
|
||||
const _BannerAction({required this.label, required this.onPressed});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
}
|
||||
|
||||
class _BannerBody extends StatelessWidget {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/foundation.dart'
|
||||
show TargetPlatform, defaultTargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
|
||||
/// PTT capability badge (gen2 v0.9.3 / SDD-091).
|
||||
///
|
||||
/// Renders the active PTT level + backend in the Voice Bar so the
|
||||
/// user understands which input path is in effect. When the
|
||||
/// resolved capability is `L0Focused` an info icon appears that
|
||||
/// opens a per-platform explanation sheet describing why Global
|
||||
/// PTT is not active and what the user can do to engage it.
|
||||
class PttCapabilityBadge extends StatelessWidget {
|
||||
/// Construct a badge.
|
||||
const PttCapabilityBadge({
|
||||
super.key,
|
||||
required this.level,
|
||||
required this.backendId,
|
||||
required this.boundInputClass,
|
||||
});
|
||||
|
||||
/// Resolved capability level as the bridge emits it
|
||||
/// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
|
||||
/// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
|
||||
final String level;
|
||||
|
||||
/// Stable backend identifier (`focused`, `windows-raw-input`, …).
|
||||
final String backendId;
|
||||
|
||||
/// Privacy-safe input class (`keyboard`, `mouse-side-button`,
|
||||
/// or empty when no binding is set).
|
||||
final String boundInputClass;
|
||||
|
||||
bool get _isFocused => level == 'L0Focused';
|
||||
|
||||
String _explainBodyForPlatform(AppL10n l10n) {
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.windows:
|
||||
return l10n.pttCapabilityExplainGoGlobalWindows;
|
||||
case TargetPlatform.macOS:
|
||||
return l10n.pttCapabilityExplainGoGlobalMacos;
|
||||
case TargetPlatform.linux:
|
||||
return l10n.pttCapabilityExplainGoGlobalLinux;
|
||||
case TargetPlatform.iOS:
|
||||
return l10n.pttCapabilityExplainGoGlobalIos;
|
||||
default:
|
||||
return l10n.pttCapabilityExplainGoGlobalGeneric;
|
||||
}
|
||||
}
|
||||
|
||||
void _openExplanationSheet(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final theme = Theme.of(sheetContext);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttCapabilityExplainTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedHeading,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_explainBodyForPlatform(l10n),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
|
||||
final tooltipMessage = boundInputClass.isEmpty
|
||||
? badgeLabel
|
||||
: '$badgeLabel\n($boundInputClass)';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Tooltip(
|
||||
message: tooltipMessage,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isFocused ? Icons.crop_free : Icons.public,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isFocused)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline, size: 16),
|
||||
tooltip: l10n.pttCapabilityExplainTitle,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _openExplanationSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,16 @@
|
||||
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
||||
// core's transmit-mode selector + release-tail timer.
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:haptic_kit/haptic_kit.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../main.dart' show PttCapabilityBadge;
|
||||
import 'ptt_capability_badge.dart';
|
||||
import 'voice_platform.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// True when the host is a mobile platform without a hardware
|
||||
/// keyboard the user would bind a PTT key on. iOS / iPadOS /
|
||||
/// Android fall here. macOS / Linux / Windows / Web fall on the
|
||||
/// hardware-key path.
|
||||
bool get _isTouchOnlyPttHost {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS || Platform.isAndroid;
|
||||
}
|
||||
|
||||
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
|
||||
/// toggle, level meter, and a leave-channel affordance.
|
||||
class VoiceBar extends StatelessWidget {
|
||||
@@ -113,7 +105,7 @@ class VoiceBar extends StatelessWidget {
|
||||
case rust.BridgeTransmitMode.continuous:
|
||||
return l10n.voiceModeContinuous;
|
||||
case rust.BridgeTransmitMode.voiceActivity:
|
||||
return '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})';
|
||||
return l10n.voiceModeVoiceActivity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +229,7 @@ class VoiceBar extends StatelessWidget {
|
||||
// pinned to the bottom of a narrow-layout screen. The
|
||||
// release-tail value is folded into the small print
|
||||
// under the button rather than shown here.
|
||||
if (isPtt && !_isTouchOnlyPttHost)
|
||||
if (isPtt && !isTouchOnlyPttHost)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 22, top: 2),
|
||||
child: Text(
|
||||
@@ -288,7 +280,7 @@ class VoiceBar extends StatelessWidget {
|
||||
// the bottom of a narrow-layout screen. The release-
|
||||
// tail value sits above the button so the user sees
|
||||
// how long their voice continues after they let go.
|
||||
if (isPtt && _isTouchOnlyPttHost) ...[
|
||||
if (isPtt && isTouchOnlyPttHost) ...[
|
||||
const SizedBox(height: 4),
|
||||
Center(
|
||||
child: Text(
|
||||
@@ -380,10 +372,24 @@ class _PttHoldButton extends StatefulWidget {
|
||||
class _PttHoldButtonState extends State<_PttHoldButton> {
|
||||
bool _pressed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(Haptics.prepare().catchError((_) => false));
|
||||
}
|
||||
|
||||
void _setHeld(bool held) {
|
||||
if (_pressed == held) return;
|
||||
setState(() => _pressed = held);
|
||||
widget.onHeldChanged(held);
|
||||
_playPressHaptic(held);
|
||||
}
|
||||
|
||||
void _playPressHaptic(bool held) {
|
||||
final haptic = held
|
||||
? Haptics.impact(HapticImpactStyle.medium)
|
||||
: Haptics.selection();
|
||||
unawaited(haptic.catchError((_) {}));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -392,54 +398,62 @@ class _PttHoldButtonState extends State<_PttHoldButton> {
|
||||
final activeNow = _pressed || widget.active;
|
||||
final l10n = AppL10n.of(context);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => _setHeld(true),
|
||||
onTapUp: (_) => _setHeld(false),
|
||||
onTapCancel: () => _setHeld(false),
|
||||
onPanDown: (_) => _setHeld(true),
|
||||
onPanEnd: (_) => _setHeld(false),
|
||||
onPanCancel: () => _setHeld(false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: activeNow
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: activeNow
|
||||
? [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withAlpha(100),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2),
|
||||
return Semantics(
|
||||
button: true,
|
||||
liveRegion: true,
|
||||
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
|
||||
hint: l10n.pttHoldToTalkSemanticsHint,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => _setHeld(true),
|
||||
onTapUp: (_) => _setHeld(false),
|
||||
onTapCancel: () => _setHeld(false),
|
||||
onPanDown: (_) => _setHeld(true),
|
||||
onPanEnd: (_) => _setHeld(false),
|
||||
onPanCancel: () => _setHeld(false),
|
||||
child: ExcludeSemantics(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: activeNow
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: activeNow
|
||||
? [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withAlpha(100),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
activeNow ? Icons.mic : Icons.mic_none,
|
||||
color: activeNow
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
size: 24,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
activeNow ? Icons.mic : Icons.mic_none,
|
||||
color: activeNow
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
size: 24,
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: activeNow
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: activeNow
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
/// True when the host is a touch-only mobile platform without a
|
||||
/// hardware keyboard the user would bind a PTT key on.
|
||||
bool get isTouchOnlyPttHost {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS || Platform.isAndroid;
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
// Voice settings dialog (SDD-097). Surfaces a TransmitMode radio
|
||||
// group, a bind-key button, and a release-tail slider.
|
||||
// Voice settings dialog (SDD-097). Surfaces transmit mode, release
|
||||
// tail, and the full P1 audio processing configuration:
|
||||
// - Noise suppression (NS)
|
||||
// - Echo cancellation (AEC3)
|
||||
// - Automatic gain control (AGC2)
|
||||
// - High-pass filter (HPF)
|
||||
// - VAD backend
|
||||
// - iOS voice processing mode
|
||||
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
@@ -7,52 +15,41 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import 'voice_platform.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// True when the host is a touch-only mobile platform without a
|
||||
/// hardware keyboard the user would bind a PTT key on. Mirrors the
|
||||
/// helper in `voice_bar.dart`.
|
||||
bool get _isTouchOnlyPttHost {
|
||||
bool get _isIos {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS || Platform.isAndroid;
|
||||
return Platform.isIOS;
|
||||
}
|
||||
|
||||
/// Result returned by [`VoiceSettingsDialog`]. `null` indicates a
|
||||
/// cancelled dialog.
|
||||
/// Result returned by [VoiceSettingsDialog].
|
||||
class VoiceSettingsResult {
|
||||
/// Construct a result snapshot.
|
||||
const VoiceSettingsResult({
|
||||
required this.mode,
|
||||
required this.releaseTailMs,
|
||||
required this.bindKeyRequested,
|
||||
required this.audioConfig,
|
||||
});
|
||||
|
||||
/// Selected transmit mode.
|
||||
final rust.BridgeTransmitMode mode;
|
||||
|
||||
/// Chosen release-tail in milliseconds (0..=500, step 25).
|
||||
final int releaseTailMs;
|
||||
|
||||
/// True when the user tapped the "bind key" button. The caller
|
||||
/// is expected to open the focus-scoped capture dialog
|
||||
/// afterwards.
|
||||
final bool bindKeyRequested;
|
||||
final rust.BridgeAudioProcessingConfig audioConfig;
|
||||
}
|
||||
|
||||
/// Voice settings dialog widget.
|
||||
/// Voice + audio processing settings dialog.
|
||||
class VoiceSettingsDialog extends StatefulWidget {
|
||||
/// Construct a dialog seeded with the current settings.
|
||||
const VoiceSettingsDialog({
|
||||
super.key,
|
||||
required this.initialMode,
|
||||
required this.initialReleaseTailMs,
|
||||
required this.initialAudioConfig,
|
||||
});
|
||||
|
||||
/// Currently active transmit mode.
|
||||
final rust.BridgeTransmitMode initialMode;
|
||||
|
||||
/// Currently configured release tail in milliseconds.
|
||||
final int initialReleaseTailMs;
|
||||
final rust.BridgeAudioProcessingConfig initialAudioConfig;
|
||||
|
||||
@override
|
||||
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
||||
@@ -62,115 +59,273 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
late rust.BridgeTransmitMode _mode;
|
||||
late double _releaseTail;
|
||||
|
||||
// Audio processing state — mirrors BridgeAudioProcessingConfig fields.
|
||||
late bool _nsEnabled;
|
||||
late bool _aecEnabled;
|
||||
late bool _agcEnabled;
|
||||
late bool _hpfEnabled;
|
||||
late bool _limiterEnabled;
|
||||
late rust.BridgeVadBackend _vadBackend;
|
||||
late rust.BridgeIosVoiceProcessingMode _iosMode;
|
||||
late bool _debugWavDump;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mode = widget.initialMode;
|
||||
_releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble();
|
||||
|
||||
final c = widget.initialAudioConfig;
|
||||
_nsEnabled = c.ns != rust.BridgeEffectOwner.off;
|
||||
_aecEnabled = c.aec != rust.BridgeEffectOwner.off;
|
||||
_agcEnabled = c.agc != rust.BridgeEffectOwner.off;
|
||||
_hpfEnabled = c.hpfEnabled;
|
||||
_limiterEnabled = c.limiterEnabled;
|
||||
_vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
: c.vadBackend;
|
||||
_iosMode = c.iosMode;
|
||||
_debugWavDump = c.debugWavDumpEnabled;
|
||||
}
|
||||
|
||||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||
final c = widget.initialAudioConfig;
|
||||
final isSonora =
|
||||
_iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
|
||||
// In VPIO mode, enabled effects are platform-owned. Sonora ownership is
|
||||
// reserved for the experimental raw path so config validation stays honest.
|
||||
final aecOwner = isSonora
|
||||
? (_aecEnabled
|
||||
? rust.BridgeEffectOwner.sonora
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: rust.BridgeEffectOwner.platform; // VPIO always owns AEC
|
||||
final nsOwner = isSonora
|
||||
? (_nsEnabled
|
||||
? rust.BridgeEffectOwner.sonora
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (_nsEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
final agcOwner = isSonora
|
||||
? (_agcEnabled
|
||||
? rust.BridgeEffectOwner.sonora
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (_agcEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
final vadBackend = _vadBackend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
: _vadBackend;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: c.route,
|
||||
iosMode: _iosMode,
|
||||
processingBackend: isSonora
|
||||
? rust.BridgeAudioBackend.sonora
|
||||
: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: vadBackend,
|
||||
aec: aecOwner,
|
||||
ns: nsOwner,
|
||||
agc: agcOwner,
|
||||
hpfEnabled: _hpfEnabled,
|
||||
limiterEnabled: _limiterEnabled,
|
||||
vadHangoverMs: c.vadHangoverMs,
|
||||
vadPreRollMs: c.vadPreRollMs,
|
||||
vadMinTxMs: c.vadMinTxMs,
|
||||
debugWavDumpEnabled: _debugWavDump,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final platformVpio =
|
||||
_iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
|
||||
return AlertDialog(
|
||||
title: Text(l10n.voiceSettingsTitle),
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.voiceModeLabel,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
RadioListTile<rust.BridgeTransmitMode>(
|
||||
dense: true,
|
||||
value: rust.BridgeTransmitMode.ptt,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModePtt),
|
||||
onChanged: (v) => setState(() => _mode = v!),
|
||||
),
|
||||
RadioListTile<rust.BridgeTransmitMode>(
|
||||
dense: true,
|
||||
value: rust.BridgeTransmitMode.continuous,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModeContinuous),
|
||||
onChanged: (v) => setState(() => _mode = v!),
|
||||
),
|
||||
RadioListTile<rust.BridgeTransmitMode>(
|
||||
dense: true,
|
||||
value: rust.BridgeTransmitMode.voiceActivity,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModeVoiceActivity),
|
||||
secondary: Text(
|
||||
l10n.voiceModeComingSoon,
|
||||
style: theme.textTheme.bodySmall,
|
||||
width: 400,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Transmit mode ──────────────────────────────────────
|
||||
_sectionHeader(theme, l10n.voiceModeLabel),
|
||||
_radioTile<rust.BridgeTransmitMode>(
|
||||
value: rust.BridgeTransmitMode.ptt,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModePtt),
|
||||
onSelected: (v) => _mode = v,
|
||||
),
|
||||
// VoiceActivity is reserved per DEC-030 — keep the
|
||||
// tile visible but disabled per SDD-095.
|
||||
onChanged: null,
|
||||
),
|
||||
const Divider(),
|
||||
// Bind-key + release-tail are PTT-only concepts. Hide
|
||||
// them entirely when the user has switched to a
|
||||
// non-PTT mode so the dialog stays focused on what's
|
||||
// actually configurable for that mode.
|
||||
//
|
||||
// Additionally on touch-only mobile hosts (iOS / iPadOS
|
||||
// / Android) there is no hardware keyboard to bind a
|
||||
// key on — the VoiceBar renders an on-screen Push to
|
||||
// Talk button instead. Hide the Bind Key affordance
|
||||
// there but keep the release-tail slider since it
|
||||
// still applies to the on-screen button's behaviour.
|
||||
if (_mode == rust.BridgeTransmitMode.ptt) ...[
|
||||
if (!_isTouchOnlyPttHost) ...[
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.keyboard),
|
||||
label: Text(l10n.voiceBindKeyAction),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(
|
||||
_radioTile<rust.BridgeTransmitMode>(
|
||||
value: rust.BridgeTransmitMode.continuous,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModeContinuous),
|
||||
onSelected: (v) => _mode = v,
|
||||
),
|
||||
_radioTile<rust.BridgeTransmitMode>(
|
||||
value: rust.BridgeTransmitMode.voiceActivity,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModeVoiceActivity),
|
||||
onSelected: (v) => _mode = v,
|
||||
),
|
||||
|
||||
// ── PTT options ────────────────────────────────────────
|
||||
if (_mode == rust.BridgeTransmitMode.ptt) ...[
|
||||
const Divider(height: 24),
|
||||
if (!isTouchOnlyPttHost) ...[
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.keyboard),
|
||||
label: Text(l10n.voiceBindKeyAction),
|
||||
onPressed: () => Navigator.of(context).pop(
|
||||
VoiceSettingsResult(
|
||||
mode: _mode,
|
||||
releaseTailMs: _releaseTail.round(),
|
||||
bindKeyRequested: true,
|
||||
audioConfig: _buildConfig(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Text(
|
||||
l10n.voiceReleaseTailLabel,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _releaseTail,
|
||||
min: 0,
|
||||
max: 500,
|
||||
divisions: 20, // step 25 ms
|
||||
label:
|
||||
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
|
||||
onChanged: (v) => setState(() => _releaseTail = v),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: Text(
|
||||
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Text(
|
||||
l10n.voiceReleaseTailLabel,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _releaseTail,
|
||||
min: 0,
|
||||
max: 500,
|
||||
divisions: 20,
|
||||
label:
|
||||
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
|
||||
onChanged: (v) => setState(() => _releaseTail = v),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: Text(
|
||||
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
// ── Audio processing ───────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
_sectionHeader(theme, 'Audio processing'),
|
||||
|
||||
// iOS mode selector (iOS only)
|
||||
if (_isIos) ...[
|
||||
_subHeader(theme, 'Processing backend'),
|
||||
_radioTile<rust.BridgeIosVoiceProcessingMode>(
|
||||
value:
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
groupValue: _iosMode,
|
||||
title: const Text('Platform (VPIO)'),
|
||||
subtitle: _tileSubtitle('Apple AEC · NS · AGC'),
|
||||
onSelected: (v) => _iosMode = v,
|
||||
),
|
||||
_radioTile<rust.BridgeIosVoiceProcessingMode>(
|
||||
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
|
||||
groupValue: _iosMode,
|
||||
title: const Text('Sonora (experimental)'),
|
||||
subtitle: _tileSubtitle('Rust AEC3 · NS · AGC2'),
|
||||
onSelected: (v) => _iosMode = v,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
|
||||
// DSP toggles
|
||||
_subHeader(theme, 'DSP stages'),
|
||||
_switchTile(
|
||||
title: 'Noise suppression (NS)',
|
||||
subtitle: 'Wiener filter · stationary noise',
|
||||
value: _nsEnabled,
|
||||
onSelected: (v) => _nsEnabled = v,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Echo cancellation (AEC3)',
|
||||
subtitle: platformVpio
|
||||
? 'Managed by platform VPIO'
|
||||
: 'Adaptive NLMS · 80 ms tail',
|
||||
value: _aecEnabled,
|
||||
// AEC is always on in VPIO mode — disable the toggle.
|
||||
onSelected: platformVpio ? null : (v) => _aecEnabled = v,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Auto gain control (AGC2)',
|
||||
subtitle: 'RNN VAD-gated · −18 dBFS target',
|
||||
value: _agcEnabled,
|
||||
onSelected: (v) => _agcEnabled = v,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'High-pass filter (HPF)',
|
||||
subtitle: '80 Hz Butterworth · DC removal',
|
||||
value: _hpfEnabled,
|
||||
onSelected: (v) => _hpfEnabled = v,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Peak limiter',
|
||||
subtitle: '−1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: _limiterEnabled,
|
||||
onSelected: (v) => _limiterEnabled = v,
|
||||
),
|
||||
|
||||
// ── VAD ────────────────────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
_sectionHeader(theme, 'Voice activity detection (VAD)'),
|
||||
|
||||
_subHeader(theme, 'Backend'),
|
||||
_radioTile<rust.BridgeVadBackend>(
|
||||
value: rust.BridgeVadBackend.webrtcVad,
|
||||
groupValue: _vadBackend,
|
||||
title: const Text('WebRTC VAD'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Fast · energy-based · always available',
|
||||
),
|
||||
onSelected: (v) => _vadBackend = v,
|
||||
),
|
||||
_radioTile<rust.BridgeVadBackend>(
|
||||
value: rust.BridgeVadBackend.sileroOnnx,
|
||||
groupValue: _vadBackend,
|
||||
title: const Text('Silero v6 (ONNX)'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Neural · 32 ms frames · requires model file',
|
||||
),
|
||||
onSelected: (v) => _vadBackend = v,
|
||||
),
|
||||
_radioTile<rust.BridgeVadBackend>(
|
||||
value: rust.BridgeVadBackend.tenVad,
|
||||
groupValue: _vadBackend,
|
||||
title: const Text('TEN VAD'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Neural · 16 kHz · native runtime optional',
|
||||
),
|
||||
onSelected: (v) => _vadBackend = v,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Debug ──────────────────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
_sectionHeader(theme, 'Debug'),
|
||||
_switchTile(
|
||||
title: 'WAV dump',
|
||||
subtitle: 'Record raw/processed mic to temp dir',
|
||||
value: _debugWavDump,
|
||||
onSelected: (v) => _debugWavDump = v,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
@@ -184,6 +339,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
mode: _mode,
|
||||
releaseTailMs: _releaseTail.round(),
|
||||
bindKeyRequested: false,
|
||||
audioConfig: _buildConfig(),
|
||||
),
|
||||
),
|
||||
child: Text(l10n.pttConfigureSaveAction),
|
||||
@@ -191,4 +347,54 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _radioTile<T>({
|
||||
required T value,
|
||||
required T groupValue,
|
||||
required Widget title,
|
||||
Widget? subtitle,
|
||||
required ValueChanged<T> onSelected,
|
||||
}) => RadioListTile<T>(
|
||||
dense: true,
|
||||
value: value,
|
||||
groupValue: groupValue,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
setState(() => onSelected(v));
|
||||
},
|
||||
);
|
||||
|
||||
Widget _switchTile({
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required bool value,
|
||||
required ValueChanged<bool>? onSelected,
|
||||
}) => SwitchListTile(
|
||||
dense: true,
|
||||
title: Text(title),
|
||||
subtitle: _tileSubtitle(subtitle),
|
||||
value: value,
|
||||
onChanged: onSelected == null ? null : (v) => setState(() => onSelected(v)),
|
||||
);
|
||||
|
||||
Widget _tileSubtitle(String text) =>
|
||||
Text(text, style: const TextStyle(fontSize: 11));
|
||||
|
||||
Widget _sectionHeader(ThemeData theme, String text) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(text, style: theme.textTheme.titleSmall),
|
||||
);
|
||||
|
||||
Widget _subHeader(ThemeData theme, String text) => Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 2),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -309,6 +309,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
haptic_kit:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: haptic_kit
|
||||
sha256: "39efffa513c9f8ce3cdded8a4423797f69d71c9281779b83727337f3ee1ed9b8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -74,6 +74,9 @@ dependencies:
|
||||
# more "am I testing the right build?" question during the
|
||||
# iOS audio test cycle.
|
||||
package_info_plus: ^10.1.0
|
||||
# Touch-only PTT feedback for mobile voice UX (P0 voice basics,
|
||||
# DEC-003 iOS 13 floor; haptic_kit supports iOS 12+).
|
||||
haptic_kit: ^1.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -105,10 +108,9 @@ flutter:
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
assets:
|
||||
- assets/models/silero_vad.onnx
|
||||
- assets/models/ten_vad.onnx
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
@@ -22,39 +22,50 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||
import 'package:chanora_flutter/src/rust/frb_generated.dart';
|
||||
|
||||
const _runE2e = bool.fromEnvironment('CHANORA_RUN_E2E');
|
||||
const _skipReason =
|
||||
'Set --dart-define=CHANORA_RUN_E2E=true with a built native bridge to run '
|
||||
'network end-to-end acceptance.';
|
||||
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
if (!_runE2e) return;
|
||||
await RustLib.init();
|
||||
});
|
||||
|
||||
test('connect/snapshot/disconnect against cn.teamspeak.app', () async {
|
||||
// Defensive: in case a previous test left a connection open.
|
||||
try {
|
||||
test(
|
||||
'connect/snapshot/disconnect against cn.teamspeak.app',
|
||||
() async {
|
||||
// Defensive: in case a previous test left a connection open.
|
||||
try {
|
||||
await rust.disconnect();
|
||||
} catch (_) {}
|
||||
|
||||
final snap = await rust.connect(
|
||||
host: 'cn.teamspeak.app',
|
||||
nickname: 'ChanoraAlphaTest',
|
||||
password: '',
|
||||
);
|
||||
expect(snap.serverName, isNotEmpty);
|
||||
expect(snap.channels, isNotEmpty);
|
||||
// Welcome message is allowed to be empty on some servers; just
|
||||
// assert it's a String type (which it always is — this is more a
|
||||
// smoke than a real assertion).
|
||||
expect(snap.welcomeMessage, isA<String>());
|
||||
|
||||
// Re-fetch the snapshot; should still succeed.
|
||||
final snap2 = await rust.snapshot();
|
||||
expect(snap2.serverName, snap.serverName);
|
||||
|
||||
final connectedBefore = await rust.isConnected();
|
||||
expect(connectedBefore, isTrue);
|
||||
|
||||
await rust.disconnect();
|
||||
} catch (_) {}
|
||||
|
||||
final snap = await rust.connect(
|
||||
host: 'cn.teamspeak.app',
|
||||
nickname: 'ChanoraAlphaTest',
|
||||
password: '',
|
||||
);
|
||||
expect(snap.serverName, isNotEmpty);
|
||||
expect(snap.channels, isNotEmpty);
|
||||
// Welcome message is allowed to be empty on some servers; just
|
||||
// assert it's a String type (which it always is — this is more a
|
||||
// smoke than a real assertion).
|
||||
expect(snap.welcomeMessage, isA<String>());
|
||||
|
||||
// Re-fetch the snapshot; should still succeed.
|
||||
final snap2 = await rust.snapshot();
|
||||
expect(snap2.serverName, snap.serverName);
|
||||
|
||||
final connectedBefore = await rust.isConnected();
|
||||
expect(connectedBefore, isTrue);
|
||||
|
||||
await rust.disconnect();
|
||||
|
||||
final connectedAfter = await rust.isConnected();
|
||||
expect(connectedAfter, isFalse);
|
||||
}, timeout: const Timeout(Duration(seconds: 30)));
|
||||
final connectedAfter = await rust.isConnected();
|
||||
expect(connectedAfter, isFalse);
|
||||
},
|
||||
timeout: const Timeout(Duration(seconds: 30)),
|
||||
skip: _runE2e ? false : _skipReason,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,60 +23,73 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||
import 'package:chanora_flutter/src/rust/frb_generated.dart';
|
||||
|
||||
const _runE2e = bool.fromEnvironment('CHANORA_RUN_E2E');
|
||||
const _skipReason =
|
||||
'Set --dart-define=CHANORA_RUN_E2E=true with a built native bridge to run '
|
||||
'network end-to-end acceptance.';
|
||||
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
if (!_runE2e) return;
|
||||
await RustLib.init();
|
||||
});
|
||||
|
||||
test('connect → start_audio → PTT cycle → disconnect', () async {
|
||||
// Defensive cleanup in case a previous test left state.
|
||||
try {
|
||||
test(
|
||||
'connect → start_audio → PTT cycle → disconnect',
|
||||
() async {
|
||||
// Defensive cleanup in case a previous test left state.
|
||||
try {
|
||||
await rust.disconnect();
|
||||
} catch (_) {}
|
||||
|
||||
final snap = await rust.connect(
|
||||
host: 'cn.teamspeak.app',
|
||||
nickname: 'ChanoraBetaTest',
|
||||
password: '',
|
||||
);
|
||||
expect(snap.serverName, isNotEmpty);
|
||||
expect(snap.channels, isNotEmpty);
|
||||
|
||||
await rust.voiceJoin(channelId: snap.channels.first.id, password: '');
|
||||
|
||||
// Zero out the release tail so set_ptt(false) takes effect
|
||||
// synchronously — the default 200 ms tail (SDD-096) would
|
||||
// otherwise delay the assertion below.
|
||||
await rust.setReleaseTailMs(ms: 0);
|
||||
|
||||
// Initial stats: PTT off, no frames sent yet.
|
||||
final s0 = await rust.audioStats();
|
||||
expect(s0.pttActive, isFalse);
|
||||
expect(s0.framesSent, 0);
|
||||
|
||||
// Press PTT, wait ~250 ms, then read stats. If the host has a
|
||||
// real microphone the encoder will emit ~10-12 frames. If the
|
||||
// host has only a null source (typical headless), capture will
|
||||
// have logged a warning at startAudio time and run in
|
||||
// playback-only mode; framesSent stays at 0. Either outcome is
|
||||
// a successful test of the wiring — what we actually verify
|
||||
// here is that the PTT flag changes and no exception is thrown.
|
||||
await rust.setPtt(active: true);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
final s1 = await rust.audioStats();
|
||||
expect(s1.pttActive, isTrue);
|
||||
|
||||
await rust.setPtt(active: false);
|
||||
// Give the release-tail (set to 0 above) one tick to settle.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
final s2 = await rust.audioStats();
|
||||
expect(s2.pttActive, isFalse);
|
||||
|
||||
await rust.disconnect();
|
||||
} catch (_) {}
|
||||
final connectedAfter = await rust.isConnected();
|
||||
expect(connectedAfter, isFalse);
|
||||
|
||||
final snap = await rust.connect(
|
||||
host: 'cn.teamspeak.app',
|
||||
nickname: 'ChanoraBetaTest',
|
||||
password: '',
|
||||
);
|
||||
expect(snap.serverName, isNotEmpty);
|
||||
expect(snap.channels, isNotEmpty);
|
||||
|
||||
await rust.voiceJoin(channelId: snap.channels.first.id, password: '');
|
||||
|
||||
// Zero out the release tail so set_ptt(false) takes effect
|
||||
// synchronously — the default 200 ms tail (SDD-096) would
|
||||
// otherwise delay the assertion below.
|
||||
await rust.setReleaseTailMs(ms: 0);
|
||||
|
||||
// Initial stats: PTT off, no frames sent yet.
|
||||
final s0 = await rust.audioStats();
|
||||
expect(s0.pttActive, isFalse);
|
||||
expect(s0.framesSent, 0);
|
||||
|
||||
// Press PTT, wait ~250 ms, then read stats. If the host has a
|
||||
// real microphone the encoder will emit ~10-12 frames. If the
|
||||
// host has only a null source (typical headless), capture will
|
||||
// have logged a warning at startAudio time and run in
|
||||
// playback-only mode; framesSent stays at 0. Either outcome is
|
||||
// a successful test of the wiring — what we actually verify
|
||||
// here is that the PTT flag changes and no exception is thrown.
|
||||
await rust.setPtt(active: true);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
final s1 = await rust.audioStats();
|
||||
expect(s1.pttActive, isTrue);
|
||||
|
||||
await rust.setPtt(active: false);
|
||||
// Give the release-tail (set to 0 above) one tick to settle.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
final s2 = await rust.audioStats();
|
||||
expect(s2.pttActive, isFalse);
|
||||
|
||||
await rust.disconnect();
|
||||
final connectedAfter = await rust.isConnected();
|
||||
expect(connectedAfter, isFalse);
|
||||
|
||||
// ignore: avoid_print
|
||||
print('Beta E2E: TX=${s1.framesSent} frames, RX=${s1.framesReceived} frames');
|
||||
}, timeout: const Timeout(Duration(seconds: 30)));
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'Beta E2E: TX=${s1.framesSent} frames, RX=${s1.framesReceived} frames',
|
||||
);
|
||||
},
|
||||
timeout: const Timeout(Duration(seconds: 30)),
|
||||
skip: _runE2e ? false : _skipReason,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,35 +11,224 @@
|
||||
// Verification-plan rows: SWE4-UV-014, SWE4-UV-019, SWE4-UV-020 (swe4-unit-verification-plan.md).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/services/android_permissions_service.dart';
|
||||
import 'package:chanora_flutter/services/ios_permissions_service.dart';
|
||||
import 'package:chanora_flutter/widgets/permission_state_banner.dart';
|
||||
import 'package:chanora_flutter/widgets/voice_compact.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('renders English banner', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Builder(builder: (ctx) {
|
||||
final l10n = AppL10n.of(ctx);
|
||||
return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
|
||||
}),
|
||||
));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (ctx) {
|
||||
final l10n = AppL10n.of(ctx);
|
||||
return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('Beta build'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders Simplified Chinese banner', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
locale: const Locale('zh'),
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Builder(builder: (ctx) {
|
||||
final l10n = AppL10n.of(ctx);
|
||||
return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
|
||||
}),
|
||||
));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('zh'),
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (ctx) {
|
||||
final l10n = AppL10n.of(ctx);
|
||||
return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('Beta 版本'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('localizes diagnostic export save action', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (ctx) {
|
||||
final l10n = AppL10n.of(ctx);
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
Text(l10n.diagnosticsSaveAction),
|
||||
Text(l10n.diagnosticsSaved('/tmp/chanora-diagnostics.txt')),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Save export'), findsOneWidget);
|
||||
expect(find.textContaining('chanora-diagnostics.txt'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('on-screen PTT exposes hold-to-talk semantics', (tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: VoicePttButton(active: false, onHeldChanged: (_) {}),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final node = tester.getSemantics(find.byType(VoicePttButton));
|
||||
expect(node.label, 'Hold to talk');
|
||||
expect(node.hint, 'Press and hold to transmit voice; release to stop.');
|
||||
expect(node.hasFlag(SemanticsFlag.isButton), isTrue);
|
||||
expect(node.hasFlag(SemanticsFlag.isLiveRegion), isTrue);
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('permission banner grants denied microphone access', (
|
||||
tester,
|
||||
) async {
|
||||
final state = ValueNotifier(AndroidRecordAudioPermissionState.denied);
|
||||
var grantCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: PermissionStateBanner.fromCallbacks(
|
||||
recordAudioState: state,
|
||||
ensureRecordAudio: () async {
|
||||
grantCount += 1;
|
||||
return AndroidRecordAudioPermissionState.granted;
|
||||
},
|
||||
openAppSettings: () async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Grant'), findsOneWidget);
|
||||
await tester.tap(find.text('Grant'));
|
||||
expect(grantCount, 1);
|
||||
state.dispose();
|
||||
});
|
||||
|
||||
testWidgets('permission banner opens settings after permanent denial', (
|
||||
tester,
|
||||
) async {
|
||||
final state = ValueNotifier(
|
||||
AndroidRecordAudioPermissionState.permanentlyDenied,
|
||||
);
|
||||
var settingsCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: PermissionStateBanner.fromCallbacks(
|
||||
recordAudioState: state,
|
||||
ensureRecordAudio: () async =>
|
||||
AndroidRecordAudioPermissionState.permanentlyDenied,
|
||||
openAppSettings: () async {
|
||||
settingsCount += 1;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Open System Settings'), findsOneWidget);
|
||||
await tester.tap(find.text('Open System Settings'));
|
||||
expect(settingsCount, 1);
|
||||
state.dispose();
|
||||
});
|
||||
|
||||
testWidgets('on-screen PTT reports press and release gestures', (
|
||||
tester,
|
||||
) async {
|
||||
final states = <bool>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: VoicePttButton(active: false, onHeldChanged: states.add),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final center = tester.getCenter(find.byType(VoicePttButton));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
|
||||
expect(states, [true, false]);
|
||||
});
|
||||
|
||||
testWidgets('iOS permission service maps channel states and settings', (
|
||||
tester,
|
||||
) async {
|
||||
const channel = MethodChannel(iosPlatformChannelName);
|
||||
final methods = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
methods.add(call.method);
|
||||
switch (call.method) {
|
||||
case methodGetMicrophonePermissionState:
|
||||
return 'NotDetermined';
|
||||
case methodRequestMicrophonePermission:
|
||||
return 'Granted';
|
||||
case methodIosOpenAppSettings:
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final service = IosPermissionsService(channel: channel);
|
||||
await service.start();
|
||||
expect(
|
||||
service.recordAudioState.value,
|
||||
AndroidRecordAudioPermissionState.denied,
|
||||
);
|
||||
expect(
|
||||
await service.ensureRecordAudio(),
|
||||
AndroidRecordAudioPermissionState.granted,
|
||||
);
|
||||
await service.openAppSettings();
|
||||
|
||||
expect(methods, [
|
||||
methodGetMicrophonePermissionState,
|
||||
methodRequestMicrophonePermission,
|
||||
methodIosOpenAppSettings,
|
||||
]);
|
||||
|
||||
service.dispose();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user