From d441dcf954f0b2af194f60696e5f551503c4caf0 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 17:39:21 -0400 Subject: [PATCH] =?UTF-8?q?fix(voice):=20fix=20silent=20STT=20failure=20?= =?UTF-8?q?=E2=80=94=20use=20AAC/M4A,=20store=20onError,=20guard=20NaN=20a?= =?UTF-8?q?mplitude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs causing silent STT failure on Android: 1. AudioEncoder.opus produces an OGG container on Android but the file was named .webm — faster-whisper rejected it due to format mismatch. Changed to AudioEncoder.aacLc + .m4a (reliable on all Android versions). Updated voice_api.dart to send audio/mp4 MIME type accordingly. 2. onError callback was never stored, so errors in _startListening() and _handleSilence() were silently swallowed. Now stored as _onError and called before exitVoiceMode() on any failure. 3. Amplitude stream can emit NaN or ±Infinity on some Android devices during recorder initialisation. NaN < -40.0 is false, so silence was never detected. Now treated explicitly as silence. Co-Authored-By: Claude Sonnet 4.6 --- lib/data/api/voice_api.dart | 4 ++-- lib/providers/voice_provider.dart | 39 +++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/lib/data/api/voice_api.dart b/lib/data/api/voice_api.dart index 42e3dad..6fed7b4 100644 --- a/lib/data/api/voice_api.dart +++ b/lib/data/api/voice_api.dart @@ -46,8 +46,8 @@ class VoiceApi { final formData = FormData.fromMap({ 'audio': MultipartFile.fromBytes( audioBytes, - filename: 'audio.webm', - contentType: DioMediaType('audio', 'webm'), + filename: 'audio.m4a', + contentType: DioMediaType('audio', 'mp4'), ), }); final response = await _dio.post( diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 5f1002a..9d7cf80 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -100,6 +100,7 @@ class VoiceNotifier extends Notifier { // Voice mode callbacks Future Function(String transcript)? _onTranscript; + void Function(String message)? _onError; bool _enableTts = false; // Streaming TTS state @@ -165,6 +166,7 @@ class VoiceNotifier extends Notifier { } _onTranscript = onTranscript; + _onError = onError; _enableTts = enableTts; _tempDir = await getTemporaryDirectory(); @@ -184,6 +186,7 @@ class VoiceNotifier extends Notifier { _lastSeenLength = 0; _streamComplete = false; _onTranscript = null; + _onError = null; state = const VoiceState(); } @@ -217,18 +220,25 @@ class VoiceNotifier extends Notifier { state = state.copyWith(mode: VoiceMode.recording); final dir = _tempDir ?? await getTemporaryDirectory(); + // AAC/M4A is reliably supported on Android (unlike WebM/Opus which can + // produce OGG bytes in a .webm file, confusing server-side decoders). final path = - '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.webm'; + '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a'; - await _recorder!.start( - const RecordConfig(encoder: AudioEncoder.opus, sampleRate: 16000), - path: path, - ); + try { + await _recorder!.start( + const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000), + path: path, + ); - _amplitudeSubscription?.cancel(); - _amplitudeSubscription = _recorder! - .onAmplitudeChanged(const Duration(milliseconds: 200)) - .listen(_onAmplitude); + _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder! + .onAmplitudeChanged(const Duration(milliseconds: 200)) + .listen(_onAmplitude); + } catch (e) { + _onError?.call('Microphone error: could not start recording'); + exitVoiceMode(); + } } void _onAmplitude(Amplitude event) { @@ -238,7 +248,12 @@ class VoiceNotifier extends Notifier { DateTime.now().millisecondsSinceEpoch - _recordingStartMs; if (elapsed < _minRecordingMs) return; - if (event.current < _silenceThresholdDb) { + final db = event.current; + // Guard against NaN / ±Infinity which can arrive on some Android devices + // when the recorder is initialising. Treat invalid readings as silence. + final isSilent = db.isNaN || db.isInfinite || db < _silenceThresholdDb; + + if (isSilent) { _silenceMs += 200; if (_silenceMs >= _silenceDurationMs) { _amplitudeSubscription?.cancel(); @@ -289,8 +304,8 @@ class VoiceNotifier extends Notifier { if (!_enableTts && state.voiceModeActive) { await _startListening(); } - } catch (_) { - // Network/API error — exit voice mode + } catch (e) { + _onError?.call('Voice error: transcription failed'); exitVoiceMode(); } }