main
dart 275 lines 8.66 KB
Raw
1 import 'dart:async';
2
3 import 'package:flutter/foundation.dart';
4 import 'package:flutter/services.dart';
5 import 'package:flutter_riverpod/flutter_riverpod.dart';
6 import 'package:speech_to_text/speech_to_text.dart';
7
8 import '../../../core/logger/app_logger.dart';
9 import '../utils/stt_error_messages.dart';
10
11 /// Double in dB-ish units (iOS) or 0..1-ish (Android) reported by the
12 /// speech_to_text plugin via `onSoundLevelChange`. Higher = louder.
13 typedef SoundLevelChange = void Function(double level);
14
15 class SttState {
16 final bool isListening;
17 final bool isAvailable;
18 final String recognizedWords;
19 final String? error;
20
21 const SttState({
22 this.isListening = false,
23 this.isAvailable = false,
24 this.recognizedWords = '',
25 this.error,
26 });
27
28 SttState copyWith({
29 bool? isListening,
30 bool? isAvailable,
31 String? recognizedWords,
32 String? error,
33 bool clearError = false,
34 }) {
35 return SttState(
36 isListening: isListening ?? this.isListening,
37 isAvailable: isAvailable ?? this.isAvailable,
38 recognizedWords: recognizedWords ?? this.recognizedWords,
39 error: clearError ? null : (error ?? this.error),
40 );
41 }
42 }
43
44 class SttNotifier extends Notifier<SttState> {
45 SttNotifier({SpeechToText Function()? speechFactory})
46 : _speechFactory = speechFactory ?? SpeechToText.new;
47
48 /// Delay before re-listening after an `error_client`, giving the platform
49 /// recognizer time to release the previous session.
50 static const clientErrorRetryDelay = Duration(milliseconds: 300);
51
52 final SpeechToText Function() _speechFactory;
53 late SpeechToText _speech;
54 bool _isInit = false;
55 Future<bool>? _initialization;
56 // Monotonically increasing counter; status callbacks from a previous
57 // session cannot clobber state set by a newer one.
58 int _session = 0;
59 // Snapshot of the most recently issued session id. Set to -1 when no
60 // listen() is in flight.
61 int _activeSession = -1;
62 // Re-issues the current listen() with the same callbacks. Null when no
63 // listen is in flight.
64 Future<void> Function()? _relisten;
65 bool _retriedClientError = false;
66 // Android: use the on-device recognizer instead of the default service.
67 // Flipped after an `error_client`, which is what devices without Google
68 // speech services (e.g. GrapheneOS) report for the default one (#100).
69 bool _useOnDeviceRecognizer = false;
70 bool _switchedRecognizerForRetry = false;
71
72 @override
73 SttState build() {
74 _speech = _speechFactory();
75 ref.onDispose(() {
76 try {
77 _speech.cancel();
78 } catch (e) {
79 Log.error('STT dispose/cancel error: $e');
80 }
81 });
82 return const SttState();
83 }
84
85 Future<bool> initSpeech() {
86 if (_isInit) return Future.value(state.isAvailable);
87 return _initialization ??= _initializeSpeech();
88 }
89
90 Future<bool> _initializeSpeech() async {
91 try {
92 final available = await _speech.initialize(
93 onError: (val) {
94 if (!ref.mounted) return;
95 Log.error('STT error: ${val.errorMsg} - permanent: ${val.permanent}');
96 if (val.errorMsg == 'error_client' && _retryAfterClientError()) {
97 return;
98 }
99 if (_switchedRecognizerForRetry) {
100 // The other recognizer didn't help either; go back to the
101 // original one for the next attempt.
102 _useOnDeviceRecognizer = !_useOnDeviceRecognizer;
103 _switchedRecognizerForRetry = false;
104 }
105 state = state.copyWith(error: val.errorMsg, isListening: false);
106 },
107 onStatus: (val) {
108 if (!ref.mounted) return;
109 Log.debug('STT status: $val');
110 // Only honour status updates from the active session; stale
111 // callbacks from a previous listen() (or callbacks fired while
112 // we've already stopped the current one) must not clobber state.
113 if (_activeSession != _session) return;
114 if (val == 'listening') {
115 state = state.copyWith(isListening: true);
116 } else if (val == 'notListening' || val == 'done') {
117 state = state.copyWith(isListening: false);
118 }
119 },
120 options: [SpeechToText.androidNoBluetooth],
121 );
122 if (!ref.mounted) return false;
123 // A denied permission must remain retryable after the user changes it
124 // in Android Settings. Do not cache an unsuccessful initialization.
125 _isInit = available;
126 state = state.copyWith(isAvailable: available, clearError: available);
127 return available;
128 } catch (e) {
129 if (!ref.mounted) return false;
130 Log.error('STT initialization failed: $e');
131 final noRecognizer =
132 e is PlatformException && e.code == 'recognizerNotAvailable';
133 state = state.copyWith(
134 isAvailable: false,
135 error: noRecognizer ? sttUnavailableCode : e.toString(),
136 );
137 return false;
138 } finally {
139 _initialization = null;
140 }
141 }
142
143 Future<void> startListening({
144 required void Function(String) onResult,
145 void Function(String)? onFinal,
146 SoundLevelChange? onSoundLevelChange,
147 }) async {
148 final available = await initSpeech();
149 if (!ref.mounted) return;
150 if (!available) {
151 state = state.copyWith(
152 error: 'Speech recognition not available or permission denied',
153 );
154 return;
155 }
156
157 state = state.copyWith(
158 isListening: true,
159 recognizedWords: '',
160 clearError: true,
161 );
162 _retriedClientError = false;
163 _switchedRecognizerForRetry = false;
164 _relisten = () => _listen(
165 onResult: onResult,
166 onFinal: onFinal,
167 onSoundLevelChange: onSoundLevelChange,
168 );
169 await _relisten!();
170 }
171
172 Future<void> _listen({
173 required void Function(String) onResult,
174 void Function(String)? onFinal,
175 SoundLevelChange? onSoundLevelChange,
176 }) async {
177 _session++;
178 _activeSession = _session;
179
180 try {
181 await _speech.listen(
182 onResult: (result) {
183 if (!ref.mounted) return;
184 state = state.copyWith(recognizedWords: result.recognizedWords);
185 onResult(result.recognizedWords);
186 if (result.finalResult && onFinal != null) {
187 onFinal(result.recognizedWords);
188 }
189 },
190 onSoundLevelChange: (level) {
191 // iOS reports negative dB (-2 quiet, 10 loud), Android ~0..1.
192 final normalised = level <= 0
193 ? ((level + 2) / 12).clamp(0.0, 1.0)
194 : level.clamp(0.0, 1.0);
195 onSoundLevelChange?.call(normalised);
196 },
197 listenOptions: SpeechListenOptions(
198 listenFor: const Duration(seconds: 30),
199 pauseFor: const Duration(seconds: 5),
200 partialResults: true,
201 cancelOnError: true,
202 onDevice: _useOnDeviceRecognizer,
203 ),
204 );
205 } catch (e) {
206 if (!ref.mounted) return;
207 Log.error('STT listen error: $e');
208 state = state.copyWith(isListening: false, error: e.toString());
209 }
210 }
211
212 /// Retries the current listen once after an `error_client`. On Android the
213 /// retry switches between the default and the on-device recognizer.
214 /// Returns false when the error should be surfaced instead.
215 bool _retryAfterClientError() {
216 final relisten = _relisten;
217 if (relisten == null || _retriedClientError) return false;
218 _retriedClientError = true;
219 if (defaultTargetPlatform == TargetPlatform.android) {
220 _useOnDeviceRecognizer = !_useOnDeviceRecognizer;
221 _switchedRecognizerForRetry = true;
222 }
223 Log.warning(
224 'STT error_client, retrying once '
225 '(onDevice: $_useOnDeviceRecognizer)',
226 );
227 final session = _session;
228 unawaited(() async {
229 try {
230 await _speech.cancel();
231 } catch (e) {
232 Log.error('STT cancel before retry failed: $e');
233 }
234 await Future<void>.delayed(clientErrorRetryDelay);
235 // Bail if the caller stopped or restarted listening meanwhile.
236 if (!ref.mounted || _session != session || _relisten != relisten) {
237 return;
238 }
239 await relisten();
240 }());
241 return true;
242 }
243
244 Future<void> stopListening() async {
245 // Invalidate the current session so any in-flight status callbacks
246 // are ignored.
247 _relisten = null;
248 _session++;
249 _activeSession = -1;
250 try {
251 await _speech.stop();
252 if (!ref.mounted) return;
253 state = state.copyWith(isListening: false);
254 } catch (e) {
255 Log.error('STT stop error: $e');
256 }
257 }
258
259 Future<void> cancelListening() async {
260 _relisten = null;
261 _session++;
262 _activeSession = -1;
263 try {
264 await _speech.cancel();
265 if (!ref.mounted) return;
266 state = state.copyWith(isListening: false);
267 } catch (e) {
268 Log.error('STT cancel error: $e');
269 }
270 }
271 }
272
273 final sttProvider = NotifierProvider<SttNotifier, SttState>(() {
274 return SttNotifier();
275 });