Skip to content
Source
Sign inSign up
Open History to see the last commit

VoiceInputProvider.tsx

176 lines7.0 KB

1import { useAtomValue } from "@effect/atom-react";
2import { AudioModule, RecordingPresets, type RecordingStatus } from "expo-audio";
3import { File } from "expo-file-system";
4import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
5import { useSyncExternalStore } from "react";
6import { Platform } from "react-native";
7import { Atom, AsyncResult } from "effect/reactivity";
8import { makeMutable } from "react-native-reanimated";
9​
10import {
11 type MicrophoneEntry,
12 microphoneOrder,
13 preferredMicrophoneInput,
14 rememberMicrophones,
15} from "../../lib/microphonePriority";
16import { getLocalVoiceTranscriber } from "../../native/voiceTranscription";
17import { createVoiceRecorder } from "../../native/voiceRecorder";
18import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
19import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene";
20import {
21 voiceInputBlocksSubmission,
22 type VoiceTranscriber,
23} from "@t3tools/client-runtime/voice-input";
24import { createLazyVoiceRecorder } from "./lazyVoiceRecorder";
25import { VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering";
26import { VoiceInputSession } from "./voiceInputSession";
27import { createVoiceRecordingPermission } from "./voiceRecordingPermission";
28import { voiceRouteAtom } from "./voiceDestination";
29import { makePermissionStatusAtom } from "../../lib/permissionStatusAtom";
30import {
31 acquireAudioSession,
32 activateAudioSession,
33 releaseAudioSession,
34} from "../../lib/audioSession";
35​
36// The native constructor expects platform-flattened options, as built by useAudioRecorder.
37const { ios: IOS_RECORDING_OPTIONS, android: ANDROID_RECORDING_OPTIONS } =
38 RecordingPresets.HIGH_QUALITY;
39const VOICE_RECORDING_OPTIONS = {
40 extension: RecordingPresets.HIGH_QUALITY.extension,
41 sampleRate: RecordingPresets.HIGH_QUALITY.sampleRate,
42 numberOfChannels: RecordingPresets.HIGH_QUALITY.numberOfChannels,
43 bitRate: RecordingPresets.HIGH_QUALITY.bitRate,
44 isMeteringEnabled: true,
45 ...(Platform.OS === "ios" ? IOS_RECORDING_OPTIONS : ANDROID_RECORDING_OPTIONS),
46};
47​
48function keepScreenAwake(tag: string): () => void {
49 const activation = activateKeepAwakeAsync(tag);
50 void activation.catch(() => {});
51 // Release after activation settles, even if the recording ends immediately.
52 return () => void activation.then(() => deactivateKeepAwake(tag)).catch(() => {});
54​
55export const deviceMicrophoneOrder = (saved?: ReadonlyArray<MicrophoneEntry>) =>
56 microphoneOrder(saved, Platform.OS === "android" ? "android" : "ios");
57​
58/** "checking" until the first availability probe settles; re-probed on foreground. */
59export const localVoiceTranscriberAtom = makePermissionStatusAtom<
60 VoiceTranscriber | null | "checking"
61>({
62 label: "mobile:voice-input:transcriber",
63 initial: "checking",
64 read: () => getLocalVoiceTranscriber().catch(() => null),
65}).pipe(Atom.keepAlive);
66​
67const voiceInputAvailableAtom = Atom.make((get) => {
68 const route = get(voiceRouteAtom);
69 if (route) return route.transcriber !== null;
70 const transcriber = get(localVoiceTranscriberAtom);
71 return transcriber !== null && transcriber !== "checking";
72});
73​
74/**
75 * The app has one microphone and one dictation at a time, so the session
76 * lives for the registry's lifetime rather than any screen's.
77 */
78const voiceInputAtom = Atom.make((get) => {
79 const registry = get.registry;
80 // Mounted so the recorder can read and remember microphones synchronously.
81 get.mount(mobilePreferencesAtom);
82 get.mount(updateMobilePreferencesAtom);
83 const audioLevels = makeMutable<ReadonlyArray<number>>(
84 Array<number>(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0),
85 );
86 let keepAwakeSequence = 0;
87 let audioToken: ReturnType<typeof acquireAudioSession> | null = null;
88 const permission = createVoiceRecordingPermission(() => {
89 void session.controller.appMovedToBackground();
90 });
91 const recorder = createLazyVoiceRecorder({
92 getDestination: () => (registry.get(voiceRouteAtom) === null ? "local" : "environment"),
93 create: (destination) =>
94 destination === "local"
95 ? createVoiceRecorder(VOICE_RECORDING_OPTIONS)
96 : new AudioModule.AudioRecorder(VOICE_RECORDING_OPTIONS),
97 onStatus: (status: RecordingStatus) => {
98 session.controller.handleRecorderStatus({
99 isFinished: status.isFinished,
100 hasError: status.hasError || status.mediaServicesDidReset === true,
101 error: status.error,
102 url: status.url,
103 });
104 },
105 // Read when recording starts so preference changes do not re-render every composer.
106 selectInput: (inputs) => {
107 const preferences = registry.get(mobilePreferencesAtom);
108 if (!AsyncResult.isSuccess(preferences)) return null;
109 const known = deviceMicrophoneOrder(preferences.value.microphones);
110 const order = rememberMicrophones(known, inputs);
111 if (order !== known) {
112 registry.set(updateMobilePreferencesAtom, {
113 transform: (current) => ({
114 microphones: rememberMicrophones(deviceMicrophoneOrder(current.microphones), inputs),
115 }),
116 });
117 }
118 return preferredMicrophoneInput(inputs, order);
119 },
120 });
121 const session: VoiceInputSession = new VoiceInputSession({
122 recorder,
123 getTranscriber: () => {
124 const route = registry.get(voiceRouteAtom);
125 if (route) return route.transcriber;
126 const transcriber = registry.get(localVoiceTranscriberAtom);
127 return transcriber === "checking" ? null : transcriber;
128 },
129 requestPermission: permission.requestPermission,
130 configureRecording: async () => {
131 const token = acquireAudioSession("recording");
132 audioToken = token;
133 try {
134 await activateAudioSession(token);
135 } catch (error) {
136 await releaseAudioSession(token).catch(() => {});
137 throw error;
138 }
139 },
140 releaseRecording: async () => {
141 if (audioToken) await releaseAudioSession(audioToken);
142 audioToken = null;
143 },
144 deleteRecording: (uri) => new File(uri).delete(),
145 sampleRecording: () => {
146 const status = recorder.getStatus();
147 return status?.isRecording
148 ? { durationMillis: status.durationMillis, metering: status.metering }
149 : null;
150 },
151 publishAudioLevels: (levels) => audioLevels.set(levels),
152 keepAwake: () => keepScreenAwake(`voice-input:${++keepAwakeSequence}`),
153 });
154 get.subscribe(voiceRouteAtom, () => session.controller.cancel());
155 get.addFinalizer(() => {
156 permission.dispose();
157 session.dispose();
158 recorder.release();
159 });
160 return { session, audioLevels };
161}).pipe(Atom.keepAlive, Atom.withLabel("mobile:voice-input"));
162​
163export function useGlobalVoiceInput() {
164 const { session, audioLevels } = useAtomValue(voiceInputAtom);
165 const isTranscriberAvailable = useAtomValue(voiceInputAvailableAtom);
166 const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot);
167 return {
168 ...snapshot,
169 isAvailable: isTranscriberAvailable || getNativeShowcaseScene() !== null,
170 audioLevels,
171 isBusy: voiceInputBlocksSubmission(snapshot.state),
172 session,
173 stop: () => session.controller.stop(),
174 cancel: () => session.controller.cancel(),
175 };