Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { type AppSymbolName } from "../../components/ui/AppSymbol";
import { AppText as Text } from "../../components/ui/AppText";
import { cn } from "../../lib/cn";
import { DICTATION_LANGUAGES } from "../../lib/dictationLanguage";
import { makePermissionStatusAtom } from "../../lib/permissionStatusAtom";
import {
isMicrophoneDevice,
Expand All @@ -22,6 +23,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/
import { ListRowLayout } from "../../components/ui/ListRowLayout";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow";
import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow";
import { SettingsDragHandle } from "./components/SettingsDragHandle";
import { SettingsSection } from "./components/SettingsSection";

Expand Down Expand Up @@ -96,6 +98,9 @@ export function SettingsMicrophoneRouteScreen() {
const entries = microphoneOrder(
AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.microphones : undefined,
);
const dictationLocale = AsyncResult.isSuccess(preferencesResult)
? (preferencesResult.value.dictationLocale ?? null)
: null;
const { permission, request: requestPermission } = useMicrophonePermission();
const [drag, setDrag] = useState<{ readonly key: string; readonly translation: number } | null>(
null,
Expand Down Expand Up @@ -201,6 +206,27 @@ export function SettingsMicrophoneRouteScreen() {
devices are added by name after you use voice input with them connected; until then, the
Other row ranks them. Swipe a device left to forget it.
</Text>
<SettingsSection title="Dictation language">
<SettingsChoiceRow
label="Device language"
selected={ready && dictationLocale === null}
disabled={!ready}
onPress={() => savePreferences({ dictationLocale: null })}
/>
{DICTATION_LANGUAGES.map((language) => (
<SettingsChoiceRow
key={language.locale}
label={language.label}
selected={dictationLocale === language.locale}
separated
disabled={!ready}
onPress={() => savePreferences({ dictationLocale: language.locale })}
/>
))}
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
Voice input transcribes on this device. The first use of a language downloads it.
</Text>
</ScrollView>
</View>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/settings/settings-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const SETTINGS_SEARCH_TERMS = {
],
SettingsNavigation: ["tabs", "tab order", "reorder", "hide tabs"],
SettingsKeyboard: ["return key", "send message", "new line", "hardware keyboard"],
SettingsMicrophone: ["voice", "dictation", "audio input", "bluetooth"],
SettingsMicrophone: ["voice", "dictation", "audio input", "bluetooth", "language"],
SettingsScheduledTasks: ["automations", "schedule", "cron", "recurring", "webhook"],
SettingsProjectOverview: ["project", "overview", "checkouts", "delete project"],
SettingsOrganization: [
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 @@ -91,7 +91,14 @@ const voiceInputAtom = Atom.make((get) => {
});
const session: VoiceInputSession = new VoiceInputSession({
recorder,
getTranscriber: getLocalVoiceTranscriber,
getTranscriber: () => {
const preferences = registry.get(mobilePreferencesAtom);
return getLocalVoiceTranscriber(
AsyncResult.isSuccess(preferences)
? (preferences.value.dictationLocale ?? undefined)
: undefined,
);
},
requestPermission: async () => {
const permission = await requestRecordingPermissionsAsync();
return { granted: permission.granted, canAskAgain: permission.canAskAgain };
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/lib/dictationLanguage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/** Locales Apple's on-device SpeechTranscriber offers. Unset follows the device language. */
export const DICTATION_LANGUAGES = [
{ locale: "en-US", label: "English (US)" },
{ locale: "en-GB", label: "English (UK)" },
{ locale: "en-AU", label: "English (Australia)" },
{ locale: "en-CA", label: "English (Canada)" },
{ locale: "en-IN", label: "English (India)" },
{ locale: "pt-BR", label: "Português (Brasil)" },
{ locale: "pt-PT", label: "Português (Portugal)" },
{ locale: "es-ES", label: "Español (España)" },
{ locale: "es-MX", label: "Español (México)" },
{ locale: "es-US", label: "Español (EE. UU.)" },
{ locale: "fr-FR", label: "Français (France)" },
{ locale: "fr-CA", label: "Français (Canada)" },
{ locale: "de-DE", label: "Deutsch" },
{ locale: "it-IT", label: "Italiano" },
{ locale: "ja-JP", label: "日本語" },
{ locale: "ko-KR", label: "한국어" },
{ locale: "zh-CN", label: "中文(普通话)" },
{ locale: "zh-TW", label: "中文(台灣)" },
{ locale: "yue-CN", label: "粵語" },
] as const;

export type DictationLocale = (typeof DICTATION_LANGUAGES)[number]["locale"];

const DICTATION_LOCALES: ReadonlySet<string> = new Set(
DICTATION_LANGUAGES.map((language) => language.locale),
);

export function isDictationLocale(value: unknown): value is DictationLocale {
return typeof value === "string" && DICTATION_LOCALES.has(value);
}
12 changes: 12 additions & 0 deletions apps/mobile/src/native/voiceTranscription.ios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,16 @@ describe("getLocalVoiceTranscriber", () => {
expect(error).toMatchObject({ code: "cancelled" });
expect(mocks.transcribe).not.toHaveBeenCalled();
});

it("prepares and transcribes the preferred locale instead of the device locale", async () => {
mocks.prepare.mockResolvedValue("pt-BR");
const options = { signal: new AbortController().signal };
const prepared = await getLocalVoiceTranscriber("pt-BR")!.prepare(options);
await prepared.transcribe("file:///voice.m4a", options);

expect(mocks.isAvailable).toHaveBeenCalledWith("pt-BR");
expect(mocks.prepare).toHaveBeenCalledWith("pt-BR");
expect(mocks.transcribe).toHaveBeenCalledWith(audio, "pt-BR");
expect(prepared.locale).toBe("pt-BR");
});
});
6 changes: 3 additions & 3 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 Expand Up @@ -63,7 +63,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 },
);
}
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 { AndroidNavigationStyle } from "../lib/androidNavigationStyle";
import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
import { isDictationLocale, type DictationLocale } from "../lib/dictationLanguage";
import type { FollowUpBehavior } from "../lib/followUpBehavior";
import { isMicrophoneKind, type MicrophoneEntry } from "../lib/microphonePriority";
import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
Expand Down Expand Up @@ -112,6 +113,8 @@ export interface Preferences {
readonly composerEnterBehavior?: ComposerEnterBehavior;
readonly followUpBehavior?: FollowUpBehavior;
readonly microphones?: ReadonlyArray<MicrophoneEntry>;
/** Unset or null means dictation follows the device language. */
readonly dictationLocale?: DictationLocale | null;
readonly projectGroupingEnabled?: boolean;
readonly projectGroupingMode?: SidebarProjectGroupingMode;
readonly planModeEnabled?: boolean;
Expand Down Expand Up @@ -192,6 +195,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
composerEnterBehavior?: ComposerEnterBehavior;
followUpBehavior?: FollowUpBehavior;
microphones?: ReadonlyArray<MicrophoneEntry>;
dictationLocale?: DictationLocale | null;
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
planModeEnabled?: boolean;
Expand Down Expand Up @@ -308,6 +312,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
: [];
});
}
if (parsed.dictationLocale === null || isDictationLocale(parsed.dictationLocale)) {
preferences.dictationLocale = parsed.dictationLocale;
}
if (typeof parsed.projectGroupingEnabled === "boolean") {
preferences.projectGroupingEnabled = parsed.projectGroupingEnabled;
}
Expand Down
3 changes: 3 additions & 0 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,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. Swipe a device left to forget it.

Voice input transcribes in your iPhone's language. To dictate in another language,
such as Brazilian Portuguese, pick it under **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