Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import { SettingsProviderAccountsRouteScreen } from "./features/settings/Setting
import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/SettingsEnvironmentDetailRouteScreen";
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsDictationLanguageRouteScreen } from "./features/settings/SettingsDictationLanguageRouteScreen";
import { SettingsFollowUpRouteScreen } from "./features/settings/SettingsFollowUpRouteScreen";
import {
SettingsEnvironmentAgentBehaviorRouteScreen,
Expand Down Expand Up @@ -332,6 +333,13 @@ const SettingsContentStack = createV5SheetStackNavigator({
title: "Microphone",
},
}),
SettingsDictationLanguage: createNativeStackScreen({
screen: SettingsDictationLanguageRouteScreen,
linking: "dictation-language",
options: {
title: "Dictation language",
},
}),
SettingsFollowUp: createNativeStackScreen({
screen: SettingsFollowUpRouteScreen,
linking: "follow-ups",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/reactivity";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { DEVICE_DICTATION_LANGUAGE_LABEL, DICTATION_LANGUAGES } from "../../lib/dictationLanguages";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { SettingsChoiceRow } from "./components/SettingsChoiceRow";
import { SettingsSection } from "./components/SettingsSection";

const OPTIONS = [{ id: null, label: DEVICE_DICTATION_LANGUAGE_LABEL }, ...DICTATION_LANGUAGES];

export function SettingsDictationLanguageRouteScreen() {
const insets = useSafeAreaInsets();
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
const selectedId = AsyncResult.isSuccess(preferencesResult)
? (preferencesResult.value.voiceInputLocale ?? null)
: undefined;

return (
<View collapsable={false} className="flex-1 bg-sheet">
<ScrollView
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
className="flex-1"
contentContainerClassName="gap-3 px-5 pt-4"
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
>
<SettingsSection footer="The language you speak when dictating.">
{OPTIONS.map((option, index) => (
<SettingsChoiceRow
key={option.id ?? "device"}
label={option.label}
selected={selectedId === option.id}
separated={index > 0}
disabled={!preferencesReady}
onPress={() => savePreferences({ voiceInputLocale: option.id })}
/>
))}
</SettingsSection>
</ScrollView>
</View>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { type AppSymbolName, SymbolView } from "../../components/ui/AppSymbol";
import { AppText as Text } from "../../components/ui/AppText";
import { cn } from "../../lib/cn";
import { dictationLanguageLabel } from "../../lib/dictationLanguages";
import {
isMicrophoneDevice,
type MicrophoneEntry,
Expand All @@ -21,6 +22,7 @@ import {
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { SettingsActionRow } from "./components/SettingsActionRow";
import { SettingsDragHandle } from "./components/SettingsDragHandle";
import { SettingsRow } from "./components/SettingsRow";
import { SettingsSection } from "./components/SettingsSection";

const REMOVE_SIZE = 20;
Expand Down Expand Up @@ -214,6 +216,18 @@ export function SettingsMicrophoneRouteScreen() {
devices are added by name after you use voice input with them connected; until then, the
Other row ranks them.
</Text>
<SettingsSection title="Language">
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
<SettingsRow
icon="globe"
label="Dictation language"
value={dictationLanguageLabel(
AsyncResult.isSuccess(preferencesResult)
? preferencesResult.value.voiceInputLocale
: undefined,
)}
target="SettingsDictationLanguage"
/>
</SettingsSection>
</ScrollView>
</View>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type SettingsSheetTarget =
| "SettingsProviderAccounts"
| "SettingsKeyboard"
| "SettingsMicrophone"
| "SettingsDictationLanguage"
| "SettingsFollowUp"
| "SettingsScheduledTasks"
| "SettingsProjectGrouping"
Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/features/voice-input/VoiceInputProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,14 @@ function useVoiceInputRuntime() {
recorderRef.current = recorder;
sessionRef.current = new VoiceInputSession({
recorder,
getTranscriber: getLocalVoiceTranscriber,
getTranscriber: () => {
const preferences = registry.get(mobilePreferencesAtom);
return getLocalVoiceTranscriber(
AsyncResult.isSuccess(preferences)
? (preferences.value.voiceInputLocale ?? undefined)
: undefined,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);
},
requestPermission: async () => {
const permission = await requestRecordingPermissionsAsync();
return { granted: permission.granted, canAskAgain: permission.canAskAgain };
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/src/lib/dictationLanguages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vite-plus/test";

import { dictationLanguageLabel, isDictationLanguage } from "./dictationLanguages";

describe("isDictationLanguage", () => {
it("accepts listed ids and rejects anything else", () => {
expect(isDictationLanguage("pt-BR")).toBe(true);
expect(isDictationLanguage("pt")).toBe(false);
expect(isDictationLanguage("xx-XX")).toBe(false);
expect(isDictationLanguage(42)).toBe(false);
});
});

describe("dictationLanguageLabel", () => {
it("names the chosen language and falls back to the device language", () => {
expect(dictationLanguageLabel("pt-BR")).toBe("Português (Brasil)");
expect(dictationLanguageLabel(undefined)).toBe("Device language");
});
});
38 changes: 38 additions & 0 deletions apps/mobile/src/lib/dictationLanguages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Every id is in SpeechTranscriber.supportedLocales on iOS 26. Labels are endonyms written out
// because Hermes's Intl.DisplayNames support is not guaranteed.
export const DICTATION_LANGUAGES: ReadonlyArray<{ readonly id: string; readonly label: string }> = [
{ id: "de-DE", label: "Deutsch (Deutschland)" },
{ id: "en-AU", label: "English (Australia)" },
{ id: "en-CA", label: "English (Canada)" },
{ id: "en-IN", label: "English (India)" },
{ id: "en-GB", label: "English (UK)" },
{ id: "en-US", label: "English (US)" },
{ id: "es-ES", label: "Español (España)" },
{ id: "es-US", label: "Español (Estados Unidos)" },
{ id: "es-MX", label: "Español (México)" },
{ id: "fr-CA", label: "Français (Canada)" },
{ id: "fr-FR", label: "Français (France)" },
{ id: "it-IT", label: "Italiano (Italia)" },
{ id: "pt-BR", label: "Português (Brasil)" },
{ id: "pt-PT", label: "Português (Portugal)" },
{ id: "hi-IN", label: "हिन्दी (भारत)" },
{ id: "ko-KR", label: "한국어(대한민국)" },
{ id: "zh-CN", label: "中文(中国大陆)" },
{ id: "zh-HK", label: "中文(香港)" },
{ id: "zh-TW", label: "中文(台灣)" },
{ id: "ja-JP", label: "日本語 (日本)" },
{ id: "yue-CN", label: "粤语(中国大陆)" },
];

export const DEVICE_DICTATION_LANGUAGE_LABEL = "Device language";

export function isDictationLanguage(value: unknown): value is string {
return DICTATION_LANGUAGES.some((language) => language.id === value);
}

export function dictationLanguageLabel(id: string | null | undefined): string {
return (
DICTATION_LANGUAGES.find((language) => language.id === id)?.label ??
DEVICE_DICTATION_LANGUAGE_LABEL
);
}
16 changes: 16 additions & 0 deletions apps/mobile/src/native/voiceTranscription.ios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ describe("getLocalVoiceTranscriber", () => {
expect(mocks.transcribe).toHaveBeenCalledWith(audio, "sv-SE");
});

it("prepares the chosen dictation language instead of the device language", async () => {
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({
...resolvedOptions,
locale: "en-US",
});
mocks.prepare.mockResolvedValue("pt_BR");

const prepared = await getLocalVoiceTranscriber("pt-BR")!.prepare({
signal: new AbortController().signal,
});

expect(mocks.prepare).toHaveBeenCalledWith("pt-BR");
expect(prepared.locale).toBe("pt_BR");
});

it("does not start native transcription after cancellation during a file read", async () => {
const enteredRead = deferred<void>();
const readResult = deferred<ArrayBuffer>();
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/native/voiceTranscription.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ 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(preferredLocale?: string): VoiceTranscriber | null {
const locale = preferredLocale ?? getDeviceLocale();
if (!AppleTranscription.isAvailable(locale)) return null;
return { prepare: (options) => prepareVoiceTranscription(locale, options) };
}
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/native/voiceTranscription.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { VoiceTranscriber } from "@t3tools/client-runtime/voice-input";

export function getLocalVoiceTranscriber(): VoiceTranscriber | null {
export function getLocalVoiceTranscriber(_preferredLocale?: string): VoiceTranscriber | null {
return null;
}
7 changes: 7 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "@t3tools/contracts";
import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
import type { FollowUpBehavior } from "../lib/followUpBehavior";
import { isDictationLanguage } from "../lib/dictationLanguages";
import { isMicrophoneKind, type MicrophoneEntry } from "../lib/microphonePriority";
import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
import * as MobileDatabase from "./mobile-database";
Expand Down Expand Up @@ -72,6 +73,8 @@ export interface Preferences {
readonly composerEnterBehavior?: ComposerEnterBehavior;
readonly followUpBehavior?: FollowUpBehavior;
readonly microphones?: ReadonlyArray<MicrophoneEntry>;
/** An id from `DICTATION_LANGUAGES`, or null for the device language. */
readonly voiceInputLocale?: string | null;
readonly projectGroupingEnabled?: boolean;
readonly projectGroupingMode?: SidebarProjectGroupingMode;
readonly planModeEnabled?: boolean;
Expand Down Expand Up @@ -145,6 +148,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
composerEnterBehavior?: ComposerEnterBehavior;
followUpBehavior?: FollowUpBehavior;
microphones?: ReadonlyArray<MicrophoneEntry>;
voiceInputLocale?: string | null;
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
planModeEnabled?: boolean;
Expand Down Expand Up @@ -240,6 +244,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
: [];
});
}
if (isDictationLanguage(parsed.voiceInputLocale) || parsed.voiceInputLocale === null) {
preferences.voiceInputLocale = parsed.voiceInputLocale;
}
if (typeof parsed.projectGroupingEnabled === "boolean") {
preferences.projectGroupingEnabled = parsed.projectGroupingEnabled;
}
Expand Down
80 changes: 80 additions & 0 deletions apps/mobile/src/state/preferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ vi.mock("../lib/runtime", async () => {
});

import { createMobilePreferencesState } from "./preferences";
import * as MobileDatabase from "../persistence/mobile-database";
import * as MobilePreferences from "../persistence/mobile-preferences";
import * as MobileSecureStorage from "../persistence/mobile-secure-storage";

function deferred<A>() {
let resolve!: (value: A) => void;
Expand Down Expand Up @@ -60,6 +62,53 @@ function makePreferencesState(
);
}

function makePreferencesStore(initialPayload: MobilePreferences.Preferences) {
const row = { payload: JSON.stringify(initialPayload), updatedAt: 1 };
const unused = Effect.die("unused in preferences tests");
return MobilePreferences.make().pipe(
Effect.provideService(
MobileDatabase.MobileDatabase,
MobileDatabase.MobileDatabase.of({
loadCache: () => unused,
listCache: () => unused,
saveCache: () => unused,
removeCache: () => unused,
clearCacheKind: () => unused,
clearEnvironmentCache: () => unused,
clearAllCaches: unused,
inspectCaches: unused,
loadPreferencesJson: Effect.sync(() => Option.some({ ...row })),
savePreferencesJson: (payload, updatedAt) =>
Effect.sync(() => {
row.payload = payload;
row.updatedAt = updatedAt;
}),
}),
),
Effect.provideService(
MobileSecureStorage.MobileSecureStorage,
MobileSecureStorage.MobileSecureStorage.of({
getItem: () => Effect.succeed(null),
setItem: () => Effect.void,
removeItem: () => Effect.void,
}),
),
Effect.map((store) => ({ store, storedPayload: () => JSON.parse(row.payload) as unknown })),
);
}

describe("mobile preferences store", () => {
it.effect("keeps a null dictation language and drops an unknown one", () =>
Effect.gen(function* () {
const cleared = yield* makePreferencesStore({ voiceInputLocale: null });
expect(yield* cleared.store.load).toEqual({ voiceInputLocale: null });

const unknown = yield* makePreferencesStore({ voiceInputLocale: "xx-XX" });
expect(yield* unknown.store.load).toEqual({});
}),
);
});

describe("mobile preferences state", () => {
it.effect("shares one preference load across consumers", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -355,4 +404,35 @@ describe("mobile preferences state", () => {
registry.dispose();
}),
);

it.effect("keeps the device dictation language after another preference saves", () =>
Effect.gen(function* () {
const { store, storedPayload } = yield* makePreferencesStore({ voiceInputLocale: "pt-BR" });
const state = createMobilePreferencesState(
Atom.runtime(Layer.succeed(MobilePreferences.MobilePreferencesStore, store)),
);
const registry = AtomRegistry.make();
const unmountPreferences = registry.mount(state.preferencesAtom);
const unmountUpdate = registry.mount(state.updatePreferencesAtom);
const save = (patch: Partial<MobilePreferences.Preferences>) => {
registry.set(state.updatePreferencesAtom, patch);
return AtomRegistry.getResult(registry, state.updatePreferencesAtom, {
suspendOnWaiting: true,
});
};

yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true });
yield* save({ voiceInputLocale: null });
yield* save({ baseFontSize: 18 });

expect(
yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true }),
).toEqual({ voiceInputLocale: null, baseFontSize: 18 });
expect(storedPayload()).toEqual({ voiceInputLocale: null, baseFontSize: 18 });

unmountUpdate();
unmountPreferences();
registry.dispose();
}),
);
});
3 changes: 3 additions & 0 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ for example, put **Other Bluetooth** above your AirPods so a new headset is pref
while the AirPods microphone stays low. By default, CarPlay comes after the built-in
microphone.

Voice input transcribes in your iPhone's language. If you speak a different language,
pick it in **Settings → Microphone → Dictation language**.

Transcription runs on your device. T3 Code deletes the temporary audio after
transcription or cancellation; only the message text is sent when you submit.

Expand Down
Loading