// 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 // - platform audio-processing mode selection where available 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; } bool get _isMacOS { if (kIsWeb) return false; return Platform.isMacOS; } bool get _isDesktopSileroVadHost { if (kIsWeb) return false; return Platform.isWindows || Platform.isLinux; } /// 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); 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'), // 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), Text( _audioProcessing.preferHardware ? 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.' : 'Android software mode applies the full WebRTC APM control set.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), ], // DSP toggles const VoiceSubHeader('DSP stages'), if (_isIos) ...[ Text( 'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are ' 'hidden here; only settings that still affect the shipping ' 'iOS path are shown.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), ], if (!_isIos && (!_isAndroid || androidShowsNsControl(_audioProcessing))) AudioProcessingToggleRow( label: 'Noise suppression (NS)', subtitle: 'Wiener filter · stationary noise', value: _audioProcessing.nsEnabled, onChanged: (v) => setState(() => _audioProcessing.nsEnabled = v), ), if (!_isIos && (!_isAndroid || androidShowsAecControl(_audioProcessing))) AudioProcessingToggleRow( label: 'Echo cancellation (AEC3)', subtitle: _isAndroid ? (_audioProcessing.preferHardware ? 'Prefers device/OS effect; WebRTC AEC3 fallback when binding is unavailable' : 'WebRTC AEC3 · adaptive filter') : (_isMacOS ? 'Managed by platform VPIO' : 'WebRTC AEC3 · adaptive filter'), value: _isMacOS ? true : _audioProcessing.aecEnabled, onChanged: _isMacOS ? null : (v) => setState(() => _audioProcessing.aecEnabled = v), ), if (!_isIos && (!_isAndroid || androidShowsAgcControl(_audioProcessing))) AudioProcessingToggleRow( label: 'Auto gain control (AGC2)', subtitle: 'RNN VAD-gated · -18 dBFS target', value: _audioProcessing.agcEnabled, onChanged: (v) => setState(() => _audioProcessing.agcEnabled = v), ), if (!_isAndroid || androidShowsHpfControl(_audioProcessing)) AudioProcessingToggleRow( label: 'High-pass filter (HPF)', subtitle: '80 Hz Butterworth · DC removal', value: _audioProcessing.hpfEnabled, onChanged: (v) => setState(() => _audioProcessing.hpfEnabled = v), ), if (!_isIos && (!_isAndroid || androidShowsLimiterControl(_audioProcessing))) 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: _isDesktopSileroVadHost ? desktopVadBackendSegments : 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), ), ], ); } }