1import type { VoiceRecorder } from "@t3tools/client-runtime/voice-input";
2
3type Subscription = { remove(): void };
4
5export interface VoiceRecordingInput {
6 readonly uid: string;
7 readonly name: string;
8 readonly type: string;
9}
10
11// The subset of expo-audio's native `AudioRecorder` the voice input hook uses.
12export interface NativeVoiceRecorder<TStatus, TRecorderState> {
13 readonly uri: string | null;
14 prepareToRecordAsync(): Promise<void>;
15 record(options: { readonly forDuration: number }): void;
16 stop(): Promise<void>;
17 getStatus(): TRecorderState;
18 addListener(
19 eventName: "recordingStatusUpdate",
20 listener: (status: TStatus) => void,
21 ): Subscription;
22 getAvailableInputs(): ReadonlyArray<VoiceRecordingInput>;
23 setInput(uid: string): void;
24 release(): void;
25}
26
27export interface LazyVoiceRecorder<TRecorderState> extends VoiceRecorder {
28 /** Status of the native recorder, or null before a recording has prepared one. */
29 getStatus(): TRecorderState | null;
30 /**
31 * Releases the native recorder after its pending prepare and stop calls settle.
32 * A later recording creates a new one, so replayed effects keep working.
33 */
34 release(): void;
35}
36
37/**
38 * Creates expo-audio's native `AudioRecorder` when a recording first prepares,
39 * instead of on composer render. Construction blocks the JS thread for tens of
40 * milliseconds on a cold composer, and most composer mounts never record.
41 */
42export function createLazyVoiceRecorder<TStatus, TRecorderState>(input: {
43 readonly create: () => NativeVoiceRecorder<TStatus, TRecorderState>;
44 readonly onStatus: (status: TStatus) => void;
45 /** Picks the input to record from once the session lists them, or null to keep the system's. */
46 readonly selectInput?: (inputs: ReadonlyArray<VoiceRecordingInput>) => VoiceRecordingInput | null;
47}): LazyVoiceRecorder<TRecorderState> {
48 let recorder: NativeVoiceRecorder<TStatus, TRecorderState> | null = null;
49 let subscription: Subscription | null = null;
50 // A released recorder still answers `uri` so the controller can delete its file.
51 let retired: NativeVoiceRecorder<TStatus, TRecorderState> | null = null;
52 let retiredUri: string | null = null;
53 let pending: Promise<unknown> = Promise.resolve();
54
55 const track = <T>(promise: Promise<T>): Promise<T> => {
56 pending = Promise.allSettled([pending, promise]).then(() => undefined);
57 return promise;
58 };
59
60 return {
61 get uri() {
62 if (recorder) return recorder.uri;
63 return retired ? retired.uri : retiredUri;
64 },
65 prepareToRecordAsync() {
66 if (!recorder) {
67 recorder = input.create();
68 subscription = recorder.addListener("recordingStatusUpdate", input.onStatus);
69 }
70 const prepared = recorder;
71 return track(
72 prepared.prepareToRecordAsync().then(() => {
73 if (!input.selectInput) return;
74 try {
75 const selected = input.selectInput(prepared.getAvailableInputs());
76 if (selected) prepared.setInput(selected.uid);
77 } catch {
78 // The input can disconnect in between. The system's route still records.
79 }
80 }),
81 );
82 },
83 record(options) {
84 if (!recorder) throw new Error("Voice recorder is not prepared.");
85 recorder.record(options);
86 },
87 stop() {
88 return recorder ? track(recorder.stop()) : Promise.resolve();
89 },
90 getStatus() {
91 return recorder ? recorder.getStatus() : null;
92 },
93 release() {
94 const released = recorder;
95 if (!released) return;
96 recorder = null;
97 subscription?.remove();
98 subscription = null;
99 retired = released;
100 void pending.then(() => {
101 if (retired === released) {
102 retiredUri = released.uri;
103 retired = null;
104 }
105 released.release();
106 });
107 },
108 };
109}