1import { RegistryContext, useAtomMount } from "@effect/atom-react";
2import {
3 AudioModule,
4 RecordingPresets,
5 requestRecordingPermissionsAsync,
6 setAudioModeAsync,
7 setIsAudioActiveAsync,
8 type RecorderState,
9 type RecordingStatus,
10} from "expo-audio";
11import { File } from "expo-file-system";
12import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
13import {
14 createContext,
15 use,
16 useCallback,
17 useEffect,
18 useId,
19 useRef,
20 useState,
21 type ReactNode,
22} from "react";
23import { AppState, Platform } from "react-native";
24import { AsyncResult } from "effect/reactivity";
25import { useSharedValue } from "react-native-reanimated";
26
27import {
28 microphoneOrder,
29 preferredMicrophoneInput,
30 rememberMicrophones,
31} from "../../lib/microphonePriority";
32import { getLocalVoiceTranscriber } from "../../native/voiceTranscription";
33import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
34import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene";
35import {
36 VOICE_RECORDING_LIMIT_SECONDS,
37 voiceInputBlocksSubmission,
38 type VoiceInputState,
39} from "@t3tools/client-runtime/voice-input";
40import { createLazyVoiceRecorder, type LazyVoiceRecorder } from "./lazyVoiceRecorder";
41import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering";
42import { VoiceInputSession } from "./voiceInputSession";
43
44const INITIAL_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null };
45const VOICE_METERING_INTERVAL_MS = 80;
46// The native constructor takes platform-flattened options, as `useAudioRecorder`
47// builds them with expo-audio's internal `createRecordingOptions`.
48const { ios: IOS_RECORDING_OPTIONS, android: ANDROID_RECORDING_OPTIONS } =
49 RecordingPresets.HIGH_QUALITY;
50const VOICE_RECORDING_OPTIONS = {
51 extension: RecordingPresets.HIGH_QUALITY.extension,
52 sampleRate: RecordingPresets.HIGH_QUALITY.sampleRate,
53 numberOfChannels: RecordingPresets.HIGH_QUALITY.numberOfChannels,
54 bitRate: RecordingPresets.HIGH_QUALITY.bitRate,
55 isMeteringEnabled: true,
56 ...(Platform.OS === "ios" ? IOS_RECORDING_OPTIONS : ANDROID_RECORDING_OPTIONS),
57};
58
59async function releaseVoiceRecordingAudio(): Promise<void> {
60 try {
61 await setAudioModeAsync({ allowsRecording: false });
62 } finally {
63 // Expo does not deactivate AVAudioSession when recording stops or its
64 // category changes. Explicit deactivation resumes interrupted app audio.
65 await setIsAudioActiveAsync(false);
66 }
67}
68
69async function configureVoiceRecordingAudio(): Promise<void> {
70 try {
71 await setAudioModeAsync({
72 allowsRecording: true,
73 interruptionMode: "doNotMix",
74 playsInSilentMode: true,
75 shouldPlayInBackground: false,
76 });
77 await setIsAudioActiveAsync(true);
78 } catch (error) {
79 try {
80 await releaseVoiceRecordingAudio();
81 } catch {
82 // Keep the setup error. The controller has not started a recorder yet.
83 }
84 throw error;
85 }
86}
87
88const VoiceInputContext = createContext<ReturnType<typeof useVoiceInputRuntime> | null>(null);
89
90export function VoiceInputProvider({ children }: { readonly children: ReactNode }) {
91 const runtime = useVoiceInputRuntime();
92 return <VoiceInputContext value={runtime}>{children}</VoiceInputContext>;
93}
94
95export function useGlobalVoiceInput() {
96 const context = use(VoiceInputContext);
97 if (!context) throw new Error("Voice input provider is missing.");
98 return context;
99}
100
101function useVoiceInputRuntime() {
102 const registry = use(RegistryContext);
103 // Dictation reads and records microphones without subscribing the provider to preferences.
104 useAtomMount(mobilePreferencesAtom);
105 useAtomMount(updateMobilePreferencesAtom);
106 const [{ state, ownerKey, label }, setState] = useState({
107 state: INITIAL_STATE,
108 ownerKey: null as string | null,
109 label: null as string | null,
110 });
111 const [focusedOwners, setFocusedOwners] = useState<ReadonlySet<string>>(new Set());
112 const setOwnerFocused = useCallback((key: string, focused: boolean) => {
113 setFocusedOwners((current) => {
114 const next = new Set(current);
115 if (focused) next.add(key);
116 else next.delete(key);
117 return next;
118 });
119 }, []);
120 const [elapsedSeconds, setElapsedSeconds] = useState(0);
121 const keepAwakeId = useId();
122 const keepAwakeSessionRef = useRef(0);
123 const elapsedSecondsRef = useRef(0);
124 const audioLevelsRef = useRef(Array<number>(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0));
125 const audioLevels = useSharedValue(audioLevelsRef.current);
126 const sessionRef = useRef<VoiceInputSession | null>(null);
127 const recorderRef = useRef<LazyVoiceRecorder<RecorderState> | null>(null);
128
129 if (!sessionRef.current || !recorderRef.current) {
130 // The native recorder is created when dictation starts, not on app launch.
131 const recorder = createLazyVoiceRecorder({
132 create: () => new AudioModule.AudioRecorder(VOICE_RECORDING_OPTIONS),
133 onStatus: (status: RecordingStatus) => {
134 sessionRef.current?.controller.handleRecorderStatus({
135 isFinished: status.isFinished,
136 hasError: status.hasError || status.mediaServicesDidReset === true,
137 error: status.error,
138 url: status.url,
139 });
140 },
141 // Read when recording starts so preference changes do not re-render every composer.
142 selectInput: (inputs) => {
143 const preferences = registry.get(mobilePreferencesAtom);
144 if (!AsyncResult.isSuccess(preferences)) return null;
145 const known = microphoneOrder(preferences.value.microphones);
146 const order = rememberMicrophones(known, inputs);
147 if (order !== known) {
148 registry.set(updateMobilePreferencesAtom, {
149 transform: (current) => ({
150 microphones: rememberMicrophones(microphoneOrder(current.microphones), inputs),
151 }),
152 });
153 }
154 return preferredMicrophoneInput(inputs, order);
155 },
156 });
157 recorderRef.current = recorder;
158 sessionRef.current = new VoiceInputSession({
159 recorder,
160 getTranscriber: getLocalVoiceTranscriber,
161 requestPermission: async () => {
162 const permission = await requestRecordingPermissionsAsync();
163 return { granted: permission.granted, canAskAgain: permission.canAskAgain };
164 },
165 configureRecording: configureVoiceRecordingAudio,
166 releaseRecording: releaseVoiceRecordingAudio,
167 deleteRecording: (uri) => new File(uri).delete(),
168 onStateChange: (nextState) =>
169 setState({
170 state: nextState,
171 ownerKey: sessionRef.current?.ownerKey ?? null,
172 label: sessionRef.current?.label ?? null,
173 }),
174 });
175 }
176
177 const session = sessionRef.current;
178 const controller = session.controller;
179 const recorder = recorderRef.current;
180
181 useEffect(() => {
182 const subscription = AppState.addEventListener("change", (nextState) => {
183 // iOS reports `inactive` while its permission dialog is open. Only the
184 // real background state cancels preparation; recorder status handles
185 // calls and route interruptions during capture.
186 if (nextState === "background") controller.appMovedToBackground();
187 });
188 return () => subscription.remove();
189 }, [controller]);
190
191 useEffect(
192 () => () => {
193 // Dispose first so an active recording is stopped before the recorder is released.
194 controller.dispose();
195 recorder.release();
196 },
197 [controller, recorder],
198 );
199
200 useEffect(() => {
201 if (state.phase !== "recording") return;
202
203 const tag = `voice-input:${keepAwakeId}:${++keepAwakeSessionRef.current}`;
204 const activation = activateKeepAwakeAsync(tag);
205 void activation.catch(() => {});
206 return () => {
207 // Release after activation settles, even if the recording ends immediately.
208 void activation.then(() => deactivateKeepAwake(tag)).catch(() => {});
209 };
210 }, [keepAwakeId, state.phase]);
211
212 useEffect(() => {
213 if (state.phase !== "preparing" && state.phase !== "recording") return;
214
215 if (audioLevelsRef.current.some((level) => level !== 0)) {
216 audioLevelsRef.current = Array<number>(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0);
217 audioLevels.value = audioLevelsRef.current;
218 }
219 if (elapsedSecondsRef.current !== 0) {
220 elapsedSecondsRef.current = 0;
221 setElapsedSeconds(0);
222 }
223 if (state.phase !== "recording") return;
224
225 const sampleRecording = () => {
226 if (controller.currentState.phase !== "recording") return;
227 const status = recorder.getStatus();
228 if (!status?.isRecording) return;
229
230 const level = normalizeVoiceInputDecibels(status.metering);
231 const history = audioLevelsRef.current;
232 if (level !== 0 || history.some((sample) => sample !== 0)) {
233 const nextLevels = [...history.slice(1), level];
234 audioLevelsRef.current = nextLevels;
235 audioLevels.value = nextLevels;
236 }
237
238 const nextElapsedSeconds = Math.min(
239 VOICE_RECORDING_LIMIT_SECONDS,
240 Math.max(0, Math.floor(status.durationMillis / 1_000)),
241 );
242 if (nextElapsedSeconds !== elapsedSecondsRef.current) {
243 elapsedSecondsRef.current = nextElapsedSeconds;
244 setElapsedSeconds(nextElapsedSeconds);
245 }
246 };
247
248 sampleRecording();
249 const intervalId = setInterval(sampleRecording, VOICE_METERING_INTERVAL_MS);
250 return () => clearInterval(intervalId);
251 }, [audioLevels, controller, recorder, state.phase]);
252
253 const stop = useCallback(() => controller.stop(), [controller]);
254 const cancel = useCallback(() => controller.cancel(), [controller]);
255
256 return {
257 // Store screenshots show the dictation button even on simulators, whose
258 // on-device transcription is unavailable.
259 isAvailable: getLocalVoiceTranscriber() !== null || getNativeShowcaseScene() !== null,
260 state,
261 audioLevels,
262 elapsedSeconds,
263 isBusy: voiceInputBlocksSubmission(state),
264 ownerKey,
265 label,
266 focusedOwners,
267 setOwnerFocused,
268 session,
269 stop,
270 cancel,
271 };
272}