401 lines
14 KiB
Dart
401 lines
14 KiB
Dart
// 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 _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<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
||
}
|
||
|
||
class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||
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;
|
||
|
||
@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;
|
||
}
|
||
|
||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||
final c = widget.initialAudioConfig;
|
||
final isSonora =
|
||
_iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
|
||
// In VPIO mode, enabled effects are platform-owned. Sonora ownership is
|
||
// reserved for the experimental raw path so config validation stays honest.
|
||
final aecOwner = isSonora
|
||
? (_aecEnabled
|
||
? rust.BridgeEffectOwner.sonora
|
||
: rust.BridgeEffectOwner.off)
|
||
: rust.BridgeEffectOwner.platform; // VPIO always owns AEC
|
||
final nsOwner = isSonora
|
||
? (_nsEnabled
|
||
? rust.BridgeEffectOwner.sonora
|
||
: rust.BridgeEffectOwner.off)
|
||
: (_nsEnabled
|
||
? rust.BridgeEffectOwner.platform
|
||
: rust.BridgeEffectOwner.off);
|
||
final agcOwner = isSonora
|
||
? (_agcEnabled
|
||
? rust.BridgeEffectOwner.sonora
|
||
: 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.sonora
|
||
: 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<rust.BridgeTransmitMode>(
|
||
value: rust.BridgeTransmitMode.ptt,
|
||
groupValue: _mode,
|
||
title: Text(l10n.voiceModePtt),
|
||
onSelected: (v) => _mode = v,
|
||
),
|
||
_radioTile<rust.BridgeTransmitMode>(
|
||
value: rust.BridgeTransmitMode.continuous,
|
||
groupValue: _mode,
|
||
title: Text(l10n.voiceModeContinuous),
|
||
onSelected: (v) => _mode = v,
|
||
),
|
||
_radioTile<rust.BridgeTransmitMode>(
|
||
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<rust.BridgeIosVoiceProcessingMode>(
|
||
value:
|
||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||
groupValue: _iosMode,
|
||
title: const Text('Platform (VPIO)'),
|
||
subtitle: _tileSubtitle('Apple AEC · NS · AGC'),
|
||
onSelected: (v) => _iosMode = v,
|
||
),
|
||
_radioTile<rust.BridgeIosVoiceProcessingMode>(
|
||
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),
|
||
],
|
||
|
||
// 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: platformVpio
|
||
? 'Managed by platform VPIO'
|
||
: 'Adaptive NLMS · 80 ms tail',
|
||
value: _aecEnabled,
|
||
// AEC is always on in VPIO mode — disable the toggle.
|
||
onSelected: platformVpio ? null : (v) => _aecEnabled = v,
|
||
),
|
||
_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<rust.BridgeVadBackend>(
|
||
value: rust.BridgeVadBackend.webrtcVad,
|
||
groupValue: _vadBackend,
|
||
title: const Text('WebRTC VAD'),
|
||
subtitle: _tileSubtitle(
|
||
'Fast · energy-based · always available',
|
||
),
|
||
onSelected: (v) => _vadBackend = v,
|
||
),
|
||
_radioTile<rust.BridgeVadBackend>(
|
||
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<rust.BridgeVadBackend>(
|
||
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<T>({
|
||
required T value,
|
||
required T groupValue,
|
||
required Widget title,
|
||
Widget? subtitle,
|
||
required ValueChanged<T> onSelected,
|
||
}) => RadioListTile<T>(
|
||
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<bool>? 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,
|
||
),
|
||
),
|
||
);
|
||
}
|