fix(voice): fix silent STT failure — use AAC/M4A, store onError, guard NaN amplitude

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 17:39:21 -04:00
parent 03dc9108a3
commit d441dcf954
2 changed files with 29 additions and 14 deletions
+2 -2
View File
@@ -46,8 +46,8 @@ class VoiceApi {
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'audio': MultipartFile.fromBytes( 'audio': MultipartFile.fromBytes(
audioBytes, audioBytes,
filename: 'audio.webm', filename: 'audio.m4a',
contentType: DioMediaType('audio', 'webm'), contentType: DioMediaType('audio', 'mp4'),
), ),
}); });
final response = await _dio.post( final response = await _dio.post(
+27 -12
View File
@@ -100,6 +100,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
// Voice mode callbacks // Voice mode callbacks
Future<void> Function(String transcript)? _onTranscript; Future<void> Function(String transcript)? _onTranscript;
void Function(String message)? _onError;
bool _enableTts = false; bool _enableTts = false;
// Streaming TTS state // Streaming TTS state
@@ -165,6 +166,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
} }
_onTranscript = onTranscript; _onTranscript = onTranscript;
_onError = onError;
_enableTts = enableTts; _enableTts = enableTts;
_tempDir = await getTemporaryDirectory(); _tempDir = await getTemporaryDirectory();
@@ -184,6 +186,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
_lastSeenLength = 0; _lastSeenLength = 0;
_streamComplete = false; _streamComplete = false;
_onTranscript = null; _onTranscript = null;
_onError = null;
state = const VoiceState(); state = const VoiceState();
} }
@@ -217,18 +220,25 @@ class VoiceNotifier extends Notifier<VoiceState> {
state = state.copyWith(mode: VoiceMode.recording); state = state.copyWith(mode: VoiceMode.recording);
final dir = _tempDir ?? await getTemporaryDirectory(); 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 = final path =
'${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.webm'; '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
await _recorder!.start( try {
const RecordConfig(encoder: AudioEncoder.opus, sampleRate: 16000), await _recorder!.start(
path: path, const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000),
); path: path,
);
_amplitudeSubscription?.cancel(); _amplitudeSubscription?.cancel();
_amplitudeSubscription = _recorder! _amplitudeSubscription = _recorder!
.onAmplitudeChanged(const Duration(milliseconds: 200)) .onAmplitudeChanged(const Duration(milliseconds: 200))
.listen(_onAmplitude); .listen(_onAmplitude);
} catch (e) {
_onError?.call('Microphone error: could not start recording');
exitVoiceMode();
}
} }
void _onAmplitude(Amplitude event) { void _onAmplitude(Amplitude event) {
@@ -238,7 +248,12 @@ class VoiceNotifier extends Notifier<VoiceState> {
DateTime.now().millisecondsSinceEpoch - _recordingStartMs; DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
if (elapsed < _minRecordingMs) return; 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; _silenceMs += 200;
if (_silenceMs >= _silenceDurationMs) { if (_silenceMs >= _silenceDurationMs) {
_amplitudeSubscription?.cancel(); _amplitudeSubscription?.cancel();
@@ -289,8 +304,8 @@ class VoiceNotifier extends Notifier<VoiceState> {
if (!_enableTts && state.voiceModeActive) { if (!_enableTts && state.voiceModeActive) {
await _startListening(); await _startListening();
} }
} catch (_) { } catch (e) {
// Network/API error — exit voice mode _onError?.call('Voice error: transcription failed');
exitVoiceMode(); exitVoiceMode();
} }
} }