Merge pull request 'Release v26.04.16.1 — Silero VAD voice detection' (#26) from dev into main

This commit was merged in pull request #26.
This commit is contained in:
2026-04-17 01:00:54 +00: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