refactor(flutter): extract shared AudioProcessingPanel widget (TODO-013)

Deduplicate audio processing toggle UI from voice_compact.dart and
voice_settings.dart into shared AudioProcessingPanel widget in
voice_settings_controls.dart. Centralize platform detection getters
in voice_platform.dart.
This commit is contained in:
Edison Jwa
2026-06-11 10:04:58 +09:00
parent ecdd68eff2
commit 484522cbb6
4 changed files with 199 additions and 273 deletions
@@ -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<bool>(
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<rust.BridgeVadBackend>(
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();
},
),
@@ -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);
@@ -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<VoiceSettingsDialog> {
rust.BridgeAudioProcessingConfig _buildConfig() {
return _audioProcessing.buildConfig(
base: widget.initialAudioConfig,
isAndroid: _isAndroid,
isAndroid: isAndroidHost,
);
}
@@ -189,92 +166,10 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
const Divider(height: 24),
const VoiceSectionHeader('Audio processing'),
// Android HW/SW selector
if (_isAndroid) ...[
const VoiceSubHeader('Processing backend'),
SegmentedButton<bool>(
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<VoiceSettingsDialog> {
),
],
// ── VAD ────────────────────────────────────────────────
const Divider(height: 24),
const VoiceSectionHeader('Voice activity detection (VAD)'),
const VoiceSubHeader('Backend'),
SegmentedButton<rust.BridgeVadBackend>(
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<VoiceSettingsDialog> {
],
// ── 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(
@@ -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<bool>(
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<rust.BridgeVadBackend>(
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,
),
),
);
}
}