feat: stabilize voice activity and audio routing

This commit is contained in:
Edison Jwa
2026-05-25 01:19:09 +09:00
parent eb9014cd81
commit 5515ff6643
34 changed files with 3054 additions and 1751 deletions
@@ -26,6 +26,21 @@ 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 {
@@ -448,17 +463,17 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
widget.initialAudioConfig,
);
// Poll audio stats at 80 ms so TX/RX counters and the level meter
// Poll audio stats at 250 ms so TX/RX counters and the level meter
// update in real time while the sheet is open, independent of the parent.
_statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async {
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
try {
final s = await rust.audioStats();
if (!mounted) return;
setState(() {
_stats = s;
_rateTickCount++;
// Compute rates every ~960 ms (12 × 80 ms).
if (_rateTickCount >= 12) {
// Compute rates every ~1 s (4 × 250 ms).
if (_rateTickCount >= 4) {
_txRate = s.framesSent - _prevSent;
_rxRate = s.framesReceived - _prevReceived;
_prevSent = s.framesSent;
@@ -657,103 +672,99 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
_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,
),
),
],
AudioProcessingToggleRow(
dense: true,
label: 'Noise suppression',
subtitle: 'Wiener filter',
value: _audioProcessing.nsEnabled,
onChanged: (v) {
setState(() => _audioProcessing.nsEnabled = v);
_notifyAudioConfig();
},
),
AudioProcessingToggleRow(
dense: true,
label: 'Echo cancellation',
subtitle: () {
if (Platform.isAndroid) {
return 'WebRTC AEC3 · adaptive filter';
}
return widget.initialAudioConfig.iosMode ==
rust
.BridgeIosVoiceProcessingMode
.platformVoiceProcessing
? 'Always on · managed by platform VPIO'
: 'AEC3 adaptive filter · 80 ms tail';
}(),
value: () {
if (Platform.isAndroid) return _audioProcessing.aecEnabled;
return widget.initialAudioConfig.iosMode ==
rust
.BridgeIosVoiceProcessingMode
.platformVoiceProcessing
? true
: _audioProcessing.aecEnabled;
}(),
onChanged: () {
if (Platform.isAndroid) {
return (v) {
setState(() => _audioProcessing.aecEnabled = v);
_notifyAudioConfig();
};
}
return widget.initialAudioConfig.iosMode ==
rust
.BridgeIosVoiceProcessingMode
.platformVoiceProcessing
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();
};
}(),
),
AudioProcessingToggleRow(
dense: true,
label: 'Auto gain control',
subtitle: 'AGC2 · 18 dBFS target',
value: _audioProcessing.agcEnabled,
onChanged: (v) {
setState(() => _audioProcessing.agcEnabled = v);
_notifyAudioConfig();
},
),
AudioProcessingToggleRow(
dense: true,
label: 'High-pass filter',
subtitle: '80 Hz · DC removal',
value: _audioProcessing.hpfEnabled,
onChanged: (v) {
setState(() => _audioProcessing.hpfEnabled = v);
_notifyAudioConfig();
},
),
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();
},
),
// iOS mode selector.
if (Platform.isIOS) ...[
const VoiceSubHeader('Processing backend'),
SegmentedButton<rust.BridgeIosVoiceProcessingMode>(
style: voiceSegmentedButtonStyle(theme),
segments: iosProcessingSegments,
selected: {_audioProcessing.iosMode},
onSelectionChanged: (s) {
setState(() => _audioProcessing.iosMode = s.first);
},
),
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),
@@ -766,7 +777,9 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
const SizedBox(height: 2),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: vadBackendSegments,
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) {
setState(() => _audioProcessing.vadBackend = s.first);