From 5ec36f596d30e67c7c8bbcb70d6d112e32f8d5c4 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Mon, 7 Sep 2026 15:56:48 +0200 Subject: [PATCH] fix(mobile): allow choosing the voice input language --- .../features/settings/SettingsRouteScreen.tsx | 50 +++++++++++++++++++ .../voice-input/useVoiceInputController.ts | 17 +++++-- .../src/native/voiceTranscription.ios.test.ts | 21 ++++++++ .../src/native/voiceTranscription.ios.ts | 5 +- apps/mobile/src/native/voiceTranscription.ts | 2 +- .../src/persistence/mobile-preferences.ts | 5 ++ docs/user/composer.md | 3 ++ 7 files changed, 95 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 3bd25a20c8da..f8f6b0f1ec9d 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -19,6 +19,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { ControlPillMenu } from "../../components/ControlPill"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; @@ -544,6 +545,9 @@ function ConfiguredSettingsRouteScreen() { function GeneralSettingsSection() { return ( + {Platform.OS === "ios" && Number.parseInt(Platform.Version, 10) >= 26 ? ( + + ) : null} @@ -551,6 +555,52 @@ function GeneralSettingsSection() { ); } +const VOICE_INPUT_LANGUAGES = [ + { id: "system", title: "System" }, + { id: "yue-CN", title: "Cantonese" }, + { id: "zh-CN", title: "Chinese (Simplified)" }, + { id: "zh-TW", title: "Chinese (Traditional)" }, + { id: "en-US", title: "English" }, + { id: "fr-FR", title: "French" }, + { id: "de-DE", title: "German" }, + { id: "it-IT", title: "Italian" }, + { id: "ja-JP", title: "Japanese" }, + { id: "ko-KR", title: "Korean" }, + { id: "pt-BR", title: "Portuguese" }, + { id: "es-ES", title: "Spanish" }, +]; + +function VoiceInputLanguageRow() { + const preferences = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + if (!AsyncResult.isSuccess(preferences)) return null; + const locale = preferences.value.voiceInputLocale ?? "system"; + + return ( + ({ + ...language, + state: language.id === locale ? "on" : "off", + }))} + onPressAction={({ nativeEvent }) => + savePreferences({ + voiceInputLocale: nativeEvent.event === "system" ? null : nativeEvent.event, + }) + } + > + language.id === locale)?.title} + /> + + ); +} + const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; /** diff --git a/apps/mobile/src/features/voice-input/useVoiceInputController.ts b/apps/mobile/src/features/voice-input/useVoiceInputController.ts index 8d1c9a9780a6..6f7dfc9e9945 100644 --- a/apps/mobile/src/features/voice-input/useVoiceInputController.ts +++ b/apps/mobile/src/features/voice-input/useVoiceInputController.ts @@ -1,3 +1,5 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { RecordingPresets, requestRecordingPermissionsAsync, @@ -14,6 +16,7 @@ import { useSharedValue } from "react-native-reanimated"; import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene"; import { VoiceInputController, @@ -69,6 +72,10 @@ export function useVoiceInputController(input: { readonly onChangeDraftMessage: (value: string) => void; readonly onChangeSelection: (selection: ComposerEditorSelection) => void; }) { + const preferences = useAtomValue(mobilePreferencesAtom); + const locale = AsyncResult.isSuccess(preferences) + ? (preferences.value.voiceInputLocale ?? undefined) + : undefined; const [state, setState] = useState(INITIAL_STATE); const [elapsedSeconds, setElapsedSeconds] = useState(0); const elapsedSecondsRef = useRef(0); @@ -84,8 +91,8 @@ export function useVoiceInputController(input: { previousDraftRef.current = { ownerKey: input.ownerKey, text: input.draftMessage }; revisionRef.current += 1; } - const latestInputRef = useRef(input); - latestInputRef.current = input; + const latestInputRef = useRef({ ...input, locale }); + latestInputRef.current = { ...input, locale }; const handleRecorderStatus = useCallback((status: RecordingStatus) => { controllerRef.current?.handleRecorderStatus({ @@ -100,7 +107,7 @@ export function useVoiceInputController(input: { if (!controllerRef.current) { controllerRef.current = new VoiceInputController({ recorder, - getTranscriber: getLocalVoiceTranscriber, + getTranscriber: () => getLocalVoiceTranscriber(latestInputRef.current.locale), requestPermission: async () => { const permission = await requestRecordingPermissionsAsync(); return { granted: permission.granted, canAskAgain: permission.canAskAgain }; @@ -206,7 +213,9 @@ export function useVoiceInputController(input: { return { // Store screenshots show the dictation button even on simulators, whose // on-device transcription is unavailable. - isAvailable: getLocalVoiceTranscriber() !== null || getNativeShowcaseScene() !== null, + isAvailable: + AsyncResult.isSuccess(preferences) && + (getLocalVoiceTranscriber(locale) !== null || getNativeShowcaseScene() !== null), state, audioLevels, elapsedSeconds, diff --git a/apps/mobile/src/native/voiceTranscription.ios.test.ts b/apps/mobile/src/native/voiceTranscription.ios.test.ts index b08e32cdb6f6..9991aa6d8db4 100644 --- a/apps/mobile/src/native/voiceTranscription.ios.test.ts +++ b/apps/mobile/src/native/voiceTranscription.ios.test.ts @@ -56,6 +56,27 @@ afterEach(() => { }); describe("getLocalVoiceTranscriber", () => { + it("transcribes in French when explicitly selected on an English device", async () => { + vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ + ...Intl.DateTimeFormat().resolvedOptions(), + locale: "en-US", + }); + mocks.prepare.mockResolvedValue("fr-FR"); + mocks.transcribe.mockResolvedValue({ + duration: 1, + segments: [{ text: "Bonjour.", startSecond: 0, endSecond: 1 }], + }); + const options = { signal: new AbortController().signal }; + const prepared = await getLocalVoiceTranscriber("fr-FR")!.prepare(options); + + await expect(prepared.transcribe("file:///voice.m4a", options)).resolves.toBe("Bonjour."); + expect(mocks.prepare).toHaveBeenCalledWith("fr-FR"); + expect(mocks.transcribe).toHaveBeenCalledWith(audio, "fr-FR"); + + await getLocalVoiceTranscriber()!.prepare(options); + expect(mocks.prepare).toHaveBeenLastCalledWith("en-US"); + }); + it("keeps the selected language and Apple's resolved locale when the device language changes", async () => { const resolvedOptions = Intl.DateTimeFormat().resolvedOptions(); const deviceLocale = vi diff --git a/apps/mobile/src/native/voiceTranscription.ios.ts b/apps/mobile/src/native/voiceTranscription.ios.ts index 216b9e958dd6..a19288bee782 100644 --- a/apps/mobile/src/native/voiceTranscription.ios.ts +++ b/apps/mobile/src/native/voiceTranscription.ios.ts @@ -33,8 +33,7 @@ function getNativeErrorCode(error: unknown): string | undefined { return typeof error.code === "string" ? error.code : undefined; } -export function getLocalVoiceTranscriber(): VoiceTranscriber | null { - const locale = getDeviceLocale(); +export function getLocalVoiceTranscriber(locale = getDeviceLocale()): VoiceTranscriber | null { if (!AppleTranscription.isAvailable(locale)) return null; return { prepare: (options) => prepareVoiceTranscription(locale, options) }; } @@ -63,7 +62,7 @@ async function prepareVoiceTranscription( if (getNativeErrorCode(error) === "AppleTranscriptionUnsupportedLocale") { throw new VoiceTranscriptionError( "unsupported-locale", - "Voice transcription does not support this device language.", + "Voice transcription does not support this language.", { cause: error }, ); } diff --git a/apps/mobile/src/native/voiceTranscription.ts b/apps/mobile/src/native/voiceTranscription.ts index e003064ae3f8..f9b4dcbf177b 100644 --- a/apps/mobile/src/native/voiceTranscription.ts +++ b/apps/mobile/src/native/voiceTranscription.ts @@ -1,5 +1,5 @@ import type { VoiceTranscriber } from "@t3tools/client-runtime/voice-input"; -export function getLocalVoiceTranscriber(): VoiceTranscriber | null { +export function getLocalVoiceTranscriber(_locale?: string): VoiceTranscriber | null { return null; } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index f14a73f15cb2..28e19419f71b 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -17,6 +17,7 @@ const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; export interface Preferences { readonly liveActivitiesEnabled?: boolean; + readonly voiceInputLocale?: string | null; readonly themeId?: MobileThemeId; readonly lightThemeId?: MobileThemeId; readonly darkThemeId?: MobileThemeId; @@ -86,6 +87,7 @@ export class MobilePreferencesStore extends Context.Service< function sanitizePreferences(parsed: Preferences): Preferences { const preferences: { liveActivitiesEnabled?: boolean; + voiceInputLocale?: string | null; themeId?: MobileThemeId; lightThemeId?: MobileThemeId; darkThemeId?: MobileThemeId; @@ -108,6 +110,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.liveActivitiesEnabled === "boolean") { preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; } + if (typeof parsed.voiceInputLocale === "string" || parsed.voiceInputLocale === null) { + preferences.voiceInputLocale = parsed.voiceInputLocale; + } if ( typeof parsed.themeId === "string" && (MOBILE_THEME_IDS as readonly string[]).includes(parsed.themeId) diff --git a/docs/user/composer.md b/docs/user/composer.md index 4a8df5333664..b668efd45508 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -86,6 +86,9 @@ On supported iPhones with iOS 26 or later, use the composer's microphone to reco then confirm to transcribe. Text is inserted where your selection was when recording started, ready for you to review and edit before sending. +Choose **Settings → General → Voice Input Language** to dictate in a different +language. **System** follows your device's locale. + The first use may download Apple's speech model and needs a network connection. Later transcription works offline for that language. Recordings can be up to five minutes long. Canceling, leaving the screen, or an audio interruption discards the