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({
'audio': MultipartFile.fromBytes(
audioBytes,
filename: 'audio.webm',
contentType: DioMediaType('audio', 'webm'),
filename: 'audio.m4a',
contentType: DioMediaType('audio', 'mp4'),
),
});
final response = await _dio.post(
+27 -12
View File
@@ -100,6 +100,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
// Voice mode callbacks
Future<void> Function(String transcript)? _onTranscript;
void Function(String message)? _onError;
bool _enableTts = false;
// Streaming TTS state
@@ -165,6 +166,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
}
_onTranscript = onTranscript;
_onError = onError;
_enableTts = enableTts;
_tempDir = await getTemporaryDirectory();
@@ -184,6 +186,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
_lastSeenLength = 0;
_streamComplete = false;
_onTranscript = null;
_onError = null;
state = const VoiceState();
}
@@ -217,18 +220,25 @@ class VoiceNotifier extends Notifier<VoiceState> {
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<VoiceState> {
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<VoiceState> {
if (!_enableTts && state.voiceModeActive) {
await _startListening();
}
} catch (_) {
// Network/API error — exit voice mode
} catch (e) {
_onError?.call('Voice error: transcription failed');
exitVoiceMode();
}
}