diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 1887ae1..2cd07d0 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -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 { AudioPlayer? _player; 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; - 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? _vadSpeechStartSub; + StreamSubscription>? _vadSpeechEndSub; + bool _speechDetected = false; + int _speechStartMs = 0; + static const _vadGraceMs = 1500; // Voice mode callbacks Future Function(String transcript)? _onTranscript; @@ -164,6 +153,9 @@ class VoiceNotifier extends Notifier { 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 { _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 { Future _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 { '${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,52 +269,52 @@ class VoiceNotifier extends Notifier { _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 _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) { - 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; + 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); } } diff --git a/pubspec.lock b/pubspec.lock index 5f15306..5c8f215 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index b76d5f8..a618b73 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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