// 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; 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; bool get _isAndroid { if (kIsWeb) return false; return Platform.isAndroid; } bool get _isIos { if (kIsWeb) return false; return Platform.isIOS; } /// Result returned by [VoiceSettingsDialog]. class VoiceSettingsResult { const VoiceSettingsResult({ required this.mode, required this.releaseTailMs, required this.bindKeyRequested, required this.audioConfig, }); final rust.BridgeTransmitMode mode; final int releaseTailMs; final bool bindKeyRequested; final rust.BridgeAudioProcessingConfig audioConfig; } /// Voice + audio processing settings dialog. class VoiceSettingsDialog extends StatefulWidget { const VoiceSettingsDialog({ super.key, required this.initialMode, required this.initialReleaseTailMs, required this.initialAudioConfig, }); final rust.BridgeTransmitMode initialMode; final int initialReleaseTailMs; final rust.BridgeAudioProcessingConfig initialAudioConfig; @override State createState() => _VoiceSettingsDialogState(); } class _VoiceSettingsDialogState extends State { 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; late bool _preferHardware; // Android only: try JNI hardware effects @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; _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; 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.webrtcApm : rust.BridgeEffectOwner.off) : rust.BridgeEffectOwner.platform; // VPIO always owns AEC final nsOwner = isSonora ? (_nsEnabled ? rust.BridgeEffectOwner.webrtcApm : rust.BridgeEffectOwner.off) : (_nsEnabled ? rust.BridgeEffectOwner.platform : rust.BridgeEffectOwner.off); final agcOwner = isSonora ? (_agcEnabled ? rust.BridgeEffectOwner.webrtcApm : 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.webrtcApm : 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: 400, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Transmit mode ────────────────────────────────────── _sectionHeader(theme, l10n.voiceModeLabel), _radioTile( value: rust.BridgeTransmitMode.ptt, groupValue: _mode, title: Text(l10n.voiceModePtt), onSelected: (v) => _mode = v, ), _radioTile( value: rust.BridgeTransmitMode.continuous, groupValue: _mode, title: Text(l10n.voiceModeContinuous), onSelected: (v) => _mode = v, ), _radioTile( 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, 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( value: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, groupValue: _iosMode, title: const Text('Platform (VPIO)'), subtitle: _tileSubtitle('Apple AEC · NS · AGC'), onSelected: (v) => _iosMode = v, ), _radioTile( 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), ], // Android HW/SW selector if (_isAndroid) ...[ _subHeader(theme, 'Processing backend'), _radioTile( value: true, groupValue: _preferHardware, title: const Text('Platform (auto)'), subtitle: _tileSubtitle( 'Try hardware JNI effects · software fallback', ), onSelected: (v) => setState(() => _preferHardware = v), ), _radioTile( 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( title: 'Noise suppression (NS)', subtitle: 'Wiener filter · stationary noise', value: _nsEnabled, onSelected: (v) => _nsEnabled = v, ), _switchTile( title: 'Echo cancellation (AEC3)', 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: (_isAndroid || !platformVpio) ? (v) => _aecEnabled = v : null, ), _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( value: rust.BridgeVadBackend.webrtcVad, groupValue: _vadBackend, title: const Text('WebRTC VAD'), subtitle: _tileSubtitle( 'Fast · energy-based · always available', ), onSelected: (v) => _vadBackend = v, ), _radioTile( 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( 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: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(l10n.closeAction), ), FilledButton( onPressed: () => Navigator.of(context).pop( VoiceSettingsResult( mode: _mode, releaseTailMs: _releaseTail.round(), bindKeyRequested: false, audioConfig: _buildConfig(), ), ), child: Text(l10n.pttConfigureSaveAction), ), ], ); } Widget _radioTile({ required T value, required T groupValue, required Widget title, Widget? subtitle, required ValueChanged onSelected, }) => RadioListTile( 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? 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, ), ), ); }