feat(voice): add iOS VAD runtime support

This commit is contained in:
Edison Jwa
2026-05-21 20:51:45 +09:00
parent 171baf6e41
commit 6af4ecab0f
73 changed files with 11529 additions and 1249 deletions
@@ -0,0 +1,209 @@
// P1 debug stats overlay widget.
//
// Shows a compact, auto-refreshing panel with the key audio processing
// metrics from [BridgeAudioProcessingStats]. Intended for internal
// debug builds only — wrap with a kDebugMode guard at the call site.
//
// Usage:
// if (kDebugMode) const AudioDebugStatsPanel(),
import 'dart:async';
import 'package:flutter/material.dart';
import '../src/rust/api.dart';
/// Compact debug panel that polls [audioProcessingStats] every 500 ms
/// and renders the key metrics in a monospace overlay.
///
/// Designed to be placed in a [Stack] over the main UI during
/// development. It is transparent to hit-testing so it does not
/// interfere with taps.
class AudioDebugStatsPanel extends StatefulWidget {
const AudioDebugStatsPanel({super.key});
@override
State<AudioDebugStatsPanel> createState() => _AudioDebugStatsPanelState();
}
class _AudioDebugStatsPanelState extends State<AudioDebugStatsPanel> {
BridgeAudioProcessingStats? _stats;
Timer? _timer;
String? _error;
@override
void initState() {
super.initState();
_poll();
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _poll());
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Future<void> _poll() async {
try {
final stats = await audioProcessingStats();
if (mounted) {
setState(() {
_stats = stats;
_error = null;
});
}
} catch (e) {
if (mounted) {
setState(() => _error = e.toString());
}
}
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Align(
alignment: Alignment.topRight,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: _buildPanel(),
),
),
),
);
}
Widget _buildPanel() {
if (_error != null) {
return _PanelBox(
child: Text(
'audio stats error:\n$_error',
style: _monoStyle(Colors.red),
),
);
}
final s = _stats;
if (s == null) {
return _PanelBox(
child: Text('audio stats: loading…', style: _monoStyle(Colors.grey)),
);
}
final vadColor = s.vadActive ? Colors.greenAccent : Colors.grey;
final txColor = s.transmitting ? Colors.redAccent : Colors.grey;
final xruns = s.callbackXruns + s.inputOverruns + s.outputUnderruns;
return _PanelBox(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_row('route', _routeLabel(s.audioRoute), Colors.white),
_row('backend', _backendLabel(s.processingBackend), Colors.white),
_row(
'vpio',
s.platformVoiceProcessingEnabled ? 'on' : 'off',
Colors.white,
),
_row('sonora', s.sonoraEnabled ? 'on' : 'off', Colors.white),
const SizedBox(height: 4),
_row(
'mic in',
'${s.inputDbfs.toStringAsFixed(1)} dBFS',
Colors.white,
),
_row(
'mic out',
'${s.processedDbfs.toStringAsFixed(1)} dBFS',
Colors.white,
),
_row(
'render',
'${s.renderDbfs.toStringAsFixed(1)} dBFS',
Colors.white,
),
const SizedBox(height: 4),
_row(
'vad',
'${(s.vadProbability * 100).toStringAsFixed(0)}% '
'${s.vadActive ? "OPEN" : "closed"}',
vadColor,
),
_row('vad backend', _vadBackendLabel(s.vadBackend), Colors.white),
if (s.vadFallbackActive)
_row('vad fallback', 'ACTIVE', Colors.orange),
const SizedBox(height: 4),
_row('tx', s.transmitting ? 'TRANSMITTING' : 'idle', txColor),
_row('sr', '${s.actualSampleRateHz} Hz', Colors.white),
_row('buf', '${s.actualIoBufferFrames} frames', Colors.white),
if (xruns > BigInt.zero)
_row('xruns', xruns.toString(), Colors.orange),
if (s.clippedSamples > BigInt.zero)
_row('clipped', s.clippedSamples.toString(), Colors.orange),
],
),
);
}
Widget _row(String label, String value, Color valueColor) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('$label: ', style: _monoStyle(Colors.grey.shade400)),
Text(value, style: _monoStyle(valueColor)),
],
);
}
TextStyle _monoStyle(Color color) => TextStyle(
fontFamily: 'monospace',
fontSize: 10,
color: color,
height: 1.4,
);
String _routeLabel(BridgeAudioRoute route) => switch (route) {
BridgeAudioRoute.speaker => 'speaker',
BridgeAudioRoute.earpiece => 'earpiece',
BridgeAudioRoute.wiredHeadset => 'wired',
BridgeAudioRoute.bluetoothHfp => 'bt-hfp',
BridgeAudioRoute.bluetoothA2Dp => 'bt-a2dp',
BridgeAudioRoute.unknown => 'unknown',
};
String _backendLabel(BridgeAudioBackend backend) => switch (backend) {
BridgeAudioBackend.platformVoiceProcessing => 'vpio',
BridgeAudioBackend.sonora => 'sonora',
BridgeAudioBackend.noop => 'noop',
BridgeAudioBackend.webrtcApm => 'webrtc-apm',
};
String _vadBackendLabel(BridgeVadBackend backend) => switch (backend) {
BridgeVadBackend.webrtcVad => 'webrtc',
BridgeVadBackend.sileroOnnx => 'silero',
BridgeVadBackend.tenVad => 'ten',
BridgeVadBackend.energyDebug => 'energy',
BridgeVadBackend.disabled => 'off',
};
}
/// Semi-transparent dark box for the debug panel.
class _PanelBox extends StatelessWidget {
const _PanelBox({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(6),
),
child: child,
);
}
}
@@ -1,4 +1,4 @@
/// SRS-209 listen-only banner for Android RECORD_AUDIO permission.
/// SRS-209 listen-only banner for mobile microphone permission.
///
/// Trace:
/// - SDD-106 §2 (denial UX — non-blocking affordance "Enable
@@ -6,17 +6,19 @@
/// - SRS-209 (path to grant; listen-only fallback).
///
/// Behaviour:
/// * Watches [AndroidPermissionsService.recordAudioState].
/// * Watches the injected microphone permission state listenable.
/// * On `denied`: renders a non-modal banner with a "Grant" action.
/// * On `permanentlyDenied`: action text becomes "Open Settings" and
/// invokes [AndroidPermissionsService.openAppSettings].
/// * On `granted` / `unknown`: builds an empty [SizedBox.shrink].
/// * On non-Android hosts the service stays at `granted`, so this
/// widget is effectively invisible without any extra branching.
/// * On platforms whose service stays at `granted`, this widget is
/// effectively invisible without any extra branching.
library;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/android_permissions_service.dart';
/// Listen-only banner widget. Drop this above the `VoiceBar` in the
@@ -24,43 +26,69 @@ import '../services/android_permissions_service.dart';
///
/// Trace: SDD-106 §2, §3; SRS-209.
class PermissionStateBanner extends StatelessWidget {
const PermissionStateBanner({super.key, required this.service});
PermissionStateBanner({super.key, required AndroidPermissionsService service})
: recordAudioState = service.recordAudioState,
ensureRecordAudio = service.ensureRecordAudio,
openAppSettings = service.openAppSettings;
/// Permissions service whose [AndroidPermissionsService.recordAudioState]
/// drives the banner.
final AndroidPermissionsService service;
const PermissionStateBanner.fromCallbacks({
super.key,
required this.recordAudioState,
required this.ensureRecordAudio,
required this.openAppSettings,
});
final ValueListenable<AndroidRecordAudioPermissionState> recordAudioState;
final Future<AndroidRecordAudioPermissionState> Function() ensureRecordAudio;
final Future<void> Function() openAppSettings;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<AndroidRecordAudioPermissionState>(
valueListenable: service.recordAudioState,
valueListenable: recordAudioState,
builder: (ctx, state, _) {
switch (state) {
case AndroidRecordAudioPermissionState.granted:
case AndroidRecordAudioPermissionState.unknown:
return const SizedBox.shrink();
case AndroidRecordAudioPermissionState.denied:
return _BannerBody(
// TODO(localization): route through AppL10n once an arb
// entry exists. SRS-209 requires the message; the
// English literal is a placeholder.
message:
'Microphone permission required for voice transmission.',
actionLabel: 'Grant',
onPressed: () => service.ensureRecordAudio(),
);
case AndroidRecordAudioPermissionState.permanentlyDenied:
return _BannerBody(
// TODO(localization): see above.
message:
'Microphone permission required for voice transmission.',
actionLabel: 'Open Settings',
onPressed: () => service.openAppSettings(),
);
final l10n = AppL10n.of(ctx);
final action = _actionFor(state, l10n);
if (action == null) {
return const SizedBox.shrink();
}
return _BannerBody(
message: l10n.microphonePermissionRequiredForVoice,
actionLabel: action.label,
onPressed: action.onPressed,
);
},
);
}
_BannerAction? _actionFor(
AndroidRecordAudioPermissionState state,
AppL10n l10n,
) {
switch (state) {
case AndroidRecordAudioPermissionState.granted:
case AndroidRecordAudioPermissionState.unknown:
return null;
case AndroidRecordAudioPermissionState.denied:
return _BannerAction(
label: l10n.permissionGrantAction,
onPressed: () => ensureRecordAudio(),
);
case AndroidRecordAudioPermissionState.permanentlyDenied:
return _BannerAction(
label: l10n.networkPermissionOpenSettings,
onPressed: () => openAppSettings(),
);
}
}
}
class _BannerAction {
const _BannerAction({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
}
class _BannerBody extends StatelessWidget {
@@ -0,0 +1,141 @@
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform;
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
/// PTT capability badge (gen2 v0.9.3 / SDD-091).
///
/// Renders the active PTT level + backend in the Voice Bar so the
/// user understands which input path is in effect. When the
/// resolved capability is `L0Focused` an info icon appears that
/// opens a per-platform explanation sheet describing why Global
/// PTT is not active and what the user can do to engage it.
class PttCapabilityBadge extends StatelessWidget {
/// Construct a badge.
const PttCapabilityBadge({
super.key,
required this.level,
required this.backendId,
required this.boundInputClass,
});
/// Resolved capability level as the bridge emits it
/// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
/// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
final String level;
/// Stable backend identifier (`focused`, `windows-raw-input`, …).
final String backendId;
/// Privacy-safe input class (`keyboard`, `mouse-side-button`,
/// or empty when no binding is set).
final String boundInputClass;
bool get _isFocused => level == 'L0Focused';
String _explainBodyForPlatform(AppL10n l10n) {
switch (defaultTargetPlatform) {
case TargetPlatform.windows:
return l10n.pttCapabilityExplainGoGlobalWindows;
case TargetPlatform.macOS:
return l10n.pttCapabilityExplainGoGlobalMacos;
case TargetPlatform.linux:
return l10n.pttCapabilityExplainGoGlobalLinux;
case TargetPlatform.iOS:
return l10n.pttCapabilityExplainGoGlobalIos;
default:
return l10n.pttCapabilityExplainGoGlobalGeneric;
}
}
void _openExplanationSheet(BuildContext context) {
final l10n = AppL10n.of(context);
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pttCapabilityExplainTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 12),
Text(
l10n.pttCapabilityExplainFocusedHeading,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
l10n.pttCapabilityExplainFocusedBody,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
_explainBodyForPlatform(l10n),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(l10n.closeAction),
),
),
],
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
final tooltipMessage = boundInputClass.isEmpty
? badgeLabel
: '$badgeLabel\n($boundInputClass)';
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Tooltip(
message: tooltipMessage,
child: Row(
children: [
Icon(
_isFocused ? Icons.crop_free : Icons.public,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
badgeLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (_isFocused)
IconButton(
icon: const Icon(Icons.info_outline, size: 16),
tooltip: l10n.pttCapabilityExplainTitle,
visualDensity: VisualDensity.compact,
onPressed: () => _openExplanationSheet(context),
),
],
),
),
);
}
}
+75 -61
View File
@@ -3,24 +3,16 @@
// `BridgeEvent::VoiceState` stream the bridge publishes from the
// core's transmit-mode selector + release-tail timer.
import 'dart:io' show Platform;
import 'dart:async' show unawaited;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:haptic_kit/haptic_kit.dart';
import '../l10n/generated/app_localizations.dart';
import '../main.dart' show PttCapabilityBadge;
import 'ptt_capability_badge.dart';
import 'voice_platform.dart';
import '../src/rust/api.dart' as rust;
/// True when the host is a mobile platform without a hardware
/// keyboard the user would bind a PTT key on. iOS / iPadOS /
/// Android fall here. macOS / Linux / Windows / Web fall on the
/// hardware-key path.
bool get _isTouchOnlyPttHost {
if (kIsWeb) return false;
return Platform.isIOS || Platform.isAndroid;
}
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
/// toggle, level meter, and a leave-channel affordance.
class VoiceBar extends StatelessWidget {
@@ -113,7 +105,7 @@ class VoiceBar extends StatelessWidget {
case rust.BridgeTransmitMode.continuous:
return l10n.voiceModeContinuous;
case rust.BridgeTransmitMode.voiceActivity:
return '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})';
return l10n.voiceModeVoiceActivity;
}
}
@@ -237,7 +229,7 @@ class VoiceBar extends StatelessWidget {
// pinned to the bottom of a narrow-layout screen. The
// release-tail value is folded into the small print
// under the button rather than shown here.
if (isPtt && !_isTouchOnlyPttHost)
if (isPtt && !isTouchOnlyPttHost)
Padding(
padding: const EdgeInsets.only(left: 22, top: 2),
child: Text(
@@ -288,7 +280,7 @@ class VoiceBar extends StatelessWidget {
// the bottom of a narrow-layout screen. The release-
// tail value sits above the button so the user sees
// how long their voice continues after they let go.
if (isPtt && _isTouchOnlyPttHost) ...[
if (isPtt && isTouchOnlyPttHost) ...[
const SizedBox(height: 4),
Center(
child: Text(
@@ -380,10 +372,24 @@ class _PttHoldButton extends StatefulWidget {
class _PttHoldButtonState extends State<_PttHoldButton> {
bool _pressed = false;
@override
void initState() {
super.initState();
unawaited(Haptics.prepare().catchError((_) => false));
}
void _setHeld(bool held) {
if (_pressed == held) return;
setState(() => _pressed = held);
widget.onHeldChanged(held);
_playPressHaptic(held);
}
void _playPressHaptic(bool held) {
final haptic = held
? Haptics.impact(HapticImpactStyle.medium)
: Haptics.selection();
unawaited(haptic.catchError((_) {}));
}
@override
@@ -392,54 +398,62 @@ class _PttHoldButtonState extends State<_PttHoldButton> {
final activeNow = _pressed || widget.active;
final l10n = AppL10n.of(context);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => _setHeld(true),
onTapUp: (_) => _setHeld(false),
onTapCancel: () => _setHeld(false),
onPanDown: (_) => _setHeld(true),
onPanEnd: (_) => _setHeld(false),
onPanCancel: () => _setHeld(false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 80),
height: 64,
decoration: BoxDecoration(
color: activeNow
? theme.colorScheme.primary
: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
boxShadow: activeNow
? [
BoxShadow(
color: theme.colorScheme.primary.withAlpha(100),
blurRadius: 12,
offset: const Offset(0, 2),
return Semantics(
button: true,
liveRegion: true,
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
hint: l10n.pttHoldToTalkSemanticsHint,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => _setHeld(true),
onTapUp: (_) => _setHeld(false),
onTapCancel: () => _setHeld(false),
onPanDown: (_) => _setHeld(true),
onPanEnd: (_) => _setHeld(false),
onPanCancel: () => _setHeld(false),
child: ExcludeSemantics(
child: AnimatedContainer(
duration: const Duration(milliseconds: 80),
height: 64,
decoration: BoxDecoration(
color: activeNow
? theme.colorScheme.primary
: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
boxShadow: activeNow
? [
BoxShadow(
color: theme.colorScheme.primary.withAlpha(100),
blurRadius: 12,
offset: const Offset(0, 2),
),
]
: null,
),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
activeNow ? Icons.mic : Icons.mic_none,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onPrimaryContainer,
size: 24,
),
]
: null,
),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
activeNow ? Icons.mic : Icons.mic_none,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onPrimaryContainer,
size: 24,
const SizedBox(width: 10),
Text(
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onPrimaryContainer,
),
),
],
),
const SizedBox(width: 10),
Text(
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onPrimaryContainer,
),
),
],
),
),
),
),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
/// True when the host is a touch-only mobile platform without a
/// hardware keyboard the user would bind a PTT key on.
bool get isTouchOnlyPttHost {
if (kIsWeb) return false;
return Platform.isIOS || Platform.isAndroid;
}
@@ -1,5 +1,13 @@
// Voice settings dialog (SDD-097). Surfaces a TransmitMode radio
// group, a bind-key button, and a release-tail slider.
// Voice settings dialog (SDD-097). Surfaces transmit mode, release
// tail, and the full P1 audio processing configuration:
// - Noise suppression (NS)
// - Echo cancellation (AEC3)
// - Automatic gain control (AGC2)
// - High-pass filter (HPF)
// - VAD backend
// - iOS voice processing mode
// ignore_for_file: deprecated_member_use
import 'dart:io' show Platform;
@@ -7,52 +15,41 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import 'voice_platform.dart';
import '../src/rust/api.dart' as rust;
/// True when the host is a touch-only mobile platform without a
/// hardware keyboard the user would bind a PTT key on. Mirrors the
/// helper in `voice_bar.dart`.
bool get _isTouchOnlyPttHost {
bool get _isIos {
if (kIsWeb) return false;
return Platform.isIOS || Platform.isAndroid;
return Platform.isIOS;
}
/// Result returned by [`VoiceSettingsDialog`]. `null` indicates a
/// cancelled dialog.
/// Result returned by [VoiceSettingsDialog].
class VoiceSettingsResult {
/// Construct a result snapshot.
const VoiceSettingsResult({
required this.mode,
required this.releaseTailMs,
required this.bindKeyRequested,
required this.audioConfig,
});
/// Selected transmit mode.
final rust.BridgeTransmitMode mode;
/// Chosen release-tail in milliseconds (0..=500, step 25).
final int releaseTailMs;
/// True when the user tapped the "bind key" button. The caller
/// is expected to open the focus-scoped capture dialog
/// afterwards.
final bool bindKeyRequested;
final rust.BridgeAudioProcessingConfig audioConfig;
}
/// Voice settings dialog widget.
/// Voice + audio processing settings dialog.
class VoiceSettingsDialog extends StatefulWidget {
/// Construct a dialog seeded with the current settings.
const VoiceSettingsDialog({
super.key,
required this.initialMode,
required this.initialReleaseTailMs,
required this.initialAudioConfig,
});
/// Currently active transmit mode.
final rust.BridgeTransmitMode initialMode;
/// Currently configured release tail in milliseconds.
final int initialReleaseTailMs;
final rust.BridgeAudioProcessingConfig initialAudioConfig;
@override
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
@@ -62,115 +59,273 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
late rust.BridgeTransmitMode _mode;
late double _releaseTail;
// Audio processing state — mirrors BridgeAudioProcessingConfig fields.
late bool _nsEnabled;
late bool _aecEnabled;
late bool _agcEnabled;
late bool _hpfEnabled;
late bool _limiterEnabled;
late rust.BridgeVadBackend _vadBackend;
late rust.BridgeIosVoiceProcessingMode _iosMode;
late bool _debugWavDump;
@override
void initState() {
super.initState();
_mode = widget.initialMode;
_releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble();
final c = widget.initialAudioConfig;
_nsEnabled = c.ns != rust.BridgeEffectOwner.off;
_aecEnabled = c.aec != rust.BridgeEffectOwner.off;
_agcEnabled = c.agc != rust.BridgeEffectOwner.off;
_hpfEnabled = c.hpfEnabled;
_limiterEnabled = c.limiterEnabled;
_vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: c.vadBackend;
_iosMode = c.iosMode;
_debugWavDump = c.debugWavDumpEnabled;
}
rust.BridgeAudioProcessingConfig _buildConfig() {
final c = widget.initialAudioConfig;
final isSonora =
_iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
// In VPIO mode, enabled effects are platform-owned. Sonora ownership is
// reserved for the experimental raw path so config validation stays honest.
final aecOwner = isSonora
? (_aecEnabled
? rust.BridgeEffectOwner.sonora
: rust.BridgeEffectOwner.off)
: rust.BridgeEffectOwner.platform; // VPIO always owns AEC
final nsOwner = isSonora
? (_nsEnabled
? rust.BridgeEffectOwner.sonora
: rust.BridgeEffectOwner.off)
: (_nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final agcOwner = isSonora
? (_agcEnabled
? rust.BridgeEffectOwner.sonora
: rust.BridgeEffectOwner.off)
: (_agcEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final vadBackend = _vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: _vadBackend;
return rust.BridgeAudioProcessingConfig(
route: c.route,
iosMode: _iosMode,
processingBackend: isSonora
? rust.BridgeAudioBackend.sonora
: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: vadBackend,
aec: aecOwner,
ns: nsOwner,
agc: agcOwner,
hpfEnabled: _hpfEnabled,
limiterEnabled: _limiterEnabled,
vadHangoverMs: c.vadHangoverMs,
vadPreRollMs: c.vadPreRollMs,
vadMinTxMs: c.vadMinTxMs,
debugWavDumpEnabled: _debugWavDump,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final platformVpio =
_iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
return AlertDialog(
title: Text(l10n.voiceSettingsTitle),
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.voiceModeLabel,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
RadioListTile<rust.BridgeTransmitMode>(
dense: true,
value: rust.BridgeTransmitMode.ptt,
groupValue: _mode,
title: Text(l10n.voiceModePtt),
onChanged: (v) => setState(() => _mode = v!),
),
RadioListTile<rust.BridgeTransmitMode>(
dense: true,
value: rust.BridgeTransmitMode.continuous,
groupValue: _mode,
title: Text(l10n.voiceModeContinuous),
onChanged: (v) => setState(() => _mode = v!),
),
RadioListTile<rust.BridgeTransmitMode>(
dense: true,
value: rust.BridgeTransmitMode.voiceActivity,
groupValue: _mode,
title: Text(l10n.voiceModeVoiceActivity),
secondary: Text(
l10n.voiceModeComingSoon,
style: theme.textTheme.bodySmall,
width: 400,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Transmit mode ──────────────────────────────────────
_sectionHeader(theme, l10n.voiceModeLabel),
_radioTile<rust.BridgeTransmitMode>(
value: rust.BridgeTransmitMode.ptt,
groupValue: _mode,
title: Text(l10n.voiceModePtt),
onSelected: (v) => _mode = v,
),
// VoiceActivity is reserved per DEC-030 — keep the
// tile visible but disabled per SDD-095.
onChanged: null,
),
const Divider(),
// Bind-key + release-tail are PTT-only concepts. Hide
// them entirely when the user has switched to a
// non-PTT mode so the dialog stays focused on what's
// actually configurable for that mode.
//
// Additionally on touch-only mobile hosts (iOS / iPadOS
// / Android) there is no hardware keyboard to bind a
// key on — the VoiceBar renders an on-screen Push to
// Talk button instead. Hide the Bind Key affordance
// there but keep the release-tail slider since it
// still applies to the on-screen button's behaviour.
if (_mode == rust.BridgeTransmitMode.ptt) ...[
if (!_isTouchOnlyPttHost) ...[
OutlinedButton.icon(
icon: const Icon(Icons.keyboard),
label: Text(l10n.voiceBindKeyAction),
onPressed: () {
Navigator.of(context).pop(
_radioTile<rust.BridgeTransmitMode>(
value: rust.BridgeTransmitMode.continuous,
groupValue: _mode,
title: Text(l10n.voiceModeContinuous),
onSelected: (v) => _mode = v,
),
_radioTile<rust.BridgeTransmitMode>(
value: rust.BridgeTransmitMode.voiceActivity,
groupValue: _mode,
title: Text(l10n.voiceModeVoiceActivity),
onSelected: (v) => _mode = v,
),
// ── PTT options ────────────────────────────────────────
if (_mode == rust.BridgeTransmitMode.ptt) ...[
const Divider(height: 24),
if (!isTouchOnlyPttHost) ...[
OutlinedButton.icon(
icon: const Icon(Icons.keyboard),
label: Text(l10n.voiceBindKeyAction),
onPressed: () => Navigator.of(context).pop(
VoiceSettingsResult(
mode: _mode,
releaseTailMs: _releaseTail.round(),
bindKeyRequested: true,
audioConfig: _buildConfig(),
),
);
},
),
const SizedBox(height: 8),
],
Text(
l10n.voiceReleaseTailLabel,
style: theme.textTheme.titleSmall,
),
Row(
children: [
Expanded(
child: Slider(
value: _releaseTail,
min: 0,
max: 500,
divisions: 20, // step 25 ms
label:
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
onChanged: (v) => setState(() => _releaseTail = v),
),
),
SizedBox(
width: 64,
child: Text(
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.end,
),
),
const SizedBox(height: 8),
],
Text(
l10n.voiceReleaseTailLabel,
style: theme.textTheme.titleSmall,
),
Row(
children: [
Expanded(
child: Slider(
value: _releaseTail,
min: 0,
max: 500,
divisions: 20,
label:
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
onChanged: (v) => setState(() => _releaseTail = v),
),
),
SizedBox(
width: 64,
child: Text(
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.end,
),
),
],
),
],
// ── Audio processing ───────────────────────────────────
const Divider(height: 24),
_sectionHeader(theme, 'Audio processing'),
// iOS mode selector (iOS only)
if (_isIos) ...[
_subHeader(theme, 'Processing backend'),
_radioTile<rust.BridgeIosVoiceProcessingMode>(
value:
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
groupValue: _iosMode,
title: const Text('Platform (VPIO)'),
subtitle: _tileSubtitle('Apple AEC · NS · AGC'),
onSelected: (v) => _iosMode = v,
),
_radioTile<rust.BridgeIosVoiceProcessingMode>(
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
groupValue: _iosMode,
title: const Text('Sonora (experimental)'),
subtitle: _tileSubtitle('Rust AEC3 · NS · AGC2'),
onSelected: (v) => _iosMode = v,
),
const SizedBox(height: 4),
],
// DSP toggles
_subHeader(theme, 'DSP stages'),
_switchTile(
title: 'Noise suppression (NS)',
subtitle: 'Wiener filter · stationary noise',
value: _nsEnabled,
onSelected: (v) => _nsEnabled = v,
),
_switchTile(
title: 'Echo cancellation (AEC3)',
subtitle: platformVpio
? 'Managed by platform VPIO'
: 'Adaptive NLMS · 80 ms tail',
value: _aecEnabled,
// AEC is always on in VPIO mode — disable the toggle.
onSelected: platformVpio ? null : (v) => _aecEnabled = v,
),
_switchTile(
title: 'Auto gain control (AGC2)',
subtitle: 'RNN VAD-gated · 18 dBFS target',
value: _agcEnabled,
onSelected: (v) => _agcEnabled = v,
),
_switchTile(
title: 'High-pass filter (HPF)',
subtitle: '80 Hz Butterworth · DC removal',
value: _hpfEnabled,
onSelected: (v) => _hpfEnabled = v,
),
_switchTile(
title: 'Peak limiter',
subtitle: '1 dBFS soft-knee · 2 ms look-ahead',
value: _limiterEnabled,
onSelected: (v) => _limiterEnabled = v,
),
// ── VAD ────────────────────────────────────────────────
const Divider(height: 24),
_sectionHeader(theme, 'Voice activity detection (VAD)'),
_subHeader(theme, 'Backend'),
_radioTile<rust.BridgeVadBackend>(
value: rust.BridgeVadBackend.webrtcVad,
groupValue: _vadBackend,
title: const Text('WebRTC VAD'),
subtitle: _tileSubtitle(
'Fast · energy-based · always available',
),
onSelected: (v) => _vadBackend = v,
),
_radioTile<rust.BridgeVadBackend>(
value: rust.BridgeVadBackend.sileroOnnx,
groupValue: _vadBackend,
title: const Text('Silero v6 (ONNX)'),
subtitle: _tileSubtitle(
'Neural · 32 ms frames · requires model file',
),
onSelected: (v) => _vadBackend = v,
),
_radioTile<rust.BridgeVadBackend>(
value: rust.BridgeVadBackend.tenVad,
groupValue: _vadBackend,
title: const Text('TEN VAD'),
subtitle: _tileSubtitle(
'Neural · 16 kHz · native runtime optional',
),
onSelected: (v) => _vadBackend = v,
),
const SizedBox(height: 8),
// ── Debug ──────────────────────────────────────────────
const Divider(height: 24),
_sectionHeader(theme, 'Debug'),
_switchTile(
title: 'WAV dump',
subtitle: 'Record raw/processed mic to temp dir',
value: _debugWavDump,
onSelected: (v) => _debugWavDump = v,
),
const SizedBox(height: 8),
],
],
),
),
),
actions: [
@@ -184,6 +339,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
mode: _mode,
releaseTailMs: _releaseTail.round(),
bindKeyRequested: false,
audioConfig: _buildConfig(),
),
),
child: Text(l10n.pttConfigureSaveAction),
@@ -191,4 +347,54 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
],
);
}
Widget _radioTile<T>({
required T value,
required T groupValue,
required Widget title,
Widget? subtitle,
required ValueChanged<T> onSelected,
}) => RadioListTile<T>(
dense: true,
value: value,
groupValue: groupValue,
title: title,
subtitle: subtitle,
onChanged: (v) {
if (v == null) return;
setState(() => onSelected(v));
},
);
Widget _switchTile({
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool>? onSelected,
}) => SwitchListTile(
dense: true,
title: Text(title),
subtitle: _tileSubtitle(subtitle),
value: value,
onChanged: onSelected == null ? null : (v) => setState(() => onSelected(v)),
);
Widget _tileSubtitle(String text) =>
Text(text, style: const TextStyle(fontSize: 11));
Widget _sectionHeader(ThemeData theme, String text) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(text, style: theme.textTheme.titleSmall),
);
Widget _subHeader(ThemeData theme, String text) => Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Text(
text,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
letterSpacing: 0.5,
),
),
);
}