493 lines · 15.4 KB · TypeScript
RawDownload
import { replaceTextRange } from "@t3tools/shared/composerTrigger"; import type { PreparedVoiceTranscription, VoiceTranscriber } from "./transcription.ts"; export const VOICE_RECORDING_LIMIT_SECONDS = 5 * 60; export type VoiceInputPhase = "idle" | "preparing" | "recording" | "transcribing" | "error"; export type VoiceInputState = {  readonly phase: VoiceInputPhase;  readonly error: string | null;  readonly errorAction: "retry" | "settings" | null;}; export function voiceInputBlocksSubmission(state: VoiceInputState): boolean {  return (    state.phase === "preparing" || state.phase === "recording" || state.phase === "transcribing"  );} export function voiceInputFreezesEditor(state: VoiceInputState): boolean {  return voiceInputBlocksSubmission(state);} export type VoiceDraftSnapshot = {  readonly ownerKey: string;  readonly text: string;  readonly selection: { readonly start: number; readonly end: number };  readonly revision: number;}; export type VoiceRecorderStatus = {  readonly isFinished: boolean;  readonly hasError: boolean;  readonly error: string | null;  readonly url: string | null;}; export interface VoiceRecorder {  readonly uri: string | null;  prepareToRecordAsync(): Promise<void>;  record(options: { readonly forDuration: number }): void;  stop(): Promise<void>;} export type VoiceInputControllerDependencies = {  readonly recorder: VoiceRecorder;  readonly getTranscriber: () => VoiceTranscriber | null;  readonly requestPermission: () => Promise<{    readonly granted: boolean;    readonly canAskAgain: boolean;  }>;  readonly configureRecording: () => Promise<void>;  readonly releaseRecording: () => Promise<void>;  readonly deleteRecording: (uri: string) => void;  readonly readDraft: () => VoiceDraftSnapshot | null;  readonly commitDraft: (    text: string,    selection: { readonly start: number; readonly end: number },  ) => void;  readonly onStateChange: (state: VoiceInputState) => void;}; type TranscriptCommitResult =  | {      readonly kind: "commit";      readonly text: string;      readonly selection: { readonly start: number; readonly end: number };    }  | { readonly kind: "stale" }  | { readonly kind: "empty" }; export function resolveTranscriptCommit(  captured: VoiceDraftSnapshot,  current: VoiceDraftSnapshot | null,  transcript: string,  locale: string,): TranscriptCommitResult {  if (    !current ||    current.ownerKey !== captured.ownerKey ||    current.text !== captured.text ||    current.revision !== captured.revision  ) {    return { kind: "stale" };  }   const replacement = transcript.trim();  if (replacement.length === 0) {    return { kind: "empty" };  }   const isEmptySelection = captured.selection.start === captured.selection.end;  const normalizedLocale = locale.replaceAll("_", "-").toLowerCase();  const usesEnglishSpacing = normalizedLocale === "en" || normalizedLocale.startsWith("en-");  let insertion = replacement;  if (isEmptySelection && usesEnglishSpacing) {    const left = captured.text[captured.selection.start - 1];    const right = captured.text[captured.selection.start];    const leftNeedsBoundary =      left !== undefined &&      /[A-Za-z0-9.!?,:;)\]}'"]/.test(left) &&      (right === undefined || /\s/.test(right));    const rightNeedsBoundary =      right !== undefined &&      /[A-Za-z0-9([{'"]/.test(right) &&      (left === undefined || /\s/.test(left));    insertion = `${leftNeedsBoundary ? " " : ""}${replacement}${rightNeedsBoundary ? " " : ""}`;  }   const result = replaceTextRange(    captured.text,    captured.selection.start,    captured.selection.end,    insertion,  );  return {    kind: "commit",    text: result.text,    selection: { start: result.cursor, end: result.cursor },  };} let activeSession: symbol | null = null;let activeTranscriptionOperation: Promise<unknown> | null = null; function acquireSession(): symbol | null {  if (activeSession) return null;  const token = Symbol("voice-input-session");  activeSession = token;  return token;} function releaseSession(token: symbol | null): void {  if (token && activeSession === token) activeSession = null;} async function runTranscriptionOperation<T>(operation: () => Promise<T>): Promise<T> {  if (activeTranscriptionOperation) {    throw new Error("voice-operation-busy");  }   const promise = operation();  activeTranscriptionOperation = promise;  try {    return await promise;  } finally {    if (activeTranscriptionOperation === promise) activeTranscriptionOperation = null;  }} function errorCode(error: unknown): string | null {  if (typeof error !== "object" || error === null || !("code" in error)) return null;  return typeof error.code === "string" ? error.code : null;} function preparationErrorMessage(error: unknown): string {  if (error instanceof Error && error.message === "voice-operation-busy") {    return "Voice transcription is still finishing. Try again shortly.";  }  if (errorCode(error) === "unsupported-locale") {    return "Voice transcription is not available for this language.";  }  return "Could not prepare voice transcription.";} function transcriptionErrorMessage(error: unknown): string {  if (error instanceof Error && error.message === "voice-operation-busy") {    return "Voice transcription is still finishing. Try again shortly.";  }  return "Could not transcribe this recording.";} const IDLE_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; export class VoiceInputController {  private readonly dependencies: VoiceInputControllerDependencies;  private state: VoiceInputState = IDLE_STATE;  private operationToken = 0;  private sessionToken: symbol | null = null;  private transcription: PreparedVoiceTranscription | null = null;  private transcriptionAbortController: AbortController | null = null;  private capturedDraft: VoiceDraftSnapshot | null = null;  private recordingUri: string | null = null;  private readonly ownedRecordingUris = new Set<string>();  private recordingConfigured = false;  private finishing = false;   constructor(dependencies: VoiceInputControllerDependencies) {    this.dependencies = dependencies;  }   get currentState(): VoiceInputState {    return this.state;  }   async start(): Promise<void> {    if (this.state.phase !== "idle" && this.state.phase !== "error") return;    const initiatingDraft = this.dependencies.readDraft();    if (!initiatingDraft) {      this.setError("This draft is no longer available.", "retry");      return;    }    const sessionToken = acquireSession();    if (!sessionToken) {      this.setError("Another voice recording is already active.", "retry");      return;    }     this.sessionToken = sessionToken;    const operationToken = ++this.operationToken;    const abortController = new AbortController();    this.transcriptionAbortController = abortController;    this.setState({ phase: "preparing", error: null, errorAction: null });     try {      const transcriber = this.dependencies.getTranscriber();      if (!transcriber) {        this.setError("Voice transcription is not available.", null);        return;      }       const permission = await this.dependencies.requestPermission();      if (!this.isCurrent(operationToken)) return;      if (!permission.granted) {        this.setError(          "Microphone access is required for voice input.",          permission.canAskAgain ? "retry" : "settings",        );        return;      }       try {        this.transcription = await runTranscriptionOperation(() =>          transcriber.prepare({ signal: abortController.signal }),        );      } catch (error) {        if (this.isCurrent(operationToken)) this.setError(preparationErrorMessage(error), "retry");        return;      }      if (!this.isCurrent(operationToken)) return;       await this.dependencies.configureRecording();      this.recordingConfigured = true;      if (!this.isCurrent(operationToken)) return;      await this.dependencies.recorder.prepareToRecordAsync();      if (!this.isCurrent(operationToken)) return;      this.recordingUri = this.dependencies.recorder.uri;      this.rememberRecordingUri(this.recordingUri);       const capturedDraft = this.dependencies.readDraft();      if (!capturedDraft || capturedDraft.ownerKey !== initiatingDraft.ownerKey) {        this.setError("This draft is no longer available.", "retry");        return;      }      this.capturedDraft = capturedDraft;      this.dependencies.recorder.record({ forDuration: VOICE_RECORDING_LIMIT_SECONDS });      this.setState({ phase: "recording", error: null, errorAction: null });    } catch {      if (this.isCurrent(operationToken))        this.setError("Could not start voice recording.", "retry");    } finally {      if (this.isCurrent(operationToken) && this.state.phase === "error") {        await this.releaseResources();      } else if (!this.isCurrent(operationToken) && !this.finishing) {        await this.releaseResources();      }    }  }   stop(): Promise<void> {    if (this.state.phase !== "recording") return Promise.resolve();    return this.finishRecording(false, null);  }   cancel(): void {    switch (this.state.phase) {      case "idle":        return;      case "error":        this.setState(IDLE_STATE);        return;      case "preparing":        this.invalidateOperation();        this.setState(IDLE_STATE);        return;      case "recording":        this.discardRecording(null);        return;      case "transcribing":        this.invalidateOperation();        this.setState(IDLE_STATE);        return;    }  }   interruptRecording(    message = "Voice recording was interrupted.",    completedUri: string | null = null,  ): Promise<void> | void {    if (this.state.phase !== "recording") return;    this.rememberRecordingUri(completedUri);    this.recordingUri = completedUri ?? this.recordingUri;    return this.discardRecording(message);  }   appMovedToBackground(): Promise<void> | void {    if (this.state.phase === "preparing") {      this.invalidateOperation();      this.setError("Voice input stopped when the app moved to the background.", "retry");      return;    }    return this.interruptRecording();  }   handleRecorderStatus(status: VoiceRecorderStatus): Promise<void> | void {    if (this.state.phase !== "recording") return;    if (status.hasError) {      return this.interruptRecording(        status.error ?? "Voice recording was interrupted.",        status.url,      );    }    if (status.isFinished) {      if (!status.url) {        return this.interruptRecording();      }      return this.finishRecording(true, status.url);    }  }   ownerChanged(): void {    if (this.state.phase === "idle") return;    this.cancel();  }   dispose(): void {    if (this.state.phase === "recording") {      this.discardRecording(null);      return;    }    if (this.state.phase === "preparing" || this.state.phase === "transcribing") {      this.invalidateOperation();      this.setState(IDLE_STATE);    }  }   private async finishRecording(    alreadyStopped: boolean,    completedUri: string | null,  ): Promise<void> {    if (this.finishing || this.state.phase !== "recording") return;    this.finishing = true;    const operationToken = this.operationToken;    this.setState({ phase: "transcribing", error: null, errorAction: null });     try {      if (!alreadyStopped) await this.dependencies.recorder.stop();      await this.releaseAudioSession();      this.recordingUri = completedUri ?? this.dependencies.recorder.uri ?? this.recordingUri;      this.rememberRecordingUri(this.recordingUri);      if (!this.isCurrent(operationToken)) return;      if (        !this.recordingUri ||        !this.transcription ||        !this.transcriptionAbortController ||        !this.capturedDraft      ) {        this.setError("Could not finish voice recording.", "retry");        return;      }       const recordingUri = this.recordingUri;      const transcription = this.transcription;      const signal = this.transcriptionAbortController.signal;      const capturedDraft = this.capturedDraft;      let transcript: string;      try {        transcript = await runTranscriptionOperation(() =>          transcription.transcribe(recordingUri, { signal }),        );      } catch (error) {        if (this.isCurrent(operationToken)) {          this.setError(transcriptionErrorMessage(error), "retry");        }        return;      }      if (!this.isCurrent(operationToken)) return;       const result = resolveTranscriptCommit(        capturedDraft,        this.dependencies.readDraft(),        transcript,        transcription.locale,      );      if (result.kind === "stale") {        this.setError(          "The draft changed while voice input was running. The transcript was not added.",          "retry",        );        return;      }      if (result.kind === "empty") {        this.setError("No speech was detected.", "retry");        return;      }       this.dependencies.commitDraft(result.text, result.selection);      this.setState(IDLE_STATE);    } catch {      if (this.isCurrent(operationToken)) {        this.setError("Could not finish voice recording.", "retry");      }    } finally {      this.finishing = false;      await this.releaseResources();    }  }   private async discardRecording(error: string | null): Promise<void> {    this.invalidateOperation();    this.setState(      error        ? { phase: "error", error, errorAction: "retry" }        : { phase: "idle", error: null, errorAction: null },    );    try {      await this.dependencies.recorder.stop();      this.rememberRecordingUri(this.dependencies.recorder.uri);    } catch {      this.rememberRecordingUri(this.dependencies.recorder.uri);    } finally {      await this.releaseResources();    }  }   private async releaseResources(): Promise<void> {    this.rememberRecordingUri(this.recordingUri);    this.rememberRecordingUri(this.dependencies.recorder.uri);    this.recordingUri = null;    for (const uri of this.ownedRecordingUris) {      try {        this.dependencies.deleteRecording(uri);      } catch {        // The cache may already have removed a failed or interrupted recording.      }    }    this.ownedRecordingUris.clear();    await this.releaseAudioSession();    releaseSession(this.sessionToken);    this.sessionToken = null;    this.capturedDraft = null;    this.transcription = null;    this.transcriptionAbortController = null;  }   private rememberRecordingUri(uri: string | null): void {    if (uri) this.ownedRecordingUris.add(uri);  }   private async releaseAudioSession(): Promise<void> {    if (!this.recordingConfigured) return;    try {      await this.dependencies.releaseRecording();      this.recordingConfigured = false;    } catch {      // Final cleanup retries if the prompt release before transcription fails.    }  }   private invalidateOperation(): void {    this.operationToken += 1;    this.transcriptionAbortController?.abort();  }   private isCurrent(operationToken: number): boolean {    return operationToken === this.operationToken;  }   private setError(error: string, errorAction: VoiceInputState["errorAction"]): void {    this.setState({ phase: "error", error, errorAction });  }   private setState(state: VoiceInputState): void {    this.state = state;    this.dependencies.onStateChange(state);  }} export function resetVoiceInputGlobalsForTests(): void {  activeSession = null;  activeTranscriptionOperation = null;}