1import {
2 throwIfVoiceTranscriptionAborted,
3 type VoiceRecorder,
4} from "@t3tools/client-runtime/voice-input";
5
6type Subscription = { remove(): void };
7type RecordingDestination = "local" | "environment";
8
9export interface VoiceRecordingInput {
10 readonly uid: string;
11 readonly name: string;
12 readonly type: string;
13}
14
15export interface NativeVoiceRecorder<TStatus, TRecorderState> {
16 readonly uri: string | null;
17 prepareToRecordAsync(): Promise<void>;
18 record(options: { readonly forDuration: number }): void;
19 stop(): Promise<void>;
20 getStatus(): TRecorderState;
21 addListener(
22 eventName: "recordingStatusUpdate",
23 listener: (status: TStatus) => void,
24 ): Subscription;
25 getAvailableInputs(): ReadonlyArray<VoiceRecordingInput>;
26 setInput(uid: string): void | Promise<void>;
27 cancelInputSelection?(): void;
28 release(): void;
29}
30
31export interface LazyVoiceRecorder<TRecorderState> extends VoiceRecorder {
32 getStatus(): TRecorderState | null;
33 release(): void;
34}
35
36/** Construct lazily: cold startup blocks JS for tens of milliseconds and most composers never record. */
37export function createLazyVoiceRecorder<TStatus, TRecorderState>(input: {
38 readonly getDestination: () => RecordingDestination;
39 readonly create: (
40 destination: RecordingDestination,
41 ) => NativeVoiceRecorder<TStatus, TRecorderState>;
42 readonly onStatus: (status: TStatus) => void;
43 readonly selectInput?: (inputs: ReadonlyArray<VoiceRecordingInput>) => VoiceRecordingInput | null;
44}): LazyVoiceRecorder<TRecorderState> {
45 let recorder: NativeVoiceRecorder<TStatus, TRecorderState> | null = null;
46 let destination: RecordingDestination | null = null;
47 let subscription: Subscription | null = null;
48 let retired: NativeVoiceRecorder<TStatus, TRecorderState> | null = null;
49 let retiredUri: string | null = null;
50 let pending: Promise<unknown> = Promise.resolve();
51 let abandonInputSelection: (() => void) | null = null;
52
53 const track = <T>(promise: Promise<T>): Promise<T> => {
54 pending = Promise.allSettled([pending, promise]).then(() => undefined);
55 return promise;
56 };
57
58 const release = () => {
59 const released = recorder;
60 if (!released) return;
61 abandonInputSelection?.();
62 recorder = null;
63 subscription?.remove();
64 subscription = null;
65 retired = released;
66 void pending.then(() => {
67 if (retired === released) {
68 retiredUri = released.uri;
69 retired = null;
70 }
71 released.release();
72 });
73 };
74
75 return {
76 get uri() {
77 if (recorder) return recorder.uri;
78 return retired ? retired.uri : retiredUri;
79 },
80 prepareToRecordAsync(signal) {
81 const next = input.getDestination();
82 if (next !== destination) release();
83 if (!recorder) {
84 recorder = input.create(next);
85 destination = next;
86 subscription = recorder.addListener("recordingStatusUpdate", input.onStatus);
87 }
88 const prepared = recorder;
89 return track(
90 prepared.prepareToRecordAsync().then(async () => {
91 if (signal) throwIfVoiceTranscriptionAborted(signal);
92 if (!input.selectInput) return;
93 let selected: VoiceRecordingInput | null;
94 try {
95 selected = input.selectInput(prepared.getAvailableInputs());
96 } catch {
97 return;
98 }
99 if (!selected) return;
100 const uid = selected.uid;
101 // Expo never settles a canceled coroutine function's promise, so stop waiting on abort.
102 const abandoned = Promise.withResolvers<void>();
103 const abandon = () => {
104 prepared.cancelInputSelection?.();
105 abandoned.resolve();
106 };
107 signal?.addEventListener("abort", abandon, { once: true });
108 abandonInputSelection = abandon;
109 try {
110 await Promise.race([
111 new Promise<void>((resolve) => resolve(prepared.setInput(uid))).catch(() => {}),
112 abandoned.promise,
113 ]);
114 if (signal) throwIfVoiceTranscriptionAborted(signal);
115 } finally {
116 signal?.removeEventListener("abort", abandon);
117 if (abandonInputSelection === abandon) abandonInputSelection = null;
118 }
119 }),
120 );
121 },
122 record(options) {
123 if (!recorder) throw new Error("The voice recorder isn’t ready. Try again.");
124 recorder.record(options);
125 },
126 stop() {
127 return recorder ? track(recorder.stop()) : Promise.resolve();
128 },
129 getStatus() {
130 return recorder ? recorder.getStatus() : null;
131 },
132 release,
133 };
134}