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:
@@ -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<String, dynamic> json) => VoiceStatus(
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
|
||||
@@ -246,6 +246,9 @@ class KnowledgeNotifier extends Notifier<KnowledgeState> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
// 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<Uint8List>();
|
||||
bool _ttsPlaying = false;
|
||||
@@ -120,7 +123,6 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
|
||||
@override
|
||||
VoiceState build() {
|
||||
_recorder = AudioRecorder();
|
||||
_player = AudioPlayer();
|
||||
ref.onDispose(() {
|
||||
_amplitudeSubscription?.cancel();
|
||||
@@ -146,32 +148,38 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
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<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}.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<VoiceState> {
|
||||
.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<VoiceState> {
|
||||
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 = '';
|
||||
|
||||
Reference in New Issue
Block a user