diff --git a/lib/data/api/voice_api.dart b/lib/data/api/voice_api.dart index 31d4eac..6f57378 100644 --- a/lib/data/api/voice_api.dart +++ b/lib/data/api/voice_api.dart @@ -15,8 +15,9 @@ class VoiceStatus { required this.tts, }); - /// True only when voice is enabled AND both STT and TTS are ready. - bool get fullyAvailable => enabled && stt && tts; + /// True when voice is enabled and at least STT is ready. + /// TTS is optional — voice mode works without it (STT-only). + bool get fullyAvailable => enabled && stt; factory VoiceStatus.fromJson(Map json) => VoiceStatus( enabled: json['enabled'] as bool? ?? false, diff --git a/lib/providers/knowledge_provider.dart b/lib/providers/knowledge_provider.dart index 86a4e26..a5b8404 100644 --- a/lib/providers/knowledge_provider.dart +++ b/lib/providers/knowledge_provider.dart @@ -246,6 +246,9 @@ class KnowledgeNotifier extends Notifier { isLoadingIds: false, 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 (_) { state = state.copyWith(isLoadingIds: false); } diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 26e9d64..5e55ad5 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -112,6 +112,9 @@ class VoiceNotifier extends Notifier { // to reduce STT mishearings of domain-specific words. String _lastAssistantContent = ''; + // Empty transcript counter — show feedback after consecutive blanks + int _emptyTranscriptCount = 0; + // TTS playback queue final _ttsQueue = Queue(); bool _ttsPlaying = false; @@ -120,7 +123,6 @@ class VoiceNotifier extends Notifier { @override VoiceState build() { - _recorder = AudioRecorder(); _player = AudioPlayer(); ref.onDispose(() { _amplitudeSubscription?.cancel(); @@ -146,32 +148,38 @@ class VoiceNotifier extends Notifier { return; } - // Check server availability + // Check server availability — STT is required, TTS is optional. try { final status = await ref.read(voiceRepositoryProvider).checkStatus(); - if (!status.fullyAvailable) { - onError('Voice not available on this server'); + if (!status.enabled || !status.stt) { + onError('Speech-to-text not available on this server'); return; } + // Downgrade to STT-only when TTS is unavailable + if (!status.tts) enableTts = false; } catch (_) { - onError('Voice not available on this server'); + onError('Could not reach voice service'); return; } // Check microphone permission - final permStatus = await Permission.microphone.request(); - if (permStatus == PermissionStatus.denied || - permStatus == PermissionStatus.permanentlyDenied) { + var permStatus = await Permission.microphone.request(); + if (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'); - if (permStatus == PermissionStatus.permanentlyDenied) { - await openAppSettings(); - } return; } _onTranscript = onTranscript; _onError = onError; _enableTts = enableTts; + _emptyTranscriptCount = 0; _tempDir = await getTemporaryDirectory(); state = state.copyWith(voiceModeActive: true, available: true); @@ -225,12 +233,22 @@ 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}.m4a'; 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( const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000), path: path, @@ -241,7 +259,7 @@ class VoiceNotifier extends Notifier { .onAmplitudeChanged(const Duration(milliseconds: 200)) .listen(_onAmplitude); } catch (e) { - _onError?.call('Microphone error: could not start recording'); + _onError?.call('Microphone error: $e'); exitVoiceMode(); } } @@ -291,10 +309,16 @@ class VoiceNotifier extends Notifier { if (!state.voiceModeActive) return; 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(); return; } + _emptyTranscriptCount = 0; // Reset TTS state for this new turn _sentenceBuffer = '';