feat: integrate chat voice and diagnostics client
This commit is contained in:
@@ -7,15 +7,19 @@
|
||||
// - 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 '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 {
|
||||
@@ -50,11 +54,23 @@ class VoiceSettingsDialog extends StatefulWidget {
|
||||
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<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
||||
@@ -64,16 +80,7 @@ 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;
|
||||
late bool _preferHardware; // Android only: try JNI hardware effects
|
||||
late final AudioProcessingConfigState _audioProcessing;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -81,93 +88,15 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
_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;
|
||||
_preferHardware = c.aec == rust.BridgeEffectOwner.platform
|
||||
|| c.ns == rust.BridgeEffectOwner.platform
|
||||
|| c.agc == rust.BridgeEffectOwner.platform;
|
||||
_audioProcessing = AudioProcessingConfigState.fromConfig(
|
||||
widget.initialAudioConfig,
|
||||
);
|
||||
}
|
||||
|
||||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||
final c = widget.initialAudioConfig;
|
||||
final isSonora =
|
||||
_iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
|
||||
|
||||
if (_isAndroid) {
|
||||
final owner = _preferHardware
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.webrtcApm;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: c.route,
|
||||
iosMode: _iosMode,
|
||||
processingBackend: _preferHardware
|
||||
? rust.BridgeAudioBackend.platformVoiceProcessing
|
||||
: rust.BridgeAudioBackend.webrtcApm,
|
||||
vadBackend: _vadBackend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
: _vadBackend,
|
||||
aec: _aecEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
ns: _nsEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
agc: _agcEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
hpfEnabled: _hpfEnabled,
|
||||
limiterEnabled: _limiterEnabled,
|
||||
vadHangoverMs: c.vadHangoverMs,
|
||||
vadPreRollMs: c.vadPreRollMs,
|
||||
vadMinTxMs: c.vadMinTxMs,
|
||||
debugWavDumpEnabled: _debugWavDump,
|
||||
);
|
||||
}
|
||||
|
||||
// iOS / macOS: VPIO vs Sonora paths.
|
||||
// In VPIO mode, enabled effects are platform-owned. The experimental raw
|
||||
// path uses WebRTC APM ownership so config validation stays honest.
|
||||
final aecOwner = isSonora
|
||||
? (_aecEnabled
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: rust.BridgeEffectOwner.platform; // VPIO always owns AEC
|
||||
final nsOwner = isSonora
|
||||
? (_nsEnabled
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (_nsEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
final agcOwner = isSonora
|
||||
? (_agcEnabled
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: 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.webrtcApm
|
||||
: 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,
|
||||
return _audioProcessing.buildConfig(
|
||||
base: widget.initialAudioConfig,
|
||||
isAndroid: _isAndroid,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,7 +105,8 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final platformVpio =
|
||||
_iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
|
||||
_audioProcessing.iosMode ==
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
|
||||
return AlertDialog(
|
||||
title: Text(l10n.voiceSettingsTitle),
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
||||
@@ -188,24 +118,12 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
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,
|
||||
VoiceSectionHeader(l10n.voiceModeLabel),
|
||||
SegmentedButton<rust.BridgeTransmitMode>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: transmitModeSegments,
|
||||
selected: {_mode},
|
||||
onSelectionChanged: (s) => setState(() => _mode = s.first),
|
||||
),
|
||||
|
||||
// ── PTT options ────────────────────────────────────────
|
||||
@@ -257,133 +175,148 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
|
||||
// ── Audio processing ───────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
_sectionHeader(theme, 'Audio processing'),
|
||||
const VoiceSectionHeader('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 VoiceSubHeader('Processing backend'),
|
||||
SegmentedButton<rust.BridgeIosVoiceProcessingMode>(
|
||||
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) ...[
|
||||
_subHeader(theme, 'Processing backend'),
|
||||
_radioTile<bool>(
|
||||
value: true,
|
||||
groupValue: _preferHardware,
|
||||
title: const Text('Platform (auto)'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Try hardware JNI effects · software fallback',
|
||||
),
|
||||
onSelected: (v) => setState(() => _preferHardware = v),
|
||||
),
|
||||
_radioTile<bool>(
|
||||
value: false,
|
||||
groupValue: _preferHardware,
|
||||
title: const Text('WebRTC APM'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Software AEC3 · NS · AGC2',
|
||||
),
|
||||
onSelected: (v) => setState(() => _preferHardware = v),
|
||||
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),
|
||||
],
|
||||
|
||||
// DSP toggles
|
||||
_subHeader(theme, 'DSP stages'),
|
||||
_switchTile(
|
||||
title: 'Noise suppression (NS)',
|
||||
const VoiceSubHeader('DSP stages'),
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Noise suppression (NS)',
|
||||
subtitle: 'Wiener filter · stationary noise',
|
||||
value: _nsEnabled,
|
||||
onSelected: (v) => _nsEnabled = v,
|
||||
value: _audioProcessing.nsEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.nsEnabled = v),
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Echo cancellation (AEC3)',
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Echo cancellation (AEC3)',
|
||||
subtitle: _isAndroid
|
||||
? 'WebRTC AEC3 · adaptive filter'
|
||||
: platformVpio
|
||||
? 'Managed by platform VPIO'
|
||||
: 'Adaptive NLMS · 80 ms tail',
|
||||
value: _aecEnabled,
|
||||
? 'Managed by platform VPIO'
|
||||
: 'Adaptive NLMS · 80 ms tail',
|
||||
value: _audioProcessing.aecEnabled,
|
||||
// AEC is always on in VPIO mode — disable the toggle.
|
||||
onSelected: (_isAndroid || !platformVpio) ? (v) => _aecEnabled = v : null,
|
||||
onChanged: (_isAndroid || !platformVpio)
|
||||
? (v) => setState(() => _audioProcessing.aecEnabled = v)
|
||||
: null,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Auto gain control (AGC2)',
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Auto gain control (AGC2)',
|
||||
subtitle: 'RNN VAD-gated · −18 dBFS target',
|
||||
value: _agcEnabled,
|
||||
onSelected: (v) => _agcEnabled = v,
|
||||
value: _audioProcessing.agcEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.agcEnabled = v),
|
||||
),
|
||||
_switchTile(
|
||||
title: 'High-pass filter (HPF)',
|
||||
AudioProcessingToggleRow(
|
||||
label: 'High-pass filter (HPF)',
|
||||
subtitle: '80 Hz Butterworth · DC removal',
|
||||
value: _hpfEnabled,
|
||||
onSelected: (v) => _hpfEnabled = v,
|
||||
value: _audioProcessing.hpfEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.hpfEnabled = v),
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Peak limiter',
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Peak limiter',
|
||||
subtitle: '−1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: _limiterEnabled,
|
||||
onSelected: (v) => _limiterEnabled = v,
|
||||
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),
|
||||
_sectionHeader(theme, 'Voice activity detection (VAD)'),
|
||||
const VoiceSectionHeader('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 VoiceSubHeader('Backend'),
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
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),
|
||||
_sectionHeader(theme, 'Debug'),
|
||||
_switchTile(
|
||||
title: 'WAV dump',
|
||||
const VoiceSectionHeader('Debug'),
|
||||
AudioProcessingToggleRow(
|
||||
label: 'WAV dump',
|
||||
subtitle: 'Record raw/processed mic to temp dir',
|
||||
value: _debugWavDump,
|
||||
onSelected: (v) => _debugWavDump = v,
|
||||
value: _audioProcessing.debugWavDump,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.debugWavDump = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
@@ -409,54 +342,4 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user