From bdaa5210f00226848f6a146aa5b6895e5f9f9219 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Wed, 15 Apr 2026 00:28:04 -0400 Subject: [PATCH] feat(voice): dynamic silence threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous -40 dBFS static threshold sat right on top of typical phone mic ambient, so silence detection rarely fired and the user always had to tap stop manually. Silence threshold is now dynamic: track the session peak dBFS and treat "silent" as 15 dB below peak. Auto-calibrates per mic and environment rather than assuming a fixed ambient level. - Grace period (1500 ms) at start so the user has time to begin speaking before checks arm. - Static -35 dB fallback until the peak clears -20 dB so a dead- silent session doesn't spin forever. - Silence duration bumped 1500 → 2000 ms for breathing room. Co-Authored-By: Claude Opus 4.6 --- lib/providers/voice_provider.dart | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 6c533b4..1887ae1 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -99,10 +99,23 @@ class VoiceNotifier extends Notifier { StreamSubscription? _amplitudeSubscription; // Recording / silence detection + // + // Silence threshold is dynamic: we track the session peak dBFS and treat + // "silent" as "current level is at least _dropFromPeakDb below peak." + // This auto-calibrates to whatever mic + room the user is on rather than + // assuming a fixed ambient level. Until the peak climbs above + // _dynamicArmDb we fall back to a conservative static threshold so a + // quiet room doesn't spin forever. A grace period at the start of the + // recording gives the user time to begin speaking before silence checks + // arm. int _recordingStartMs = 0; int _silenceMs = 0; - static const _silenceThresholdDb = -40.0; - static const _silenceDurationMs = 1500; + double _peakDb = -100.0; + static const _fallbackThresholdDb = -35.0; + static const _dropFromPeakDb = 15.0; + static const _dynamicArmDb = -20.0; + static const _graceMs = 1500; + static const _silenceDurationMs = 2000; static const _minRecordingMs = 300; // Voice mode callbacks @@ -236,6 +249,7 @@ class VoiceNotifier extends Notifier { if (!state.voiceModeActive) return; _silenceMs = 0; + _peakDb = -100.0; _recordingStartMs = DateTime.now().millisecondsSinceEpoch; state = state.copyWith(mode: VoiceMode.recording); @@ -285,13 +299,22 @@ class VoiceNotifier extends Notifier { if ((norm - state.amplitude).abs() > 0.02) { state = state.copyWith(amplitude: norm); } + if (db > _peakDb) _peakDb = db; } final elapsed = DateTime.now().millisecondsSinceEpoch - _recordingStartMs; + // Grace period at the start — let the user begin speaking before we + // start silence-counting. Also suppresses any false triggers while + // the native recorder is still warming up. + if (elapsed < _graceMs) return; if (elapsed < _minRecordingMs) return; - final isSilent = !validDb || db < _silenceThresholdDb; + // Dynamic threshold once we've seen real speech; static fallback + // before that so a dead-silent session doesn't spin forever. + final threshold = + _peakDb > _dynamicArmDb ? _peakDb - _dropFromPeakDb : _fallbackThresholdDb; + final isSilent = !validDb || db < threshold; if (isSilent) { _silenceMs += 200;