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
+49 -59
View File
@@ -8,6 +8,7 @@ import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
import 'package:vad/vad.dart';
import 'api_client_provider.dart';
@@ -98,25 +99,13 @@ class VoiceNotifier extends Notifier<VoiceState> {
AudioPlayer? _player;
StreamSubscription<Amplitude>? _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;
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;
// VAD-based speech detection
VadHandler? _vadHandler;
StreamSubscription<void>? _vadSpeechStartSub;
StreamSubscription<List<double>>? _vadSpeechEndSub;
bool _speechDetected = false;
int _speechStartMs = 0;
static const _vadGraceMs = 1500;
// Voice mode callbacks
Future<void> Function(String transcript)? _onTranscript;
@@ -164,6 +153,9 @@ class VoiceNotifier extends Notifier<VoiceState> {
required void Function(String message) onError,
}) async {
if (state.voiceModeActive) {
if (state.mode == VoiceMode.recording && !_speechDetected) {
onError('No speech detected');
}
exitVoiceMode();
return;
}
@@ -211,12 +203,14 @@ class VoiceNotifier extends Notifier<VoiceState> {
_amplitudeSubscription?.cancel();
_amplitudeSubscription = null;
_recorder?.stop();
_stopVad();
_player?.stop();
_ttsQueue.clear();
_ttsPlaying = false;
_sentenceBuffer = '';
_lastSeenLength = 0;
_streamComplete = false;
_speechDetected = false;
_onTranscript = null;
_onError = null;
state = const VoiceState();
@@ -248,9 +242,8 @@ class VoiceNotifier extends Notifier<VoiceState> {
Future<void> _startListening() async {
if (!state.voiceModeActive) return;
_silenceMs = 0;
_peakDb = -100.0;
_recordingStartMs = DateTime.now().millisecondsSinceEpoch;
_speechDetected = false;
_speechStartMs = 0;
state = state.copyWith(mode: VoiceMode.recording);
final dir = _tempDir ?? await getTemporaryDirectory();
@@ -258,9 +251,6 @@ class VoiceNotifier extends Notifier<VoiceState> {
'${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
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 = AudioRecorder();
@@ -279,53 +269,53 @@ class VoiceNotifier extends Notifier<VoiceState> {
_amplitudeSubscription = _recorder!
.onAmplitudeChanged(const Duration(milliseconds: 200))
.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) {
_onError?.call('Microphone error: $e');
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) {
if (!state.voiceModeActive) return;
final db = event.current;
final validDb = !(db.isNaN || db.isInfinite);
// Normalize dB to 0..1 for the pulse animation. -60dB is dead-quiet,
// 0dB is peak; we clamp and bias so the button visibly breathes even
// on soft speech without exploding on loud input.
if (validDb) {
if (db.isNaN || db.isInfinite) return;
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;
}
}
Future<void> _handleSilence() async {
+23 -7
View File
@@ -804,10 +804,10 @@ packages:
dependency: "direct main"
description:
name: record
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6"
sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
url: "https://pub.dev"
source: hosted
version: "5.2.1"
version: "6.2.0"
record_android:
dependency: transitive
description:
@@ -816,22 +816,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.1"
record_darwin:
record_ios:
dependency: transitive
description:
name: record_darwin
sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c
name: record_ios
sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
version: "1.2.0"
record_linux:
dependency: "direct overridden"
dependency: transitive
description:
name: record_linux
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -1165,6 +1173,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
+2 -4
View File
@@ -28,13 +28,11 @@ dependencies:
google_fonts: ^8.0.2
flutter_timezone: ^5.0.2
url_launcher: ^6.3.1
record: ^5.0.0
record: ^6.2.0
vad: ^0.0.7
just_audio: ^0.9.39
table_calendar: ^3.1.2
dependency_overrides:
record_linux: ^1.3.0
dev_dependencies:
flutter_test:
sdk: flutter