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:
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user