feat: stabilize voice activity and audio routing
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user