// 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 import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../l10n/generated/app_localizations.dart'; import 'audio_device_list_tile.dart'; import 'audio_output_tile.dart'; import 'audio_processing_config_state.dart'; import 'ptt_capability_badge.dart'; import 'talk_power_warning.dart'; import 'voice_platform.dart'; import 'voice_settings_controls.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, this.pttLevel = '', this.pttBackendId = '', this.pttBoundInputClass = '', this.talkPower, this.neededTalkPower, this.talkPowerGranted, }); final rust.BridgeTransmitMode initialMode; final int initialReleaseTailMs; final rust.BridgeAudioProcessingConfig initialAudioConfig; final String pttLevel; final String pttBackendId; final String pttBoundInputClass; final int? talkPower; final int? neededTalkPower; final bool? talkPowerGranted; @override State createState() => _VoiceSettingsDialogState(); } class _VoiceSettingsDialogState extends State { late rust.BridgeTransmitMode _mode; late double _releaseTail; late final AudioProcessingConfigState _audioProcessing; @override void initState() { super.initState(); _mode = widget.initialMode; _releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble(); _audioProcessing = AudioProcessingConfigState.fromConfig( widget.initialAudioConfig, ); } rust.BridgeAudioProcessingConfig _buildConfig() { return _audioProcessing.buildConfig( base: widget.initialAudioConfig, isAndroid: _isAndroid, ); } @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); final platformVpio = _audioProcessing.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 ────────────────────────────────────── VoiceSectionHeader(l10n.voiceModeLabel), SegmentedButton( style: voiceSegmentedButtonStyle(theme), segments: transmitModeSegments, selected: {_mode}, onSelectionChanged: (s) => setState(() => _mode = s.first), ), // ── 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), const VoiceSectionHeader('Audio processing'), // iOS mode selector (iOS only) if (_isIos) ...[ const VoiceSubHeader('Processing backend'), SegmentedButton( style: voiceSegmentedButtonStyle(theme), segments: iosProcessingSegments, selected: {_audioProcessing.iosMode}, onSelectionChanged: (s) => setState(() => _audioProcessing.iosMode = s.first), ), const SizedBox(height: 4), ], // Android HW/SW selector if (_isAndroid) ...[ const VoiceSubHeader('Processing backend'), SegmentedButton( style: voiceSegmentedButtonStyle(theme), segments: androidProcessingSegments, selected: {_audioProcessing.preferHardware}, onSelectionChanged: (s) => setState(() => _audioProcessing.preferHardware = s.first), ), const SizedBox(height: 4), ], // DSP toggles const VoiceSubHeader('DSP stages'), AudioProcessingToggleRow( label: 'Noise suppression (NS)', subtitle: 'Wiener filter · stationary noise', value: _audioProcessing.nsEnabled, onChanged: (v) => setState(() => _audioProcessing.nsEnabled = v), ), AudioProcessingToggleRow( label: 'Echo cancellation (AEC3)', subtitle: _isAndroid ? 'WebRTC AEC3 · adaptive filter' : platformVpio ? 'Managed by platform VPIO' : 'Adaptive NLMS · 80 ms tail', value: _audioProcessing.aecEnabled, // AEC is always on in VPIO mode — disable the toggle. onChanged: (_isAndroid || !platformVpio) ? (v) => setState(() => _audioProcessing.aecEnabled = v) : null, ), AudioProcessingToggleRow( label: 'Auto gain control (AGC2)', subtitle: 'RNN VAD-gated · −18 dBFS target', value: _audioProcessing.agcEnabled, onChanged: (v) => setState(() => _audioProcessing.agcEnabled = v), ), AudioProcessingToggleRow( label: 'High-pass filter (HPF)', subtitle: '80 Hz Butterworth · DC removal', value: _audioProcessing.hpfEnabled, onChanged: (v) => setState(() => _audioProcessing.hpfEnabled = v), ), AudioProcessingToggleRow( label: 'Peak limiter', subtitle: '−1 dBFS soft-knee · 2 ms look-ahead', value: _audioProcessing.limiterEnabled, onChanged: (v) => setState(() => _audioProcessing.limiterEnabled = v), ), if (isTalkPowerBlocked( talkPower: widget.talkPower, neededTalkPower: widget.neededTalkPower, talkPowerGranted: widget.talkPowerGranted, )) ...[ const SizedBox(height: 8), TalkPowerWarning( talkPower: widget.talkPower, neededTalkPower: widget.neededTalkPower, talkPowerGranted: widget.talkPowerGranted, ), ], // ── VAD ──────────────────────────────────────────────── const Divider(height: 24), const VoiceSectionHeader('Voice activity detection (VAD)'), const VoiceSubHeader('Backend'), SegmentedButton( style: voiceSegmentedButtonStyle(theme), segments: vadBackendSegments, selected: {_audioProcessing.vadBackend}, onSelectionChanged: (s) => setState(() => _audioProcessing.vadBackend = s.first), ), const SizedBox(height: 8), // ── PTT capability badge ──────────────────────────────── if (_mode == rust.BridgeTransmitMode.ptt && widget.pttLevel.isNotEmpty) ...[ const Divider(height: 24), const VoiceSectionHeader('PTT capability'), PttCapabilityBadge( level: widget.pttLevel, backendId: widget.pttBackendId, boundInputClass: widget.pttBoundInputClass, ), ], // ── Audio output route picker (mobile only) ───────────── if (_isAndroid || _isIos) ...[ const Divider(height: 24), const VoiceSectionHeader('Audio output'), const AudioOutputTile(), ], // ── Audio devices (desktop only, SRS-026) ────────────── if (!_isAndroid && !_isIos) ...[ const Divider(height: 24), const VoiceSectionHeader('Audio devices'), const AudioDeviceListTile( label: 'Input', kind: AudioDeviceKind.input, ), const AudioDeviceListTile( label: 'Output', kind: AudioDeviceKind.output, ), const SizedBox(height: 8), ], // ── Debug ────────────────────────────────────────────── const Divider(height: 24), const VoiceSectionHeader('Debug'), AudioProcessingToggleRow( label: 'WAV dump', subtitle: 'Record raw/processed mic to temp dir', value: _audioProcessing.debugWavDump, onChanged: (v) => setState(() => _audioProcessing.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), ), ], ); } }