diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 1446ed5..b5f93e6 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -26,21 +26,6 @@ import 'voice_settings_controls.dart'; import 'voice_status_summary.dart'; import '../src/rust/api.dart' as rust; -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; -} - /// Two-line status chip that summarises the current voice state. /// Tap to open the voice details modal. class VoiceStatusChip extends StatelessWidget { @@ -737,130 +722,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { ), ), const SizedBox(height: 4), - - // Android HW/SW selector. - if (Platform.isAndroid) ...[ - const VoiceSubHeader('Processing backend'), - SegmentedButton( - style: voiceSegmentedButtonStyle(theme), - segments: androidProcessingSegments, - selected: {_audioProcessing.preferHardware}, - onSelectionChanged: (s) { - setState(() => _audioProcessing.preferHardware = s.first); - _notifyAudioConfig(); - }, - ), - const SizedBox(height: 4), - Text( - _audioProcessing.preferHardware - ? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.' - : 'Software mode applies the full WebRTC APM stage set.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - - 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: 4), - ], - if (!_isIos && - (!Platform.isAndroid || - androidShowsNsControl(_audioProcessing))) - AudioProcessingToggleRow( - dense: true, - label: 'Noise suppression', - subtitle: 'Wiener filter', - value: _audioProcessing.nsEnabled, - onChanged: (v) { - setState(() => _audioProcessing.nsEnabled = v); - _notifyAudioConfig(); - }, - ), - if (!_isIos && - (!Platform.isAndroid || - androidShowsAecControl(_audioProcessing))) - AudioProcessingToggleRow( - dense: true, - label: 'Echo cancellation', - subtitle: Platform.isAndroid - ? (_audioProcessing.preferHardware - ? 'Prefers device/OS effect; falls back to WebRTC AEC3' - : '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); - _notifyAudioConfig(); - }, - ), - if (!_isIos && - (!Platform.isAndroid || - androidShowsAgcControl(_audioProcessing))) - AudioProcessingToggleRow( - dense: true, - label: 'Auto gain control', - subtitle: 'AGC2 · -18 dBFS target', - value: _audioProcessing.agcEnabled, - onChanged: (v) { - setState(() => _audioProcessing.agcEnabled = v); - _notifyAudioConfig(); - }, - ), - if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing)) - AudioProcessingToggleRow( - dense: true, - label: 'High-pass filter', - subtitle: '80 Hz · DC removal', - value: _audioProcessing.hpfEnabled, - onChanged: (v) { - setState(() => _audioProcessing.hpfEnabled = v); - _notifyAudioConfig(); - }, - ), - if (!_isIos && - (!Platform.isAndroid || - androidShowsLimiterControl(_audioProcessing))) - AudioProcessingToggleRow( - dense: true, - label: 'Peak limiter', - subtitle: '-1 dBFS soft-knee · 2 ms look-ahead', - value: _audioProcessing.limiterEnabled, - onChanged: (v) { - setState(() => _audioProcessing.limiterEnabled = v); - _notifyAudioConfig(); - }, - ), - - // VAD backend. - const SizedBox(height: 8), - Text( - 'Voice activity detection (VAD)', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - SegmentedButton( - style: voiceSegmentedButtonStyle(theme), - segments: _isDesktopSileroVadHost - ? desktopVadBackendSegments - : vadBackendSegments, - selected: {_audioProcessing.vadBackend}, - onSelectionChanged: (s) { - setState(() => _audioProcessing.vadBackend = s.first); + + AudioProcessingPanel( + config: _audioProcessing, + dense: true, + update: (mutation) { + setState(mutation); _notifyAudioConfig(); }, ), diff --git a/apps/chanora_flutter/lib/widgets/voice_platform.dart b/apps/chanora_flutter/lib/widgets/voice_platform.dart index 799185d..2fd8e81 100644 --- a/apps/chanora_flutter/lib/widgets/voice_platform.dart +++ b/apps/chanora_flutter/lib/widgets/voice_platform.dart @@ -2,9 +2,17 @@ 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; -} +// TODO(refactor): Scattered Platform.isX checks exist across ~6 Dart files. +// Centralize all platform checks here and update call sites to use these +// getters instead of raw Platform.isAndroid/isIOS/etc. +bool get _notWeb => !kIsWeb; + +bool get isTouchOnlyPttHost => _notWeb && (Platform.isIOS || Platform.isAndroid); + +bool get isAndroidHost => _notWeb && Platform.isAndroid; + +bool get isIosHost => _notWeb && Platform.isIOS; + +bool get isMacOsHost => _notWeb && Platform.isMacOS; + +bool get isDesktopSileroVadHost => _notWeb && (Platform.isWindows || Platform.isLinux); diff --git a/apps/chanora_flutter/lib/widgets/voice_settings.dart b/apps/chanora_flutter/lib/widgets/voice_settings.dart index 940bfd1..7a62981 100644 --- a/apps/chanora_flutter/lib/widgets/voice_settings.dart +++ b/apps/chanora_flutter/lib/widgets/voice_settings.dart @@ -7,9 +7,6 @@ // - 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'; @@ -22,26 +19,6 @@ 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({ @@ -106,7 +83,7 @@ class _VoiceSettingsDialogState extends State { rust.BridgeAudioProcessingConfig _buildConfig() { return _audioProcessing.buildConfig( base: widget.initialAudioConfig, - isAndroid: _isAndroid, + isAndroid: isAndroidHost, ); } @@ -189,92 +166,10 @@ class _VoiceSettingsDialogState extends State { 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), - ), + AudioProcessingPanel( + config: _audioProcessing, + update: (mutation) => setState(mutation), + ), if (isTalkPowerBlocked( talkPower: widget.talkPower, @@ -289,22 +184,6 @@ class _VoiceSettingsDialogState extends State { ), ], - // ── 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) ...[ @@ -318,14 +197,14 @@ class _VoiceSettingsDialogState extends State { ], // ── Audio output route picker (mobile only) ───────────── - if (_isAndroid || _isIos) ...[ + if (isAndroidHost || isIosHost) ...[ const Divider(height: 24), const VoiceSectionHeader('Audio output'), const AudioOutputTile(), ], // ── Audio devices (desktop only, SRS-026) ────────────── - if (!_isAndroid && !_isIos) ...[ + if (!isAndroidHost && !isIosHost) ...[ const Divider(height: 24), const VoiceSectionHeader('Audio devices'), const AudioDeviceListTile( diff --git a/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart b/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart index f52ffea..278b303 100644 --- a/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart +++ b/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart @@ -3,6 +3,8 @@ import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; +import 'audio_processing_config_state.dart'; +import 'voice_platform.dart'; import '../src/rust/api.dart' as rust; /// Shared compact style for voice settings segmented buttons. @@ -230,3 +232,173 @@ class AudioProcessingToggleRow extends StatelessWidget { ); } } + +typedef AudioFieldUpdater = void Function(VoidCallback mutation); + +class AudioProcessingPanel extends StatelessWidget { + const AudioProcessingPanel({ + super.key, + required this.config, + required this.update, + this.dense = false, + }); + + final AudioProcessingConfigState config; + final AudioFieldUpdater update; + final bool dense; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isAndroid = isAndroidHost; + final isIos = isIosHost; + final isMacOS = isMacOsHost; + final isDesktopSilero = isDesktopSileroVadHost; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (isAndroid) ...[ + if (!dense) const VoiceSubHeader('Processing backend'), + if (dense) _compactLabel(context, 'Processing backend'), + SegmentedButton( + style: voiceSegmentedButtonStyle(theme), + segments: androidProcessingSegments, + selected: {config.preferHardware}, + onSelectionChanged: (s) { + update(() => config.preferHardware = s.first); + }, + ), + const SizedBox(height: 4), + Text( + config.preferHardware + ? (dense + ? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.' + : 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.') + : (dense + ? 'Software mode applies the full WebRTC APM stage set.' + : 'Android software mode applies the full WebRTC APM control set.'), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (!dense) const SizedBox(height: 8), + ], + + if (!dense) 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: 4), + ], + + if (!isIos && + (!isAndroid || androidShowsNsControl(config))) + AudioProcessingToggleRow( + dense: dense, + label: dense ? 'Noise suppression' : 'Noise suppression (NS)', + subtitle: dense + ? 'Wiener filter' + : 'Wiener filter · stationary noise', + value: config.nsEnabled, + onChanged: (v) => update(() => config.nsEnabled = v), + ), + if (!isIos && + (!isAndroid || androidShowsAecControl(config))) + AudioProcessingToggleRow( + dense: dense, + label: dense ? 'Echo cancellation' : 'Echo cancellation (AEC3)', + subtitle: _aecSubtitle(isAndroid, isMacOS, config), + value: isMacOS ? true : config.aecEnabled, + onChanged: + isMacOS ? null : (v) => update(() => config.aecEnabled = v), + ), + if (!isIos && + (!isAndroid || androidShowsAgcControl(config))) + AudioProcessingToggleRow( + dense: dense, + label: dense ? 'Auto gain control' : 'Auto gain control (AGC2)', + subtitle: dense + ? 'AGC2 · -18 dBFS target' + : 'RNN VAD-gated · -18 dBFS target', + value: config.agcEnabled, + onChanged: (v) => update(() => config.agcEnabled = v), + ), + if (!isAndroid || androidShowsHpfControl(config)) + AudioProcessingToggleRow( + dense: dense, + label: dense ? 'High-pass filter' : 'High-pass filter (HPF)', + subtitle: dense + ? '80 Hz · DC removal' + : '80 Hz Butterworth · DC removal', + value: config.hpfEnabled, + onChanged: (v) => update(() => config.hpfEnabled = v), + ), + if (!isIos && + (!isAndroid || androidShowsLimiterControl(config))) + AudioProcessingToggleRow( + dense: dense, + label: 'Peak limiter', + subtitle: '-1 dBFS soft-knee · 2 ms look-ahead', + value: config.limiterEnabled, + onChanged: (v) => update(() => config.limiterEnabled = v), + ), + + if (!dense) ...[ + const Divider(height: 24), + const VoiceSectionHeader('Voice activity detection (VAD)'), + const VoiceSubHeader('Backend'), + ] else ...[ + const SizedBox(height: 8), + Text( + 'Voice activity detection (VAD)', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + ], + SegmentedButton( + style: voiceSegmentedButtonStyle(theme), + segments: + isDesktopSilero ? desktopVadBackendSegments : vadBackendSegments, + selected: {config.vadBackend}, + onSelectionChanged: (s) { + update(() => config.vadBackend = s.first); + }, + ), + if (!dense) const SizedBox(height: 8), + ], + ); + } + + String _aecSubtitle(bool isAndroid, bool isMacOS, AudioProcessingConfigState c) { + if (isAndroid) { + return c.preferHardware + ? 'Prefers device/OS effect; falls back to WebRTC AEC3' + : 'WebRTC AEC3 · adaptive filter'; + } + if (isMacOS) return 'Managed by platform VPIO'; + return 'WebRTC AEC3 · adaptive filter'; + } + + Widget _compactLabel(BuildContext context, String text) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + text, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } +}