Skip to content
Source
Sign inSign up

voiceTranscription.ios.ts

155 lines5.0 KB

1import * as Schema from "effect/Schema";
2import AppleTranscription from "@react-native-ai/apple/src/NativeAppleTranscription";
3import { File } from "expo-file-system";
4import { Settings } from "react-native";
5​
6import {
7 VoiceTranscriptionError,
8 throwIfVoiceTranscriptionAborted,
9 type PreparedVoiceTranscription,
10 type VoiceTranscriber,
11 type VoiceTranscriptionOptions,
12} from "@t3tools/client-runtime/voice-input";
13​
14const isVoiceTranscriptionError = Schema.is(VoiceTranscriptionError);
15​
16/** The user's ordered Language & Region list, then the app locale; the app ships in English only. */
17function getPreferredLocales(): string[] {
18 const languages: unknown = Settings.get("AppleLanguages");
19 const preferred = Array.isArray(languages)
20 ? languages.filter(
21 (language): language is string => typeof language === "string" && language.length > 0,
22 )
23 : [];
24 return [...new Set([...preferred, Intl.DateTimeFormat().resolvedOptions().locale])];
26​
27function wrapError(
28 code: "preparation-failed" | "transcription-failed",
29 message: string,
30 cause: unknown,
31): VoiceTranscriptionError {
32 if (isVoiceTranscriptionError(cause)) {
33 return cause;
34 }
35​
36 return new VoiceTranscriptionError({ code, message, cause });
38​
39function getNativeErrorCode(error: unknown): string | undefined {
40 if (typeof error !== "object" || error === null || !("code" in error)) {
41 return undefined;
42 }
43​
44 return typeof error.code === "string" ? error.code : undefined;
46​
47export async function getLocalVoiceTranscriber(): Promise<VoiceTranscriber | null> {
48 const locales = getPreferredLocales();
49 // Availability is a device and OS check; which languages have a model only `prepare` knows.
50 if (!AppleTranscription.isAvailable(locales[0]!)) return null;
51 return { prepare: (options) => prepareVoiceTranscription(locales, options) };
53​
54async function prepareVoiceTranscription(
55 locales: readonly string[],
56 { signal }: VoiceTranscriptionOptions,
57): Promise<PreparedVoiceTranscription> {
58 throwIfVoiceTranscriptionAborted(signal);
59 if (!AppleTranscription.isAvailable(locales[0]!)) {
60 throw new VoiceTranscriptionError({
61 code: "unavailable",
62 message: "Voice transcription requires a supported device with iOS 26 or later.",
63 });
64 }
65​
66 let locale = locales[0]!;
67 const model = prepareLocales(locales, signal).then((supported) => {
68 locale = supported;
69 });
70 // Recorder setup can fail before either consumer observes the model.
71 void model.catch(() => {});
72​
73 async function ready(signal: AbortSignal) {
74 throwIfVoiceTranscriptionAborted(signal);
75 const cancelled = Promise.withResolvers<void>();
76 const cancel = () => cancelled.resolve();
77 signal.addEventListener("abort", cancel, { once: true });
78 try {
79 await Promise.race([model, cancelled.promise]);
80 throwIfVoiceTranscriptionAborted(signal);
81 return locale;
82 } finally {
83 signal.removeEventListener("abort", cancel);
84 }
85 }
86 return {
87 get locale() {
88 return locale;
89 },
90 start: async (_uri, { signal }) => {
91 await ready(signal);
92 return null;
93 },
94 transcribe: async (uri, options) =>
95 transcribeVoiceRecording(uri, await ready(options.signal), options),
96 };
98​
99async function prepareLocales(locales: readonly string[], signal: AbortSignal): Promise<string> {
100 throwIfVoiceTranscriptionAborted(signal);
101 let unsupported: VoiceTranscriptionError | undefined;
102 for (const locale of locales) {
103 try {
104 return await prepareLocale(locale, signal);
105 } catch (error) {
106 if (!isVoiceTranscriptionError(error) || error.code !== "unsupported-locale") throw error;
107 unsupported = error;
108 }
109 }
110 throw unsupported!;
112​
113async function prepareLocale(locale: string, signal: AbortSignal): Promise<string> {
114 try {
115 const supportedLocale = await AppleTranscription.prepare(locale);
116 throwIfVoiceTranscriptionAborted(signal);
117 return supportedLocale;
118 } catch (error) {
119 throwIfVoiceTranscriptionAborted(signal);
120 if (getNativeErrorCode(error) === "AppleTranscriptionUnsupportedLocale") {
121 throw new VoiceTranscriptionError({
122 code: "unsupported-locale",
123 message: "Voice transcription does not support any of your languages.",
124 cause: error,
125 });
126 }
127​
128 throw wrapError(
129 "preparation-failed",
130 "Voice transcription could not prepare this language.",
131 error,
132 );
133 }
135​
136async function transcribeVoiceRecording(
137 uri: string,
138 locale: string,
139 { signal }: VoiceTranscriptionOptions,
140): Promise<string> {
141 try {
142 throwIfVoiceTranscriptionAborted(signal);
143 const audio = await new File(uri).arrayBuffer();
144 throwIfVoiceTranscriptionAborted(signal);
145 const result = await AppleTranscription.transcribe(audio, locale);
146 throwIfVoiceTranscriptionAborted(signal);
147 return result.segments
148 .map((segment) => segment.text)
149 .join(" ")
150 .trim();
151 } catch (error) {
152 throwIfVoiceTranscriptionAborted(signal);
153 throw wrapError("transcription-failed", "Voice transcription failed.", error);
154 }