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,
|
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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user