Repository navigation
Expand file tree
/
Copy pathuse-speech-capture.ts
More file actions
255 lines (233 loc) · 9.56 KB
/
Copy pathuse-speech-capture.ts
File metadata and controls
255 lines (233 loc) · 9.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
import { useCallback, useEffect, useRef, useState } from 'react';
import { toIntlLocale, type AppLanguage } from '@/lib/languages';
import { LONG_PAUSE_SECONDS, type SpeechTake } from '@/lib/voice/delivery';
import { useVoiceAvailabilityStore } from '@/lib/voice/availability';
// Speech capture for a spoken turn. It is a sibling of useSpeechToText, which
// serves short dictation: a turn can run for minutes, so this hook restarts
// recognition when the browser ends it on silence, shows interim words, and
// times the take. Audio goes from the microphone to the browser's recogniser
// only. DebateLab never receives or stores it.
type Recognition = InstanceType<typeof window.SpeechRecognition>;
export type SpeechCaptureError = 'not-allowed' | 'no-microphone' | 'network' | 'start-failed';
const STOP_GRACE_MS = 1_500;
/** A recogniser that keeps dying this quickly is not going to recover. */
const MIN_HEALTHY_SESSION_MS = 750;
const MAX_QUICK_FAILURES = 3;
function emptyTake(): SpeechTake {
return { text: '', speakingMs: 0, pausesMs: [] };
}
export interface SpeechCapture {
listening: boolean;
/** Words heard but not yet final. */
interim: string;
error: SpeechCaptureError | null;
start: () => Promise<boolean>;
/** Stops listening and resolves once the last words have arrived. */
stop: () => Promise<void>;
/**
* Drops everything: a start still waiting for permission, the open
* recogniser, pending words, and the take. For when a turn or a round ends
* some other way than by submitting what was said.
*/
cancel: () => void;
/** Everything captured since the last reset, across pauses and restarts. */
getTake: () => SpeechTake;
resetTake: () => void;
}
export function useSpeechCapture(language: AppLanguage, onFinalText: (text: string) => void): SpeechCapture {
const [listening, setListening] = useState(false);
const [interim, setInterim] = useState('');
const [error, setError] = useState<SpeechCaptureError | null>(null);
const recognitionRef = useRef<Recognition | null>(null);
const wantListeningRef = useRef(false);
const takeRef = useRef<SpeechTake>(emptyTake());
const startedAtRef = useRef(0);
const sessionStartedAtRef = useRef(0);
const lastResultAtRef = useRef<number | null>(null);
const interimRef = useRef('');
const quickFailuresRef = useRef(0);
const stoppedRef = useRef<(() => void) | null>(null);
const stoppingRef = useRef<Promise<void> | null>(null);
// Bumped by every start, stop, and cancel, so a start that was waiting for
// the permission prompt can tell it is no longer wanted.
const attemptRef = useRef(0);
const onFinalTextRef = useRef(onFinalText);
onFinalTextRef.current = onFinalText;
const commitFinal = useCallback((text: string) => {
const clean = text.trim();
if (!clean) return;
takeRef.current.text = takeRef.current.text ? `${takeRef.current.text} ${clean}` : clean;
onFinalTextRef.current(clean);
}, []);
const finishListening = useCallback(() => {
if (startedAtRef.current) takeRef.current.speakingMs += Date.now() - startedAtRef.current;
startedAtRef.current = 0;
// Words still marked interim when the recogniser closes were spoken.
commitFinal(interimRef.current);
interimRef.current = '';
setInterim('');
wantListeningRef.current = false;
recognitionRef.current = null;
setListening(false);
stoppedRef.current?.();
stoppedRef.current = null;
}, [commitFinal]);
const fail = useCallback((reason: SpeechCaptureError) => {
setError(reason);
if (reason === 'not-allowed') useVoiceAvailabilityStore.getState().markMicrophoneDenied();
const recognition = recognitionRef.current;
if (recognition) {
recognition.onend = () => {};
try { recognition.abort(); } catch { /* already closed */ }
}
finishListening();
}, [finishListening]);
const start = useCallback(async () => {
const Constructor = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!Constructor || wantListeningRef.current) return false;
setError(null);
wantListeningRef.current = true;
const attempt = ++attemptRef.current;
try {
// Ask for the microphone up front so a refusal is reported clearly.
if (navigator.mediaDevices?.getUserMedia) {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((track) => track.stop());
}
} catch (cause) {
if (attempt !== attemptRef.current) return false;
const name = cause instanceof DOMException ? cause.name : '';
wantListeningRef.current = false;
const reason: SpeechCaptureError = name === 'NotAllowedError' || name === 'PermissionDeniedError'
? 'not-allowed'
: name === 'NotFoundError' ? 'no-microphone' : 'start-failed';
setError(reason);
if (reason === 'not-allowed') useVoiceAvailabilityStore.getState().markMicrophoneDenied();
return false;
}
// The turn was submitted or ended while the permission prompt was open.
if (attempt !== attemptRef.current) return false;
const recognition = new Constructor();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = toIntlLocale(language);
recognition.onresult = (event) => {
const now = Date.now();
const last = lastResultAtRef.current;
if (last !== null && now - last >= LONG_PAUSE_SECONDS * 1_000) takeRef.current.pausesMs.push(now - last);
lastResultAtRef.current = now;
let pending = '';
for (let index = event.resultIndex; index < event.results.length; index += 1) {
const result = event.results[index];
if (result.isFinal) commitFinal(result[0].transcript);
else pending += result[0].transcript;
}
interimRef.current = pending;
setInterim(pending.trim());
};
recognition.onerror = (event) => {
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') fail('not-allowed');
else if (event.error === 'audio-capture') fail('no-microphone');
else if (event.error === 'network') fail('network');
// 'no-speech' and 'aborted' end the session; onend decides what follows.
};
recognition.onend = () => {
if (!wantListeningRef.current || recognitionRef.current !== recognition) {
finishListening();
return;
}
// The browser closed the session (silence, or its own time limit) while
// the student is still on the clock. Carry on listening.
commitFinal(interimRef.current);
interimRef.current = '';
setInterim('');
quickFailuresRef.current = Date.now() - sessionStartedAtRef.current < MIN_HEALTHY_SESSION_MS
? quickFailuresRef.current + 1
: 0;
if (quickFailuresRef.current >= MAX_QUICK_FAILURES) {
fail('start-failed');
return;
}
try {
sessionStartedAtRef.current = Date.now();
recognition.start();
} catch {
fail('start-failed');
}
};
recognitionRef.current = recognition;
quickFailuresRef.current = 0;
lastResultAtRef.current = null;
try {
startedAtRef.current = Date.now();
sessionStartedAtRef.current = startedAtRef.current;
recognition.start();
} catch {
fail('start-failed');
return false;
}
setListening(true);
return true;
}, [commitFinal, fail, finishListening, language]);
const stop = useCallback((): Promise<void> => {
// A second caller (the Stop button, then Submit) joins the stop in progress.
if (stoppingRef.current) return stoppingRef.current;
const recognition = recognitionRef.current;
wantListeningRef.current = false;
attemptRef.current += 1;
if (!recognition) return Promise.resolve();
// The recogniser delivers its last words after stop(), then ends.
const stopping = new Promise<void>((resolve) => {
const timeout = window.setTimeout(() => {
recognition.onend = () => {};
try { recognition.abort(); } catch { /* already closed */ }
finishListening();
}, STOP_GRACE_MS);
stoppedRef.current = () => {
window.clearTimeout(timeout);
stoppingRef.current = null;
resolve();
};
try {
recognition.stop();
} catch {
finishListening();
}
});
// finishListening may already have run and cleared the marker.
if (stoppedRef.current) stoppingRef.current = stopping;
return stopping;
}, [finishListening]);
const cancel = useCallback(() => {
attemptRef.current += 1;
wantListeningRef.current = false;
const recognition = recognitionRef.current;
recognitionRef.current = null;
if (recognition) {
recognition.onresult = () => {};
recognition.onerror = () => {};
recognition.onend = () => {};
try { recognition.abort(); } catch { /* already closed */ }
}
startedAtRef.current = 0;
interimRef.current = '';
lastResultAtRef.current = null;
takeRef.current = emptyTake();
setInterim('');
setListening(false);
// Anyone waiting on a stop is released; there is nothing left to wait for.
stoppedRef.current?.();
stoppedRef.current = null;
}, []);
const getTake = useCallback((): SpeechTake => ({
...takeRef.current,
pausesMs: [...takeRef.current.pausesMs],
speakingMs: takeRef.current.speakingMs + (startedAtRef.current ? Date.now() - startedAtRef.current : 0),
}), []);
const resetTake = useCallback(() => {
takeRef.current = emptyTake();
lastResultAtRef.current = null;
}, []);
useEffect(() => cancel, [cancel]);
return { listening, interim, error, start, stop, cancel, getTake, resetTake };
}