Compare commits

...

3 Commits

Author SHA1 Message Date
bvandeusen 413b82f724 Merge pull request 'Release v26.04.16.1 — Silero VAD voice detection' (#26) from dev into main 2026-04-17 01:00:54 +00:00
bvandeusen 5f11b344a3 feat(voice): replace amplitude silence detection with Silero VAD
Use VadHandler from the vad package (Silero VAD v5 ONNX) for speech
detection instead of amplitude thresholds. VAD runs its own PCM
stream while the file recorder captures AAC-LC for Whisper. Grace
period starts on speech-start, auto-stop on speech-end after grace.
No-speech guard shows error on manual stop without detected speech.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:57:52 -04:00
bvandeusen 8959b62abe chore: upgrade record to v6, add vad package
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:55:18 -04:00
3 changed files with 77 additions and 73 deletions
+52 -62
View File
@@ -8,6 +8,7 @@ import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart'; import 'package:record/record.dart';
import 'package:vad/vad.dart';
import 'api_client_provider.dart'; import 'api_client_provider.dart';
@@ -98,25 +99,13 @@ class VoiceNotifier extends Notifier<VoiceState> {
AudioPlayer? _player; AudioPlayer? _player;
StreamSubscription<Amplitude>? _amplitudeSubscription; StreamSubscription<Amplitude>? _amplitudeSubscription;
// Recording / silence detection // VAD-based speech detection
// VadHandler? _vadHandler;
// Silence threshold is dynamic: we track the session peak dBFS and treat StreamSubscription<void>? _vadSpeechStartSub;
// "silent" as "current level is at least _dropFromPeakDb below peak." StreamSubscription<List<double>>? _vadSpeechEndSub;
// This auto-calibrates to whatever mic + room the user is on rather than bool _speechDetected = false;
// assuming a fixed ambient level. Until the peak climbs above int _speechStartMs = 0;
// _dynamicArmDb we fall back to a conservative static threshold so a static const _vadGraceMs = 1500;
// 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;
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 // Voice mode callbacks
Future<void> Function(String transcript)? _onTranscript; Future<void> Function(String transcript)? _onTranscript;
@@ -164,6 +153,9 @@ class VoiceNotifier extends Notifier<VoiceState> {
required void Function(String message) onError, required void Function(String message) onError,
}) async { }) async {
if (state.voiceModeActive) { if (state.voiceModeActive) {
if (state.mode == VoiceMode.recording && !_speechDetected) {
onError('No speech detected');
}
exitVoiceMode(); exitVoiceMode();
return; return;
} }
@@ -211,12 +203,14 @@ class VoiceNotifier extends Notifier<VoiceState> {
_amplitudeSubscription?.cancel(); _amplitudeSubscription?.cancel();
_amplitudeSubscription = null; _amplitudeSubscription = null;
_recorder?.stop(); _recorder?.stop();
_stopVad();
_player?.stop(); _player?.stop();
_ttsQueue.clear(); _ttsQueue.clear();
_ttsPlaying = false; _ttsPlaying = false;
_sentenceBuffer = ''; _sentenceBuffer = '';
_lastSeenLength = 0; _lastSeenLength = 0;
_streamComplete = false; _streamComplete = false;
_speechDetected = false;
_onTranscript = null; _onTranscript = null;
_onError = null; _onError = null;
state = const VoiceState(); state = const VoiceState();
@@ -248,9 +242,8 @@ class VoiceNotifier extends Notifier<VoiceState> {
Future<void> _startListening() async { Future<void> _startListening() async {
if (!state.voiceModeActive) return; if (!state.voiceModeActive) return;
_silenceMs = 0; _speechDetected = false;
_peakDb = -100.0; _speechStartMs = 0;
_recordingStartMs = DateTime.now().millisecondsSinceEpoch;
state = state.copyWith(mode: VoiceMode.recording); state = state.copyWith(mode: VoiceMode.recording);
final dir = _tempDir ?? await getTemporaryDirectory(); final dir = _tempDir ?? await getTemporaryDirectory();
@@ -258,9 +251,6 @@ class VoiceNotifier extends Notifier<VoiceState> {
'${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a'; '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
try { try {
// Recreate the recorder each session — the record package can leave
// the native AudioRecord in a bad state after stop/error cycles,
// and a stale instance is the most common cause of "could not start".
_recorder?.dispose(); _recorder?.dispose();
_recorder = AudioRecorder(); _recorder = AudioRecorder();
@@ -279,52 +269,52 @@ class VoiceNotifier extends Notifier<VoiceState> {
_amplitudeSubscription = _recorder! _amplitudeSubscription = _recorder!
.onAmplitudeChanged(const Duration(milliseconds: 200)) .onAmplitudeChanged(const Duration(milliseconds: 200))
.listen(_onAmplitude); .listen(_onAmplitude);
// VAD for speech detection — uses its own AudioRecorder internally
await _stopVad();
_vadHandler = VadHandler.create();
_vadSpeechStartSub = _vadHandler!.onSpeechStart.listen((_) {
if (!_speechDetected) {
_speechDetected = true;
_speechStartMs = DateTime.now().millisecondsSinceEpoch;
}
});
_vadSpeechEndSub = _vadHandler!.onSpeechEnd.listen((_) {
if (!state.voiceModeActive) return;
final now = DateTime.now().millisecondsSinceEpoch;
final sinceStart = _speechStartMs > 0 ? now - _speechStartMs : 0;
if (_speechDetected && sinceStart >= _vadGraceMs) {
_amplitudeSubscription?.cancel();
_amplitudeSubscription = null;
_stopVad();
_handleSilence();
}
});
await _vadHandler!.startListening(model: 'v5');
} catch (e) { } catch (e) {
_onError?.call('Microphone error: $e'); _onError?.call('Microphone error: $e');
exitVoiceMode(); exitVoiceMode();
} }
} }
Future<void> _stopVad() async {
await _vadSpeechStartSub?.cancel();
_vadSpeechStartSub = null;
await _vadSpeechEndSub?.cancel();
_vadSpeechEndSub = null;
if (_vadHandler != null) {
await _vadHandler!.dispose();
_vadHandler = null;
}
}
void _onAmplitude(Amplitude event) { void _onAmplitude(Amplitude event) {
if (!state.voiceModeActive) return; if (!state.voiceModeActive) return;
final db = event.current; final db = event.current;
final validDb = !(db.isNaN || db.isInfinite); if (db.isNaN || db.isInfinite) return;
final norm = ((db + 60.0) / 60.0).clamp(0.0, 1.0);
// Normalize dB to 0..1 for the pulse animation. -60dB is dead-quiet, if ((norm - state.amplitude).abs() > 0.02) {
// 0dB is peak; we clamp and bias so the button visibly breathes even state = state.copyWith(amplitude: norm);
// on soft speech without exploding on loud input.
if (validDb) {
final norm = ((db + 60.0) / 60.0).clamp(0.0, 1.0);
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;
// 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;
if (_silenceMs >= _silenceDurationMs) {
_amplitudeSubscription?.cancel();
_amplitudeSubscription = null;
_handleSilence();
}
} else {
_silenceMs = 0;
} }
} }
+23 -7
View File
@@ -804,10 +804,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: record name: record
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6" sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.2.1" version: "6.2.0"
record_android: record_android:
dependency: transitive dependency: transitive
description: description:
@@ -816,22 +816,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.1" version: "1.5.1"
record_darwin: record_ios:
dependency: transitive dependency: transitive
description: description:
name: record_darwin name: record_ios
sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.2" version: "1.2.0"
record_linux: record_linux:
dependency: "direct overridden" dependency: transitive
description: description:
name: record_linux name: record_linux
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
record_platform_interface: record_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -1165,6 +1173,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.3" version: "4.5.3"
vad:
dependency: "direct main"
description:
name: vad
sha256: ef6c8b12c5af7a6a519ff5684f074b8a2ac00c434705f544af379ea77bccd258
url: "https://pub.dev"
source: hosted
version: "0.0.7+1"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
+2 -4
View File
@@ -28,13 +28,11 @@ dependencies:
google_fonts: ^8.0.2 google_fonts: ^8.0.2
flutter_timezone: ^5.0.2 flutter_timezone: ^5.0.2
url_launcher: ^6.3.1 url_launcher: ^6.3.1
record: ^5.0.0 record: ^6.2.0
vad: ^0.0.7
just_audio: ^0.9.39 just_audio: ^0.9.39
table_calendar: ^3.1.2 table_calendar: ^3.1.2
dependency_overrides:
record_linux: ^1.3.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter