feat: Android Oboe voice backend — WebRTC APM, VAD, HW/SW toggle, BBCode welcome, link trust, foreground task

Audio engine (Rust):
- Android Oboe: WebRTC APM (AEC/NS/AGC/HPF) + TEN/Silero ONNX VAD
- Hardware effects (JNI) with software fallback per-effect
- Render reference buffer for AEC between output/capture callbacks
- Voice activity gate: suppress transmission when speaker muted (all platforms)
- Audio focus (SDD-109) + Bluetooth SCO (SDD-110) via JNI
- ONNX Runtime 1.26 via ort 2.0.0-rc.12 (down from rc.10, ndarray 0.17)
- VAD worker channel capacity 8→32, initial seq u64::MAX (warm-up fix)
- TEN VAD default backend (was Silero)
- Platform→WebrtcApm resolution after hardware binding
- oboe-rs edisonjwa fork with get_raw_session_id()

Android Kotlin:
- AndroidAudioFocusController + AndroidBluetoothScoController
- AndroidAudioLifecycleController (route changes to Flutter)
- ProGuard rules for new controllers

Flutter UI:
- VoiceSettings: Android HW/SW toggle (Platform auto / WebRTC APM)
- VoiceStatusChip: mute warning border + Speaker muted label
- BBCode welcome message parser (BbCodeText, case-insensitive)
- Welcome message foldable (expanded by default)
- Link trust dialog (domain wildcards, SharedPreferences)
- HapticFeedback on voice sheet opener
- Server name in AppBar, version v0.1.0
- Default channel (id=1) visible, serverquery clients hidden
- flutter_foreground_task integration

