1import { replaceTextRange } from "@t3tools/shared/composerTrigger";
2
3import type { PreparedVoiceTranscription, VoiceTranscriber } from "./transcription.ts";
4
5export const VOICE_RECORDING_LIMIT_SECONDS = 5 * 60;
6
7export type VoiceInputPhase = "idle" | "preparing" | "recording" | "transcribing" | "error";
8
9export type VoiceInputState = {
10 readonly phase: VoiceInputPhase;
11 readonly error: string | null;
12 readonly errorAction: "retry" | "settings" | null;
13};
14
15export function voiceInputBlocksSubmission(state: VoiceInputState): boolean {
16 return (
17 state.phase === "preparing" || state.phase === "recording" || state.phase === "transcribing"
18 );
19}
20
21export function voiceInputFreezesEditor(state: VoiceInputState): boolean {
22 return voiceInputBlocksSubmission(state);
23}
24
25export type VoiceDraftSnapshot = {
26 readonly ownerKey: string;
27 readonly text: string;
28 readonly selection: { readonly start: number; readonly end: number };
29 readonly revision: number;
30};
31
32export type VoiceRecorderStatus = {
33 readonly isFinished: boolean;
34 readonly hasError: boolean;
35 readonly error: string | null;
36 readonly url: string | null;
37};
38
39export interface VoiceRecorder {
40 readonly uri: string | null;
41 prepareToRecordAsync(): Promise<void>;
42 record(options: { readonly forDuration: number }): void;
43 stop(): Promise<void>;
44}
45
46export type VoiceInputControllerDependencies = {
47 readonly recorder: VoiceRecorder;
48 readonly getTranscriber: () => VoiceTranscriber | null;
49 readonly requestPermission: () => Promise<{
50 readonly granted: boolean;
51 readonly canAskAgain: boolean;
52 }>;
53 readonly configureRecording: () => Promise<void>;
54 readonly releaseRecording: () => Promise<void>;
55 readonly deleteRecording: (uri: string) => void;
56 readonly readDraft: () => VoiceDraftSnapshot | null;
57 readonly commitDraft: (
58 text: string,
59 selection: { readonly start: number; readonly end: number },
60 ) => void;
61 readonly onStateChange: (state: VoiceInputState) => void;
62};
63
64type TranscriptCommitResult =
65 | {
66 readonly kind: "commit";
67 readonly text: string;
68 readonly selection: { readonly start: number; readonly end: number };
69 }
70 | { readonly kind: "stale" }
71 | { readonly kind: "empty" };
72
73export function resolveTranscriptCommit(
74 captured: VoiceDraftSnapshot,
75 current: VoiceDraftSnapshot | null,
76 transcript: string,
77 locale: string,
78): TranscriptCommitResult {
79 if (
80 !current ||
81 current.ownerKey !== captured.ownerKey ||
82 current.text !== captured.text ||
83 current.revision !== captured.revision
84 ) {
85 return { kind: "stale" };
86 }
87
88 const replacement = transcript.trim();
89 if (replacement.length === 0) {
90 return { kind: "empty" };
91 }
92
93 const isEmptySelection = captured.selection.start === captured.selection.end;
94 const normalizedLocale = locale.replaceAll("_", "-").toLowerCase();
95 const usesEnglishSpacing = normalizedLocale === "en" || normalizedLocale.startsWith("en-");
96 let insertion = replacement;
97 if (isEmptySelection && usesEnglishSpacing) {
98 const left = captured.text[captured.selection.start - 1];
99 const right = captured.text[captured.selection.start];
100 const leftNeedsBoundary =
101 left !== undefined &&
102 /[A-Za-z0-9.!?,:;)\]}'"]/.test(left) &&
103 (right === undefined || /\s/.test(right));
104 const rightNeedsBoundary =
105 right !== undefined &&
106 /[A-Za-z0-9([{'"]/.test(right) &&
107 (left === undefined || /\s/.test(left));
108 insertion = `${leftNeedsBoundary ? " " : ""}${replacement}${rightNeedsBoundary ? " " : ""}`;
109 }
110
111 const result = replaceTextRange(
112 captured.text,
113 captured.selection.start,
114 captured.selection.end,
115 insertion,
116 );
117 return {
118 kind: "commit",
119 text: result.text,
120 selection: { start: result.cursor, end: result.cursor },
121 };
122}
123
124type VoiceInputSession = {
125 abandoned: boolean;
126 readonly released: Promise<void>;
127 readonly release: () => void;
128};
129
130let activeSession: VoiceInputSession | null = null;
131let activeTranscriptionOperation: Promise<unknown> | null = null;
132
133function acquireSession(): VoiceInputSession | null {
134 if (activeSession) return null;
135 const { promise, resolve } = Promise.withResolvers<void>();
136 const token = { abandoned: false, released: promise, release: resolve };
137 activeSession = token;
138 return token;
139}
140
141function releaseSession(token: VoiceInputSession | null): void {
142 if (token && activeSession === token) {
143 activeSession = null;
144 token.release();
145 }
146}
147
148async function runTranscriptionOperation<T>(operation: () => Promise<T>): Promise<T> {
149 if (activeTranscriptionOperation) {
150 throw new Error("voice-operation-busy");
151 }
152
153 const promise = operation();
154 activeTranscriptionOperation = promise;
155 try {
156 return await promise;
157 } finally {
158 if (activeTranscriptionOperation === promise) activeTranscriptionOperation = null;
159 }
160}
161
162function errorCode(error: unknown): string | null {
163 if (typeof error !== "object" || error === null || !("code" in error)) return null;
164 return typeof error.code === "string" ? error.code : null;
165}
166
167function preparationErrorMessage(error: unknown): string {
168 if (error instanceof Error && error.message === "voice-operation-busy") {
169 return "Voice transcription is still finishing. Try again shortly.";
170 }
171 if (errorCode(error) === "unsupported-locale") {
172 return "Voice transcription is not available for this language.";
173 }
174 return "Could not prepare voice transcription.";
175}
176
177function transcriptionErrorMessage(error: unknown): string {
178 if (error instanceof Error && error.message === "voice-operation-busy") {
179 return "Voice transcription is still finishing. Try again shortly.";
180 }
181 return "Could not transcribe this recording.";
182}
183
184const IDLE_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null };
185
186export class VoiceInputController {
187 private readonly dependencies: VoiceInputControllerDependencies;
188 private state: VoiceInputState = IDLE_STATE;
189 private operationToken = 0;
190 private sessionToken: VoiceInputSession | null = null;
191 private transcription: PreparedVoiceTranscription | null = null;
192 private transcriptionAbortController: AbortController | null = null;
193 private capturedDraft: VoiceDraftSnapshot | null = null;
194 private recordingUri: string | null = null;
195 private readonly ownedRecordingUris = new Set<string>();
196 private recordingConfigured = false;
197 private finishing = false;
198
199 constructor(dependencies: VoiceInputControllerDependencies) {
200 this.dependencies = dependencies;
201 }
202
203 get currentState(): VoiceInputState {
204 return this.state;
205 }
206
207 async start(): Promise<void> {
208 if (this.state.phase !== "idle" && this.state.phase !== "error") return;
209 const initiatingDraft = this.dependencies.readDraft();
210 if (!initiatingDraft) {
211 this.setError("This draft is no longer available.", "retry");
212 return;
213 }
214 let sessionToken: VoiceInputSession | null = null;
215 const operationToken = ++this.operationToken;
216 this.setState({ phase: "preparing", error: null, errorAction: null });
217
218 try {
219 for (
220 let previousSession = activeSession;
221 previousSession?.abandoned;
222 previousSession = activeSession
223 ) {
224 await previousSession.released;
225 if (!this.isCurrent(operationToken)) return;
226 }
227 sessionToken = acquireSession();
228 if (!sessionToken) {
229 this.setError("Another voice recording is already active.", "retry");
230 return;
231 }
232 this.sessionToken = sessionToken;
233 const abortController = new AbortController();
234 this.transcriptionAbortController = abortController;
235
236 const transcriber = this.dependencies.getTranscriber();
237 if (!transcriber) {
238 this.setError("Voice transcription is not available.", null);
239 return;
240 }
241
242 const permission = await this.dependencies.requestPermission();
243 if (!this.isCurrent(operationToken)) return;
244 if (!permission.granted) {
245 this.setError(
246 "Microphone access is required for voice input.",
247 permission.canAskAgain ? "retry" : "settings",
248 );
249 return;
250 }
251
252 try {
253 this.transcription = await runTranscriptionOperation(() =>
254 transcriber.prepare({ signal: abortController.signal }),
255 );
256 } catch (error) {
257 if (this.isCurrent(operationToken)) this.setError(preparationErrorMessage(error), "retry");
258 return;
259 }
260 if (!this.isCurrent(operationToken)) return;
261
262 await this.dependencies.configureRecording();
263 this.recordingConfigured = true;
264 if (!this.isCurrent(operationToken)) return;
265 await this.dependencies.recorder.prepareToRecordAsync();
266 if (!this.isCurrent(operationToken)) return;
267 this.recordingUri = this.dependencies.recorder.uri;
268 this.rememberRecordingUri(this.recordingUri);
269
270 const capturedDraft = this.dependencies.readDraft();
271 if (!capturedDraft || capturedDraft.ownerKey !== initiatingDraft.ownerKey) {
272 this.setError("This draft is no longer available.", "retry");
273 return;
274 }
275 this.capturedDraft = capturedDraft;
276 this.dependencies.recorder.record({ forDuration: VOICE_RECORDING_LIMIT_SECONDS });
277 this.setState({ phase: "recording", error: null, errorAction: null });
278 } catch {
279 if (this.isCurrent(operationToken))
280 this.setError("Could not start voice recording.", "retry");
281 } finally {
282 if (sessionToken && this.sessionToken === sessionToken) {
283 if (this.isCurrent(operationToken) && this.state.phase === "error") {
284 await this.releaseResources();
285 } else if (!this.isCurrent(operationToken) && !this.finishing) {
286 await this.releaseResources();
287 }
288 }
289 }
290 }
291
292 stop(): Promise<void> {
293 if (this.state.phase !== "recording") return Promise.resolve();
294 return this.finishRecording(false, null);
295 }
296
297 cancel(): void {
298 switch (this.state.phase) {
299 case "idle":
300 return;
301 case "error":
302 this.setState(IDLE_STATE);
303 return;
304 case "preparing":
305 this.invalidateOperation();
306 this.setState(IDLE_STATE);
307 return;
308 case "recording":
309 this.discardRecording(null);
310 return;
311 case "transcribing":
312 this.invalidateOperation();
313 this.setState(IDLE_STATE);
314 return;
315 }
316 }
317
318 interruptRecording(
319 message = "Voice recording was interrupted.",
320 completedUri: string | null = null,
321 ): Promise<void> | void {
322 if (this.state.phase !== "recording") return;
323 this.rememberRecordingUri(completedUri);
324 this.recordingUri = completedUri ?? this.recordingUri;
325 return this.discardRecording(message);
326 }
327
328 appMovedToBackground(): Promise<void> | void {
329 if (this.state.phase === "preparing") {
330 this.invalidateOperation();
331 this.setError("Voice input stopped when the app moved to the background.", "retry");
332 return;
333 }
334 return this.interruptRecording();
335 }
336
337 handleRecorderStatus(status: VoiceRecorderStatus): Promise<void> | void {
338 if (this.state.phase !== "recording") return;
339 if (status.hasError) {
340 return this.interruptRecording(
341 status.error ?? "Voice recording was interrupted.",
342 status.url,
343 );
344 }
345 if (status.isFinished) {
346 if (!status.url) {
347 return this.interruptRecording();
348 }
349 return this.finishRecording(true, status.url);
350 }
351 }
352
353 ownerChanged(): void {
354 if (this.state.phase === "idle") return;
355 this.cancel();
356 }
357
358 dispose(): void {
359 if (this.state.phase === "recording") {
360 this.discardRecording(null);
361 return;
362 }
363 if (this.state.phase === "preparing" || this.state.phase === "transcribing") {
364 this.invalidateOperation();
365 this.setState(IDLE_STATE);
366 }
367 }
368
369 private async finishRecording(
370 alreadyStopped: boolean,
371 completedUri: string | null,
372 ): Promise<void> {
373 if (this.finishing || this.state.phase !== "recording") return;
374 this.finishing = true;
375 const operationToken = this.operationToken;
376 this.setState({ phase: "transcribing", error: null, errorAction: null });
377
378 try {
379 if (!alreadyStopped) await this.dependencies.recorder.stop();
380 await this.releaseAudioSession();
381 this.recordingUri = completedUri ?? this.dependencies.recorder.uri ?? this.recordingUri;
382 this.rememberRecordingUri(this.recordingUri);
383 if (!this.isCurrent(operationToken)) return;
384 if (
385 !this.recordingUri ||
386 !this.transcription ||
387 !this.transcriptionAbortController ||
388 !this.capturedDraft
389 ) {
390 this.setError("Could not finish voice recording.", "retry");
391 return;
392 }
393
394 const recordingUri = this.recordingUri;
395 const transcription = this.transcription;
396 const signal = this.transcriptionAbortController.signal;
397 const capturedDraft = this.capturedDraft;
398 let transcript: string;
399 try {
400 transcript = await runTranscriptionOperation(() =>
401 transcription.transcribe(recordingUri, { signal }),
402 );
403 } catch (error) {
404 if (this.isCurrent(operationToken)) {
405 this.setError(transcriptionErrorMessage(error), "retry");
406 }
407 return;
408 }
409 if (!this.isCurrent(operationToken)) return;
410
411 const result = resolveTranscriptCommit(
412 capturedDraft,
413 this.dependencies.readDraft(),
414 transcript,
415 transcription.locale,
416 );
417 if (result.kind === "stale") {
418 this.setError(
419 "The draft changed while voice input was running. The transcript was not added.",
420 "retry",
421 );
422 return;
423 }
424 if (result.kind === "empty") {
425 this.setError("No speech was detected.", "retry");
426 return;
427 }
428
429 this.dependencies.commitDraft(result.text, result.selection);
430 this.setState(IDLE_STATE);
431 } catch {
432 if (this.isCurrent(operationToken)) {
433 this.setError("Could not finish voice recording.", "retry");
434 }
435 } finally {
436 this.finishing = false;
437 await this.releaseResources();
438 }
439 }
440
441 private async discardRecording(error: string | null): Promise<void> {
442 this.invalidateOperation();
443 this.setState(
444 error
445 ? { phase: "error", error, errorAction: "retry" }
446 : { phase: "idle", error: null, errorAction: null },
447 );
448 try {
449 await this.dependencies.recorder.stop();
450 } catch {
451 } finally {
452 await this.releaseResources();
453 }
454 }
455
456 private async releaseResources(): Promise<void> {
457 this.rememberRecordingUri(this.recordingUri);
458 try {
459 this.rememberRecordingUri(this.dependencies.recorder.uri);
460 } catch {}
461 this.recordingUri = null;
462 for (const uri of this.ownedRecordingUris) {
463 try {
464 this.dependencies.deleteRecording(uri);
465 } catch {
466 // The cache may already have removed a failed or interrupted recording.
467 }
468 }
469 this.ownedRecordingUris.clear();
470 await this.releaseAudioSession();
471 releaseSession(this.sessionToken);
472 this.sessionToken = null;
473 this.capturedDraft = null;
474 this.transcription = null;
475 this.transcriptionAbortController = null;
476 }
477
478 private rememberRecordingUri(uri: string | null): void {
479 if (uri) this.ownedRecordingUris.add(uri);
480 }
481
482 private async releaseAudioSession(): Promise<void> {
483 if (!this.recordingConfigured) return;
484 try {
485 await this.dependencies.releaseRecording();
486 this.recordingConfigured = false;
487 } catch {
488 // Final cleanup retries if the prompt release before transcription fails.
489 }
490 }
491
492 private invalidateOperation(): void {
493 this.operationToken += 1;
494 if (this.sessionToken) this.sessionToken.abandoned = true;
495 this.transcriptionAbortController?.abort();
496 }
497
498 private isCurrent(operationToken: number): boolean {
499 return operationToken === this.operationToken;
500 }
501
502 private setError(error: string, errorAction: VoiceInputState["errorAction"]): void {
503 this.setState({ phase: "error", error, errorAction });
504 }
505
506 private setState(state: VoiceInputState): void {
507 this.state = state;
508 this.dependencies.onStateChange(state);
509 }
510}
511
512export function resetVoiceInputGlobalsForTests(): void {
513 activeSession = null;
514 activeTranscriptionOperation = null;
515}