fix(voice,knowledge): mic recording + pagination + STT-only mode

Voice: recreate AudioRecorder each session to avoid stale native
state, improve permission handling (re-check after settings), show
feedback after 3 consecutive empty transcripts, allow STT-only mode
when TTS is unavailable.

Knowledge: hydrate IDs immediately after loading more pages so
scroll-based pagination doesn't stall at the bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 11:34:00 -04:00
parent 36644cf8a5
commit 4240c90d55
3 changed files with 45 additions and 17 deletions
+3 -2
View File
@@ -15,8 +15,9 @@ class VoiceStatus {
required this.tts, required this.tts,
}); });
/// True only when voice is enabled AND both STT and TTS are ready. /// True when voice is enabled and at least STT is ready.
bool get fullyAvailable => enabled && stt && tts; /// TTS is optional — voice mode works without it (STT-only).
bool get fullyAvailable => enabled && stt;
factory VoiceStatus.fromJson(Map<String, dynamic> json) => VoiceStatus( factory VoiceStatus.fromJson(Map<String, dynamic> json) => VoiceStatus(
enabled: json['enabled'] as bool? ?? false, enabled: json['enabled'] as bool? ?? false,
+3
View File
@@ -246,6 +246,9 @@ class KnowledgeNotifier extends Notifier<KnowledgeState> {
isLoadingIds: false, isLoadingIds: false,
hasMore: combined.length < total, hasMore: combined.length < total,
); );
// Hydrate the newly fetched IDs immediately — the user is
// already at the scroll bottom so _onScroll won't re-fire.
await hydrateNext();
} catch (_) { } catch (_) {
state = state.copyWith(isLoadingIds: false); state = state.copyWith(isLoadingIds: false);
} }
+39 -15
View File
@@ -112,6 +112,9 @@ class VoiceNotifier extends Notifier<VoiceState> {
// to reduce STT mishearings of domain-specific words. // to reduce STT mishearings of domain-specific words.
String _lastAssistantContent = ''; String _lastAssistantContent = '';
// Empty transcript counter — show feedback after consecutive blanks
int _emptyTranscriptCount = 0;
// TTS playback queue // TTS playback queue
final _ttsQueue = Queue<Uint8List>(); final _ttsQueue = Queue<Uint8List>();
bool _ttsPlaying = false; bool _ttsPlaying = false;
@@ -120,7 +123,6 @@ class VoiceNotifier extends Notifier<VoiceState> {
@override @override
VoiceState build() { VoiceState build() {
_recorder = AudioRecorder();
_player = AudioPlayer(); _player = AudioPlayer();
ref.onDispose(() { ref.onDispose(() {
_amplitudeSubscription?.cancel(); _amplitudeSubscription?.cancel();
@@ -146,32 +148,38 @@ class VoiceNotifier extends Notifier<VoiceState> {
return; return;
} }
// Check server availability // Check server availability — STT is required, TTS is optional.
try { try {
final status = await ref.read(voiceRepositoryProvider).checkStatus(); final status = await ref.read(voiceRepositoryProvider).checkStatus();
if (!status.fullyAvailable) { if (!status.enabled || !status.stt) {
onError('Voice not available on this server'); onError('Speech-to-text not available on this server');
return; return;
} }
// Downgrade to STT-only when TTS is unavailable
if (!status.tts) enableTts = false;
} catch (_) { } catch (_) {
onError('Voice not available on this server'); onError('Could not reach voice service');
return; return;
} }
// Check microphone permission // Check microphone permission
final permStatus = await Permission.microphone.request(); var permStatus = await Permission.microphone.request();
if (permStatus == PermissionStatus.denied || if (permStatus == PermissionStatus.permanentlyDenied) {
permStatus == PermissionStatus.permanentlyDenied) { onError('Microphone blocked — opening settings');
final opened = await openAppSettings();
if (!opened) return;
// Re-check after user returns from settings
permStatus = await Permission.microphone.status;
}
if (!permStatus.isGranted) {
onError('Microphone permission required'); onError('Microphone permission required');
if (permStatus == PermissionStatus.permanentlyDenied) {
await openAppSettings();
}
return; return;
} }
_onTranscript = onTranscript; _onTranscript = onTranscript;
_onError = onError; _onError = onError;
_enableTts = enableTts; _enableTts = enableTts;
_emptyTranscriptCount = 0;
_tempDir = await getTemporaryDirectory(); _tempDir = await getTemporaryDirectory();
state = state.copyWith(voiceModeActive: true, available: true); state = state.copyWith(voiceModeActive: true, available: true);
@@ -225,12 +233,22 @@ 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}.m4a'; '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
try { 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();
if (!await _recorder!.hasPermission()) {
_onError?.call('Microphone permission was revoked');
exitVoiceMode();
return;
}
await _recorder!.start( await _recorder!.start(
const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000), const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000),
path: path, path: path,
@@ -241,7 +259,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
.onAmplitudeChanged(const Duration(milliseconds: 200)) .onAmplitudeChanged(const Duration(milliseconds: 200))
.listen(_onAmplitude); .listen(_onAmplitude);
} catch (e) { } catch (e) {
_onError?.call('Microphone error: could not start recording'); _onError?.call('Microphone error: $e');
exitVoiceMode(); exitVoiceMode();
} }
} }
@@ -291,10 +309,16 @@ class VoiceNotifier extends Notifier<VoiceState> {
if (!state.voiceModeActive) return; if (!state.voiceModeActive) return;
if (transcript.isEmpty) { if (transcript.isEmpty) {
// Empty transcript — restart silently _emptyTranscriptCount++;
if (_emptyTranscriptCount >= 3) {
_onError?.call('No speech detected — tap the mic to try again');
exitVoiceMode();
return;
}
await _startListening(); await _startListening();
return; return;
} }
_emptyTranscriptCount = 0;
// Reset TTS state for this new turn // Reset TTS state for this new turn
_sentenceBuffer = ''; _sentenceBuffer = '';