Config:
- ort load-dynamic on all non-iOS (Android/Linux/Windows)
- ONNX Runtime AAR 1.26.0
- ndarray moved to common deps (was Apple-only)
This commit is contained in:
Edison Jwa
2026-05-22 09:29:57 +09:00
parent 6af4ecab0f
commit bf284018e6
37 changed files with 3676 additions and 703 deletions
+134 -17
View File
@@ -18,6 +18,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart';
@@ -30,6 +31,8 @@ import 'widgets/voice_platform.dart';
import 'widgets/voice_bar.dart';
import 'widgets/voice_compact.dart';
import 'widgets/voice_settings.dart';
import 'widgets/bbcode_text.dart';
import 'services/link_trust_service.dart';
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
@@ -62,11 +65,12 @@ Future<void> _configureBundledVadModels() async {
assetPath: _sileroVadAsset,
fileName: 'silero_vad.onnx',
);
await _copyBundledAssetToDocuments(
final ten = await _copyBundledAssetToDocuments(
assetPath: _tenVadAsset,
fileName: 'ten_vad.onnx',
);
await rust.setVadModelPath(path: silero.path);
await rust.setTenVadModelPath(path: ten.path);
}
/// Top padding for macOS to clear traffic-light buttons.
@@ -136,7 +140,7 @@ String? _pttDisplayLabelForKey(LogicalKeyboardKey k) {
/// pubspec.yaml advances (e.g. rc.8 -> rc.9 -> 1.0.0). The
/// build-counter suffix changes automatically on every pubspec
/// `+<n>` bump because Flutter writes it into Info.plist.
const String _kSemverBaseline = 'v1.0.0-rc.8';
const String _kSemverBaseline = 'v0.1.0';
String _kAppVersion = _kSemverBaseline;
Future<void> main() async {
@@ -146,6 +150,8 @@ Future<void> main() async {
unawaited(_wireStorage());
unawaited(_wireConnectivity());
_wireIosAudioLifecycle();
_wireAndroidAudioLifecycle();
await _configureBundledVadModels();
runApp(const ChanoraApp());
}
@@ -214,6 +220,37 @@ void _wireIosAudioLifecycle() {
});
}
/// Wire the Android audio lifecycle MethodChannel.
///
/// Kotlin side (`AndroidAudioLifecycleController`) posts route-change
/// events through `FlutterMethodChannel` named
/// `"chanora/android_audio_lifecycle"`. This handler dispatches them to
/// the FRB bridge functions on the Rust side, mirroring the iOS pattern.
void _wireAndroidAudioLifecycle() {
if (!Platform.isAndroid) return;
const channel = MethodChannel('chanora/android_audio_lifecycle');
channel.setMethodCallHandler((call) async {
try {
switch (call.method) {
case 'handleRouteChange':
final args = call.arguments;
final routeStr = args is Map
? (args['routeType'] as String? ?? 'Unknown')
: (args as String? ?? 'Unknown');
final route = _parseBridgeAudioRoute(routeStr);
rust.handleRouteChange(route: route);
break;
default:
// Unknown method — ignore gracefully rather than crashing.
break;
}
} catch (_) {
// Errors from the Rust side are already logged there;
// don't propagate exceptions to the Android framework.
}
});
}
/// Populate `_kAppVersion` by suffixing the platform-canonical
/// build number to `_kSemverBaseline`. Format:
/// `v1.0.0-rc.8+<build>` (e.g. `v1.0.0-rc.8+64`). The build
@@ -728,6 +765,15 @@ class _BetaHomeState extends State<_BetaHome> {
_phase = _Phase.connected;
_applySnapshot(snap);
});
try {
await FlutterForegroundTask.startService(
notificationTitle: 'Chanora',
notificationText: 'Connected to ${snap.serverName}',
notificationButtons: const [
NotificationButton(id: 'disconnect', text: 'Disconnect'),
],
);
} catch (_) {}
} catch (e) {
if (!mounted) return;
final errorStr = e.toString();
@@ -1186,6 +1232,9 @@ class _BetaHomeState extends State<_BetaHome> {
try {
await rust.disconnect();
} catch (_) {}
try {
await FlutterForegroundTask.stopService();
} catch (_) {}
if (!mounted) return;
setState(() {
_phase = _Phase.idle;
@@ -1506,6 +1555,7 @@ class _BetaHomeState extends State<_BetaHome> {
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
isSelected: _hardMute,
selectedIcon: const Icon(Icons.mic_off),
color: _hardMute ? theme.colorScheme.error : null,
onPressed: _onToggleHardMute,
),
IconButton(
@@ -1513,6 +1563,7 @@ class _BetaHomeState extends State<_BetaHome> {
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
isSelected: _outputMuted,
selectedIcon: const Icon(Icons.headset_off),
color: _outputMuted ? theme.colorScheme.error : null,
onPressed: _toggleOutputMute,
),
],
@@ -1532,6 +1583,10 @@ class _BetaHomeState extends State<_BetaHome> {
const headerTitle = SizedBox.shrink();
final appBarTitle = _phase == _Phase.connected
? Text(_snapshot?.serverName ?? l10n.appTitle, style: theme.textTheme.titleMedium)
: headerTitle;
final bodyContent = LayoutBuilder(
builder: (ctx, bodyConstraints) {
const wideBreakpoint = 600.0;
@@ -1554,7 +1609,8 @@ class _BetaHomeState extends State<_BetaHome> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
Text(statusText(), style: theme.textTheme.titleMedium),
if (_phase != _Phase.connected)
Text(statusText(), style: theme.textTheme.titleMedium),
if (_lostReason != null || _reconnectAttempt != null) ...[
const SizedBox(height: 8),
Container(
@@ -1707,6 +1763,8 @@ class _BetaHomeState extends State<_BetaHome> {
pttBoundKeyLabel: _pttBoundKeyLabel,
audioStats: _audioStats,
isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute,
outputMuted: _outputMuted,
onTap: () => _onOpenVoiceDetailsSheet(),
),
if (_inChannel &&
@@ -1756,7 +1814,7 @@ class _BetaHomeState extends State<_BetaHome> {
}
return Scaffold(
appBar: AppBar(title: headerTitle, actions: headerActions),
appBar: AppBar(title: appBarTitle, actions: headerActions),
body: SafeArea(
top: false,
child: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
@@ -2168,6 +2226,75 @@ class _BookmarkList extends StatelessWidget {
///
/// Driven by the `BridgeEvent::PttCapability` stream published by
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
class _WelcomeMessageTile extends StatefulWidget {
const _WelcomeMessageTile({required this.welcomeMessage});
final String welcomeMessage;
@override
State<_WelcomeMessageTile> createState() => _WelcomeMessageTileState();
}
class _WelcomeMessageTileState extends State<_WelcomeMessageTile> {
bool _expanded = true;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
HapticFeedback.selectionClick();
setState(() => _expanded = !_expanded);
},
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Server welcome message',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
AnimatedCrossFade(
firstChild: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: BbCodeText(
widget.welcomeMessage,
linkTrust: LinkTrustService.instance,
),
),
secondChild: const SizedBox(width: double.infinity),
crossFadeState: _expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
],
),
);
}
}
/// updates the props on each transition.
class _SnapshotView extends StatelessWidget {
const _SnapshotView({
@@ -2242,17 +2369,7 @@ class _SnapshotView extends StatelessWidget {
),
if (snapshot.welcomeMessage.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
snapshot.welcomeMessage,
style: theme.textTheme.bodySmall,
),
),
_WelcomeMessageTile(welcomeMessage: snapshot.welcomeMessage),
],
const Divider(height: 24),
Text(l10n.channelsHeading, style: theme.textTheme.titleMedium),
@@ -2271,7 +2388,6 @@ class _SnapshotView extends StatelessWidget {
: null,
),
title: Text(ch.name),
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
selected: ch.id == currentVoiceChannelId,
onTap:
hasJoinPending ||
@@ -2284,7 +2400,8 @@ class _SnapshotView extends StatelessWidget {
),
),
for (final cl in byChannel[ch.id] ?? const <rust.BridgeClient>[])
_clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel),
if (!cl.isServerQuery)
_clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel),
],
],
);
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
class LinkTrustService extends ChangeNotifier {
static LinkTrustService? _instance;
final Set<String> _trusted = {};
bool _loaded = false;
static LinkTrustService get instance {
_instance ??= LinkTrustService._();
return _instance!;
}
LinkTrustService._() {
_load();
}
Future<void> _load() async {
if (_loaded) return;
_loaded = true;
final prefs = await SharedPreferences.getInstance();
final domains = prefs.getStringList('trusted_domains') ?? [];
_trusted.addAll(domains);
notifyListeners();
}
bool isTrusted(String host) {
host = host.toLowerCase();
for (final pattern in _trusted) {
if (_matches(host, pattern)) return true;
}
return false;
}
Future<void> addTrustedDomain(String host) async {
host = host.toLowerCase();
_trusted.add(host);
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList('trusted_domains', _trusted.toList());
}
bool _matches(String host, String pattern) {
if (pattern.startsWith('*.')) {
final suffix = pattern.substring(2);
return host == suffix || host.endsWith('.$suffix');
}
return host == pattern;
}
}
Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
bool remember = false;
return showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Open external link?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('You are about to open a link to:\n\n$domain'),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: remember,
onChanged: (v) => setDialogState(() => remember = v ?? false),
),
),
const SizedBox(width: 8),
const Flexible(
child: Text('Trust all links from this domain'),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(remember),
child: const Text('Open'),
),
],
),
),
);
}
+28 -46
View File
@@ -10,7 +10,7 @@ import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
part 'api.freezed.dart';
// These functions are ignored because they are not marked as `pub`: `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
/// Return the platform-conventional log-file path as a string, or
@@ -46,10 +46,24 @@ Future<bool> isConnected() => RustLib.instance.api.crateApiIsConnected();
void handleRouteChange({required BridgeAudioRoute route}) =>
RustLib.instance.api.crateApiHandleRouteChange(route: route);
/// Handle iOS AVAudioSession media-services reset.
/// Handle iOS AVAudioSession media-services reset (legacy, no route arg).
///
/// Called by the existing FRB-generated Dart binding. Uses
/// `AudioRoute::Unknown` which triggers a route-change recompute.
/// The AppDelegate now also calls `handle_media_services_reset_with_route`
/// directly after rebuilding the session.
void handleMediaServicesReset() =>
RustLib.instance.api.crateApiHandleMediaServicesReset();
/// Handle iOS AVAudioSession media-services reset with the current
/// route class. Called by AppDelegate after rebuilding the session.
///
/// `route_class` is the Swift-side route class string (e.g. "Speaker").
void handleMediaServicesResetWithRoute({required String routeClass}) => RustLib
.instance
.api
.crateApiHandleMediaServicesResetWithRoute(routeClass: routeClass);
/// Handle iOS AVAudioSession interruption begin (SDD-101).
void handleInterruptionBegan() =>
RustLib.instance.api.crateApiHandleInterruptionBegan();
@@ -229,59 +243,27 @@ Future<BridgeAudioStats> audioStats() =>
/// Apply the P1 audio-processing config.
Future<void> setAudioProcessingConfig({
required BridgeAudioProcessingConfig config,
}) async {
_lastAppliedAudioConfig = config;
return RustLib.instance.api.crateApiSetAudioProcessingConfig(config: config);
}
}) => RustLib.instance.api.crateApiSetAudioProcessingConfig(config: config);
/// Read the current audio-processing config.
///
/// Returns the live config as last applied to the audio engine.
/// Returns a default config when no session is active.
Future<BridgeAudioProcessingConfig> getAudioProcessingConfig() =>
RustLib.instance.api.crateApiGetAudioProcessingConfig();
/// 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);
/// Configure the TEN VAD ONNX model path.
Future<void> setTenVadModelPath({required String path}) =>
RustLib.instance.api.crateApiSetTenVadModelPath(path: path);
/// Enable or disable audio debug WAV dumping.
Future<void> enableAudioDebugWavDump({required bool enabled}) =>
RustLib.instance.api.crateApiEnableAudioDebugWavDump(enabled: enabled);
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0';
@override
int get rustContentHash => -1835973251;
int get rustContentHash => -436507436;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -103,6 +103,8 @@ abstract class RustLibApi extends BaseApi {
String crateApiExportDiagnostics();
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig();
Future<(String, String)> crateApiGetPttBinding();
Future<int> crateApiGetReleaseTailMs();
@@ -115,6 +117,8 @@ abstract class RustLibApi extends BaseApi {
void crateApiHandleMediaServicesReset();
void crateApiHandleMediaServicesResetWithRoute({required String routeClass});
void crateApiHandleRouteChange({required BridgeAudioRoute route});
Future<void> crateApiInitStorage({required String dir});
@@ -159,6 +163,8 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiSetReleaseTailMs({required int ms});
Future<void> crateApiSetTenVadModelPath({required String path});
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode});
Future<void> crateApiSetVadModelPath({required String path});
@@ -469,7 +475,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
const TaskConstMeta(debugName: "export_diagnostics", argNames: []);
@override
Future<(String, String)> crateApiGetPttBinding() {
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
@@ -481,6 +487,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_bridge_audio_processing_config,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiGetAudioProcessingConfigConstMeta,
argValues: [],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiGetAudioProcessingConfigConstMeta =>
const TaskConstMeta(
debugName: "get_audio_processing_config",
argNames: [],
);
@override
Future<(String, String)> crateApiGetPttBinding() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 12,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_record_string_string,
decodeErrorData: null,
@@ -504,7 +540,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 12,
funcId: 13,
port: port_,
);
},
@@ -531,7 +567,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 13,
funcId: 14,
port: port_,
);
},
@@ -555,7 +591,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -578,7 +614,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bool(shouldResume, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -603,7 +639,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -622,6 +658,32 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: [],
);
@override
void crateApiHandleMediaServicesResetWithRoute({required String routeClass}) {
return handler.executeSync(
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(routeClass, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: null,
),
constMeta: kCrateApiHandleMediaServicesResetWithRouteConstMeta,
argValues: [routeClass],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiHandleMediaServicesResetWithRouteConstMeta =>
const TaskConstMeta(
debugName: "handle_media_services_reset_with_route",
argNames: ["routeClass"],
);
@override
void crateApiHandleRouteChange({required BridgeAudioRoute route}) {
return handler.executeSync(
@@ -629,7 +691,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_audio_route(route, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -657,7 +719,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 18,
funcId: 20,
port: port_,
);
},
@@ -684,7 +746,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 19,
funcId: 21,
port: port_,
);
},
@@ -711,7 +773,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 20,
funcId: 22,
port: port_,
);
},
@@ -735,7 +797,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -765,7 +827,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 22,
funcId: 24,
port: port_,
);
},
@@ -794,7 +856,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 23,
funcId: 25,
port: port_,
);
},
@@ -827,7 +889,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 24,
funcId: 26,
port: port_,
);
},
@@ -858,7 +920,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 25,
funcId: 27,
port: port_,
);
},
@@ -886,7 +948,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 26,
funcId: 28,
port: port_,
);
},
@@ -916,7 +978,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 27,
funcId: 29,
port: port_,
);
},
@@ -944,7 +1006,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_network_state(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -970,7 +1032,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 29,
funcId: 31,
port: port_,
);
},
@@ -998,7 +1060,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 30,
funcId: 32,
port: port_,
);
},
@@ -1026,7 +1088,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 31,
funcId: 33,
port: port_,
);
},
@@ -1058,7 +1120,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 32,
funcId: 34,
port: port_,
);
},
@@ -1088,7 +1150,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 33,
funcId: 35,
port: port_,
);
},
@@ -1106,6 +1168,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiSetReleaseTailMsConstMeta =>
const TaskConstMeta(debugName: "set_release_tail_ms", argNames: ["ms"]);
@override
Future<void> crateApiSetTenVadModelPath({required String path}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(path, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 36,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiSetTenVadModelPathConstMeta,
argValues: [path],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiSetTenVadModelPathConstMeta => const TaskConstMeta(
debugName: "set_ten_vad_model_path",
argNames: ["path"],
);
@override
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode}) {
return handler.executeNormal(
@@ -1116,7 +1208,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 34,
funcId: 37,
port: port_,
);
},
@@ -1144,7 +1236,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 35,
funcId: 38,
port: port_,
);
},
@@ -1171,7 +1263,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 36,
funcId: 39,
port: port_,
);
},
@@ -1199,7 +1291,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 37,
funcId: 40,
port: port_,
);
},
@@ -1231,7 +1323,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 38,
funcId: 41,
port: port_,
);
},
@@ -1260,7 +1352,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 39,
funcId: 42,
port: port_,
);
},
@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/link_trust_service.dart';
final _tagRe = RegExp(
r'\[(\/?(?:b|i|u|s'
r'|color(?:=[^\]]*)?'
r'|size(?:=\d+)?'
r'|url(?:=[^\]]*)?'
r'|img(?:=[^\]]*)?'
r'|list|\*|quote|code|center|left|right'
r'))\]',
caseSensitive: false,
);
final _colorRe = RegExp(r'color=([#\w]+)');
final _sizeRe = RegExp(r'size=(\d+)');
final _urlRe = RegExp(r'url=(.+)');
final _imgRe = RegExp(r'img=(.+)');
final _urlAutoRe = RegExp(r'(?:\[url\])?(https?://[^\s\[\]]+)(?:\[/url\])?', caseSensitive: false);
final _closeUrlRe = RegExp(r'\[/url\]', caseSensitive: false);
int? _findCloseUrl(String src, int from) {
final m = _closeUrlRe.matchAsPrefix(src, from);
if (m != null) return from;
final idx = src.indexOf('[/url]', from);
if (idx >= 0) return idx;
final idxu = src.indexOf('[/URL]', from);
if (idxu >= 0) return idxu;
return null;
}
Color? _parseColor(String hex) {
try {
var h = hex.replaceFirst('#', '');
if (h.length == 6) h = 'FF$h';
if (h.length == 8) {
return Color(int.parse(h, radix: 16));
}
} catch (_) {}
return null;
}
class BbCodeText extends StatelessWidget {
const BbCodeText(this.text, {super.key, required this.linkTrust});
final String text;
final LinkTrustService linkTrust;
@override
Widget build(BuildContext context) {
if (!text.contains('[') || !text.contains(']')) {
return _plainWithAutoLinks(context, text);
}
return _render(context, text);
}
Widget _plainWithAutoLinks(BuildContext context, String src) {
final parts = <InlineSpan>[];
int last = 0;
for (final m in _urlAutoRe.allMatches(src)) {
if (m.start > last) {
parts.add(TextSpan(text: src.substring(last, m.start)));
}
final url = m.group(1)!;
parts.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url,
linkTrust: linkTrust,
child: Text(
url,
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
));
last = m.end;
}
if (last < src.length) {
parts.add(TextSpan(text: src.substring(last)));
}
if (parts.isEmpty) return Text(src);
return Text.rich(TextSpan(children: parts));
}
Widget _render(BuildContext context, String src) {
final spans = <InlineSpan>[];
final tags = <String>[];
void flush(StringBuffer buf) {
if (buf.isEmpty) return;
var t = buf.toString();
buf.clear();
bool bold = false;
bool italic = false;
bool underline = false;
bool strikethrough = false;
Color? color;
double? size;
for (final tag in tags) {
if (tag == 'b') {
bold = true;
} else if (tag == 'i') {
italic = true;
} else if (tag == 'u') {
underline = true;
} else if (tag == 's') {
strikethrough = true;
} else if (tag.startsWith('color=')) {
color = _parseColor(tag.substring(6));
} else if (tag.startsWith('size=')) {
size = double.tryParse(tag.substring(5));
}
}
spans.add(TextSpan(
text: t,
style: TextStyle(
fontWeight: bold ? FontWeight.bold : null,
fontStyle: italic ? FontStyle.italic : null,
decoration: TextDecoration.combine([
if (underline) TextDecoration.underline,
if (strikethrough) TextDecoration.lineThrough,
]),
color: color,
fontSize: size,
),
));
}
final buf = StringBuffer();
int i = 0;
while (i < src.length) {
if (src[i] != '[') {
buf.write(src[i]);
i++;
continue;
}
final m = _tagRe.matchAsPrefix(src, i);
if (m == null) {
buf.write(src[i]);
i++;
continue;
}
flush(buf);
final raw = m.group(1)!.toLowerCase();
i = m.end;
if (raw.startsWith('/')) {
final closeTag = raw.substring(1);
if (closeTag == 'url' || closeTag == 'img') {
continue;
}
tags.remove(closeTag);
continue;
}
switch (raw) {
case 'b':
case 'i':
case 'u':
case 's':
case 'list':
case 'quote':
case 'code':
case 'center':
case 'left':
case 'right':
tags.add(raw);
break;
case '*':
spans.add(const TextSpan(text: '\n \u2022 '));
break;
default:
if (raw.startsWith('color=') || raw.startsWith('size=')) {
tags.add(raw);
} else if (raw.startsWith('url=')) {
final url = _urlRe.firstMatch(raw)?.group(1) ?? '';
final closeIdx = _findCloseUrl(src, i);
String inner;
if (closeIdx != null) {
inner = src.substring(i, closeIdx);
i = closeIdx + 6;
} else {
inner = url;
}
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url.isNotEmpty ? url : inner,
linkTrust: linkTrust,
child: Text(
inner,
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
));
} else if (raw.startsWith('img=')) {
final src2 = _imgRe.firstMatch(raw)?.group(1) ?? '';
if (src2.isNotEmpty) {
spans.add(WidgetSpan(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
Uri.tryParse(src2)?.toString() ?? src2,
fit: BoxFit.scaleDown,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
));
}
} else if (raw == 'url') {
final closeIdx = _findCloseUrl(src, i);
if (closeIdx != null) {
final url = src.substring(i, closeIdx).trim();
i = closeIdx + 6;
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url,
linkTrust: linkTrust,
child: Text(
url,
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
));
}
}
break;
}
}
flush(buf);
if (spans.isEmpty) return Text(src);
return Text.rich(TextSpan(children: spans));
}
}
class _LinkTap extends StatefulWidget {
const _LinkTap({
required this.url,
required this.child,
required this.linkTrust,
});
final String url;
final Widget child;
final LinkTrustService linkTrust;
@override
State<_LinkTap> createState() => _LinkTapState();
}
class _LinkTapState extends State<_LinkTap> {
@override
void initState() {
super.initState();
widget.linkTrust.addListener(_onChanged);
}
@override
void dispose() {
widget.linkTrust.removeListener(_onChanged);
super.dispose();
}
void _onChanged() => mounted ? setState(() {}) : null;
Future<void> _open() async {
final uri = Uri.tryParse(widget.url);
if (uri == null) return;
final host = uri.host;
if (host.isEmpty) return;
if (widget.linkTrust.isTrusted(host)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
return;
}
if (!mounted) return;
final trust = await showLinkTrustDialog(context, host);
if (trust == null) return;
if (trust) {
await widget.linkTrust.addTrustedDomain(host);
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _open,
child: widget.child,
);
}
}
@@ -40,6 +40,8 @@ class VoiceStatusChip extends StatelessWidget {
required this.audioStats,
required this.isTouchOnly,
required this.onTap,
this.inputMuted = false,
this.outputMuted = false,
});
/// Current transmit mode.
@@ -57,6 +59,12 @@ class VoiceStatusChip extends StatelessWidget {
/// True on iOS / iPadOS / Android.
final bool isTouchOnly;
/// True when local mic is muted (hard mute or permission mute).
final bool inputMuted;
/// True when local speaker is muted.
final bool outputMuted;
/// Open the voice details modal.
final VoidCallback onTap;
@@ -95,9 +103,15 @@ class VoiceStatusChip extends StatelessWidget {
final tailText = transmitMode == rust.BridgeTransmitMode.ptt
? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}'
: null;
final micText = micOn ? l10n.voiceMicOn : l10n.voiceMicOff;
final micText = inputMuted
? '${l10n.voiceMicOff} (muted)'
: outputMuted
? 'Speaker muted'
: (micOn ? l10n.voiceMicOn : l10n.voiceMicOff);
final line2 = tailText == null ? micText : '$tailText \u00b7 $micText';
final muted = inputMuted || outputMuted;
return Semantics(
button: true,
label: '${l10n.voiceSheetTitle}: $line1, $line2',
@@ -105,17 +119,24 @@ class VoiceStatusChip extends StatelessWidget {
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
onTap: () {
HapticFeedback.lightImpact();
onTap();
},
borderRadius: BorderRadius.circular(12),
child: ExcludeSemantics(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
color: muted
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outlineVariant,
width: 0.5,
color: muted
? theme.colorScheme.error
: theme.colorScheme.outlineVariant,
width: muted ? 1.5 : 0.5,
),
),
child: Row(
@@ -394,6 +415,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
late bool _aecEnabled;
late bool _agcEnabled;
late bool _hpfEnabled;
late bool _preferHardware;
late rust.BridgeVadBackend _vadBackend;
@override
@@ -404,6 +426,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
_aecEnabled = c.aec != rust.BridgeEffectOwner.off;
_agcEnabled = c.agc != rust.BridgeEffectOwner.off;
_hpfEnabled = c.hpfEnabled;
_preferHardware = c.aec == rust.BridgeEffectOwner.platform;
_vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: c.vadBackend;
@@ -440,23 +463,51 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
final c = widget.initialAudioConfig;
final isSonora =
c.iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
// VPIO owns enabled effects on the default path. Sonora owns them only in
// the experimental raw path.
final isAndroid = Platform.isAndroid;
if (isAndroid) {
final owner = _preferHardware
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.webrtcApm;
return rust.BridgeAudioProcessingConfig(
route: c.route,
iosMode: c.iosMode,
processingBackend: _preferHardware
? rust.BridgeAudioBackend.platformVoiceProcessing
: rust.BridgeAudioBackend.webrtcApm,
vadBackend: _vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: _vadBackend,
aec: _aecEnabled ? owner : rust.BridgeEffectOwner.off,
ns: _nsEnabled ? owner : rust.BridgeEffectOwner.off,
agc: _agcEnabled ? owner : rust.BridgeEffectOwner.off,
hpfEnabled: _hpfEnabled,
limiterEnabled: c.limiterEnabled,
vadHangoverMs: c.vadHangoverMs,
vadPreRollMs: c.vadPreRollMs,
vadMinTxMs: c.vadMinTxMs,
debugWavDumpEnabled: c.debugWavDumpEnabled,
);
}
// iOS / macOS: VPIO vs Sonora paths.
// VPIO owns enabled effects on the default path. The experimental raw path
// delegates app-side processing to WebRTC APM.
final aecOwner = isSonora
? (_aecEnabled
? rust.BridgeEffectOwner.sonora
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: rust.BridgeEffectOwner.platform;
final nsOwner = isSonora
? (_nsEnabled
? rust.BridgeEffectOwner.sonora
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (_nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final agcOwner = isSonora
? (_agcEnabled
? rust.BridgeEffectOwner.sonora
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (_agcEnabled
? rust.BridgeEffectOwner.platform
@@ -631,6 +682,22 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
),
),
const SizedBox(height: 4),
// Android: hardware (JNI) vs software (WebRTC APM)
if (Platform.isAndroid) ...[
_AudioToggleRow(
label: 'Prefer hardware effects',
subtitle: _preferHardware
? 'Try JNI hardware · software fallback'
: 'Software WebRTC AEC3 · NS · AGC2',
value: _preferHardware,
onChanged: (v) {
setState(() => _preferHardware = v);
_notifyAudioConfig();
},
),
],
_AudioToggleRow(
label: 'Noise suppression',
subtitle: 'Wiener filter',
@@ -642,25 +709,40 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
),
_AudioToggleRow(
label: 'Echo cancellation',
subtitle:
widget.initialAudioConfig.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
? 'Always on · managed by platform VPIO'
: 'AEC3 adaptive filter · 80 ms tail',
subtitle: () {
if (Platform.isAndroid) {
return 'WebRTC AEC3 · adaptive filter';
}
return widget.initialAudioConfig.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
? 'Always on · managed by platform VPIO'
: 'AEC3 adaptive filter · 80 ms tail';
}(),
value:
widget.initialAudioConfig.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
? true // always on in VPIO
: _aecEnabled,
() {
if (Platform.isAndroid) return _aecEnabled;
return widget.initialAudioConfig.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
? true
: _aecEnabled;
}(),
// AEC is always on in VPIO — disable the toggle.
onChanged:
widget.initialAudioConfig.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
? null
: (v) {
setState(() => _aecEnabled = v);
_notifyAudioConfig();
},
// On Android, AEC is user-selectable.
onChanged: () {
if (Platform.isAndroid) {
return (v) {
setState(() => _aecEnabled = v);
_notifyAudioConfig();
};
}
return widget.initialAudioConfig.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
? null
: (v) {
setState(() => _aecEnabled = v);
_notifyAudioConfig();
};
}(),
),
_AudioToggleRow(
label: 'Auto gain control',
@@ -18,6 +18,11 @@ import '../l10n/generated/app_localizations.dart';
import 'voice_platform.dart';
import '../src/rust/api.dart' as rust;
bool get _isAndroid {
if (kIsWeb) return false;
return Platform.isAndroid;
}
bool get _isIos {
if (kIsWeb) return false;
return Platform.isIOS;
@@ -68,6 +73,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
late rust.BridgeVadBackend _vadBackend;
late rust.BridgeIosVoiceProcessingMode _iosMode;
late bool _debugWavDump;
late bool _preferHardware; // Android only: try JNI hardware effects
@override
void initState() {
@@ -86,29 +92,59 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
: c.vadBackend;
_iosMode = c.iosMode;
_debugWavDump = c.debugWavDumpEnabled;
_preferHardware = c.aec == rust.BridgeEffectOwner.platform
|| c.ns == rust.BridgeEffectOwner.platform
|| c.agc == rust.BridgeEffectOwner.platform;
}
rust.BridgeAudioProcessingConfig _buildConfig() {
final c = widget.initialAudioConfig;
final isSonora =
_iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
// In VPIO mode, enabled effects are platform-owned. Sonora ownership is
// reserved for the experimental raw path so config validation stays honest.
if (_isAndroid) {
final owner = _preferHardware
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.webrtcApm;
return rust.BridgeAudioProcessingConfig(
route: c.route,
iosMode: _iosMode,
processingBackend: _preferHardware
? rust.BridgeAudioBackend.platformVoiceProcessing
: rust.BridgeAudioBackend.webrtcApm,
vadBackend: _vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: _vadBackend,
aec: _aecEnabled ? owner : rust.BridgeEffectOwner.off,
ns: _nsEnabled ? owner : rust.BridgeEffectOwner.off,
agc: _agcEnabled ? owner : rust.BridgeEffectOwner.off,
hpfEnabled: _hpfEnabled,
limiterEnabled: _limiterEnabled,
vadHangoverMs: c.vadHangoverMs,
vadPreRollMs: c.vadPreRollMs,
vadMinTxMs: c.vadMinTxMs,
debugWavDumpEnabled: _debugWavDump,
);
}
// iOS / macOS: VPIO vs Sonora paths.
// In VPIO mode, enabled effects are platform-owned. The experimental raw
// path uses WebRTC APM ownership so config validation stays honest.
final aecOwner = isSonora
? (_aecEnabled
? rust.BridgeEffectOwner.sonora
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: rust.BridgeEffectOwner.platform; // VPIO always owns AEC
final nsOwner = isSonora
? (_nsEnabled
? rust.BridgeEffectOwner.sonora
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (_nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final agcOwner = isSonora
? (_agcEnabled
? rust.BridgeEffectOwner.sonora
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (_agcEnabled
? rust.BridgeEffectOwner.platform
@@ -120,7 +156,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
route: c.route,
iosMode: _iosMode,
processingBackend: isSonora
? rust.BridgeAudioBackend.sonora
? rust.BridgeAudioBackend.webrtcApm
: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: vadBackend,
aec: aecOwner,
@@ -244,6 +280,30 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
const SizedBox(height: 4),
],
// Android HW/SW selector
if (_isAndroid) ...[
_subHeader(theme, 'Processing backend'),
_radioTile<bool>(
value: true,
groupValue: _preferHardware,
title: const Text('Platform (auto)'),
subtitle: _tileSubtitle(
'Try hardware JNI effects · software fallback',
),
onSelected: (v) => setState(() => _preferHardware = v),
),
_radioTile<bool>(
value: false,
groupValue: _preferHardware,
title: const Text('WebRTC APM'),
subtitle: _tileSubtitle(
'Software AEC3 · NS · AGC2',
),
onSelected: (v) => setState(() => _preferHardware = v),
),
const SizedBox(height: 4),
],
// DSP toggles
_subHeader(theme, 'DSP stages'),
_switchTile(
@@ -254,12 +314,14 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
),
_switchTile(
title: 'Echo cancellation (AEC3)',
subtitle: platformVpio
? 'Managed by platform VPIO'
: 'Adaptive NLMS · 80 ms tail',
subtitle: _isAndroid
? 'WebRTC AEC3 · adaptive filter'
: platformVpio
? 'Managed by platform VPIO'
: 'Adaptive NLMS · 80 ms tail',
value: _aecEnabled,
// AEC is always on in VPIO mode — disable the toggle.
onSelected: platformVpio ? null : (v) => _aecEnabled = v,
onSelected: (_isAndroid || !platformVpio) ? (v) => _aecEnabled = v : null,
),
_switchTile(
title: 'Auto gain control (AGC2)',