1import AppleTranscription from "@react-native-ai/apple/src/NativeAppleTranscription";
2import { File } from "expo-file-system";
3
4import {
5 VoiceTranscriptionError,
6 throwIfVoiceTranscriptionAborted,
7 type PreparedVoiceTranscription,
8 type VoiceTranscriber,
9 type VoiceTranscriptionOptions,
10} from "@t3tools/client-runtime/voice-input";
11
12function getDeviceLocale(): string {
13 return Intl.DateTimeFormat().resolvedOptions().locale;
14}
15
16function wrapError(
17 code: "preparation-failed" | "transcription-failed",
18 message: string,
19 cause: unknown,
20): VoiceTranscriptionError {
21 if (cause instanceof VoiceTranscriptionError) {
22 return cause;
23 }
24
25 return new VoiceTranscriptionError(code, message, { cause });
26}
27
28function getNativeErrorCode(error: unknown): string | undefined {
29 if (typeof error !== "object" || error === null || !("code" in error)) {
30 return undefined;
31 }
32
33 return typeof error.code === "string" ? error.code : undefined;
34}
35
36export function getLocalVoiceTranscriber(): VoiceTranscriber | null {
37 const locale = getDeviceLocale();
38 if (!AppleTranscription.isAvailable(locale)) return null;
39 return { prepare: (options) => prepareVoiceTranscription(locale, options) };
40}
41
42async function prepareVoiceTranscription(
43 locale: string,
44 { signal }: VoiceTranscriptionOptions,
45): Promise<PreparedVoiceTranscription> {
46 throwIfVoiceTranscriptionAborted(signal);
47 if (!AppleTranscription.isAvailable(locale)) {
48 throw new VoiceTranscriptionError(
49 "unavailable",
50 "Voice transcription requires a supported device with iOS 26 or later.",
51 );
52 }
53
54 try {
55 const supportedLocale = await AppleTranscription.prepare(locale);
56 throwIfVoiceTranscriptionAborted(signal);
57 return {
58 locale: supportedLocale,
59 transcribe: (uri, options) => transcribeVoiceRecording(uri, supportedLocale, options),
60 };
61 } catch (error) {
62 throwIfVoiceTranscriptionAborted(signal);
63 if (getNativeErrorCode(error) === "AppleTranscriptionUnsupportedLocale") {
64 throw new VoiceTranscriptionError(
65 "unsupported-locale",
66 "Voice transcription does not support this device language.",
67 { cause: error },
68 );
69 }
70
71 throw wrapError(
72 "preparation-failed",
73 "Voice transcription could not prepare this language.",
74 error,
75 );
76 }
77}
78
79async function transcribeVoiceRecording(
80 uri: string,
81 locale: string,
82 { signal }: VoiceTranscriptionOptions,
83): Promise<string> {
84 try {
85 throwIfVoiceTranscriptionAborted(signal);
86 const audio = await new File(uri).arrayBuffer();
87 throwIfVoiceTranscriptionAborted(signal);
88 const result = await AppleTranscription.transcribe(audio, locale);
89 throwIfVoiceTranscriptionAborted(signal);
90 return result.segments
91 .map((segment) => segment.text)
92 .join(" ")
93 .trim();
94 } catch (error) {
95 throwIfVoiceTranscriptionAborted(signal);
96 throw wrapError("transcription-failed", "Voice transcription failed.", error);
97 }
98}