diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx index f62348ad2f..0a208529b6 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/_layout.tsx @@ -1,11 +1,16 @@ import { Stack } from 'expo-router'; import { appUnlockScreenLayout } from '@/components/app-unlock-screen'; +import { useFormSheetDetents } from '@/lib/form-sheet'; export const unstable_settings = { initialRouteName: 'index', }; export default function KiloClawLayout() { + // Cap the full detent like every other formSheet: PickerSheet's header drops + // the top clearance ("bottom-form-sheet") on both platforms, which is only + // safe when the sheet cannot reach the status bar. + const { fullSheetDetent } = useFormSheetDetents(); return ( @@ -13,7 +18,7 @@ export default function KiloClawLayout() { name="chat/instance-picker" options={{ presentation: 'formSheet', - sheetAllowedDetents: [0.5, 1], + sheetAllowedDetents: [0.5, fullSheetDetent], sheetGrabberVisible: true, headerShown: false, }} diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 41481af941..fe8176c623 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -212,6 +212,15 @@ export default function AppLayout() { headerShown: false, }} /> + ; +} diff --git a/apps/mobile/src/components/agents/instance-picker.mounted.test.tsx b/apps/mobile/src/components/agents/instance-picker.mounted.test.tsx index 2fe1d74113..a82dae80f6 100644 --- a/apps/mobile/src/components/agents/instance-picker.mounted.test.tsx +++ b/apps/mobile/src/components/agents/instance-picker.mounted.test.tsx @@ -1,4 +1,5 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts the native tree without a DOM. */ +/* eslint-disable max-lines -- the full react-native mock harness (FlatList, Platform for SheetHeader) stays inline so the picker contract reads as one screen */ import { createElement, type EffectCallback, Fragment, type ReactNode, useEffect } from 'react'; import { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; @@ -27,6 +28,8 @@ type ListProps = { vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + StatusBar: { currentHeight: 0 }, FlatList: (props: ListProps) => createElement( 'FlatList', diff --git a/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx b/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx index 5842b4d9c2..356bede6bf 100644 --- a/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx +++ b/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx @@ -72,6 +72,7 @@ vi.mock('react-native', () => ({ Switch: 'Switch', ActivityIndicator: 'ActivityIndicator', Platform: platform, + StatusBar: { currentHeight: 0 }, I18nManager: { isRTL: false }, AccessibilityInfo: { announceForAccessibility: announcements }, AppState: { diff --git a/apps/mobile/src/components/code-reviewer/review-detail-screen.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/review-detail-screen.mounted.test.tsx index c54dfe63b5..37d4190056 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.mounted.test.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.mounted.test.tsx @@ -78,6 +78,9 @@ vi.mock('react-native', () => ({ }, Pressable: 'Pressable', Platform: nativePlatform, + // SheetHeader reads the synchronous Android status-bar height the same way + // the form-sheet detents do (src/lib/form-sheet.ts). + StatusBar: { currentHeight: 24 }, AppState: { addEventListener: () => ({ remove: vi.fn() }) }, Alert: { alert: vi.fn() }, })); diff --git a/apps/mobile/src/components/picker-sheet.tsx b/apps/mobile/src/components/picker-sheet.tsx index 42ab734c2b..ee3ba2baa0 100644 --- a/apps/mobile/src/components/picker-sheet.tsx +++ b/apps/mobile/src/components/picker-sheet.tsx @@ -64,6 +64,7 @@ export function PickerSheet({ doneLabel={doneLabel} cancelLabel={cancelLabel} disabled={disabled} + topInset="bottom-form-sheet" /> {headerContent} diff --git a/apps/mobile/src/components/sheet-header-layout.mounted.test.tsx b/apps/mobile/src/components/sheet-header-layout.mounted.test.tsx index 4cbb2475d5..5b21528c4b 100644 --- a/apps/mobile/src/components/sheet-header-layout.mounted.test.tsx +++ b/apps/mobile/src/components/sheet-header-layout.mounted.test.tsx @@ -6,7 +6,12 @@ import { describe, expect, it, vi } from 'vitest'; import { SheetHeader } from './sheet-header'; import '@/i18n'; -vi.mock('react-native', () => ({ Pressable: 'Pressable', View: 'View' })); +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + StatusBar: { currentHeight: 0 }, + View: 'View', +})); // SheetHeader reads the landscape side insets; this suite mounts without a // device, so the hook gets portrait-zero insets (same pattern as // sheet-header.mounted.test.tsx). diff --git a/apps/mobile/src/components/sheet-header.mounted.test.tsx b/apps/mobile/src/components/sheet-header.mounted.test.tsx index 43b6602a90..1a1ade3a5f 100644 --- a/apps/mobile/src/components/sheet-header.mounted.test.tsx +++ b/apps/mobile/src/components/sheet-header.mounted.test.tsx @@ -8,12 +8,23 @@ import { SheetHeader } from './sheet-header'; import '@/i18n'; const safeArea = vi.hoisted(() => ({ top: 0, bottom: 0, left: 0, right: 0 })); +const rn = vi.hoisted(() => ({ os: 'ios', statusHeight: 0 })); vi.mock('react-native', () => ({ Pressable: 'Pressable', ScrollView: 'ScrollView', View: 'View', I18nManager: { allowRTL: vi.fn(), isRTL: false, forceRTL: vi.fn() }, + Platform: { + get OS() { + return rn.os; + }, + }, + StatusBar: { + get currentHeight() { + return rn.statusHeight; + }, + }, })); vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => safeArea, @@ -69,26 +80,46 @@ function findHeaderContainer(root: TestRenderer.ReactTestInstance): TestRenderer } /** - * The header row sits in an inner wrapper that carries only the landscape side + * The header row sits in an inner wrapper that carries the top and landscape side * insets, so they add to the outer container's `px-4` gutter instead of - * overriding it. It is the only View in the header without a className. + * overriding it. Derive it from the row it wraps: a parent shell (PickerSheet) + * also renders a className-less View, so scanning for one would return the shell + * and the assertion on the wrapper's style would be vacuous. */ -function findSideInsetWrapper( - root: TestRenderer.ReactTestInstance -): TestRenderer.ReactTestInstance { - const wrappers = root.findAll( +function findSafeAreaWrapper(root: TestRenderer.ReactTestInstance): TestRenderer.ReactTestInstance { + const row = root.find( node => typeof node.type === 'string' && (node.type as string) === 'View' && - node.props.className === undefined + typeof node.props.className === 'string' && + node.props.className.includes('flex-row') ); - const wrapper = wrappers[0]; - if (!wrapper) { - throw new Error('side-inset wrapper not found'); + const wrapper = row.parent; + if (!wrapper || typeof wrapper.type !== 'string') { + throw new Error('safe-area wrapper not found'); } return wrapper; } +/** + * Mounts a bottom-form-sheet header with a resolved top inset under `os` and + * returns the inset wrapper's style, so the test can compare platforms. + */ +async function bottomFormSheetTopInsetStyle(os: string): Promise { + rn.os = os; + rn.statusHeight = 48; + safeArea.top = 59; + const renderer = await mount({ + title: 'Voice language', + onDone: () => undefined, + onCancel: () => undefined, + topInset: 'bottom-form-sheet', + }); + const style = findSafeAreaWrapper(renderer.root).props.style; + renderer.unmount(); + return style; +} + function HeaderWithActionFeedback({ initialTitle = 'report.pdf', doneLabel = 'Finish', @@ -116,6 +147,8 @@ function HeaderWithActionFeedback({ describe('SheetHeader', () => { beforeEach(() => { Object.assign(safeArea, { top: 0, bottom: 0, left: 0, right: 0 }); + rn.os = 'ios'; + rn.statusHeight = 0; }); it('renders a Share pressable in the leading slot when onShare is provided', async () => { @@ -370,7 +403,7 @@ describe('SheetHeader', () => { expect(container.props.collapsable).toBe(false); expect(container.props.className).toContain('px-4'); expect(container.props.style).toBeUndefined(); - expect(findSideInsetWrapper(renderer.root).props.style).toBeUndefined(); + expect(findSafeAreaWrapper(renderer.root).props.style).toBeUndefined(); renderer.unmount(); }); @@ -391,7 +424,7 @@ describe('SheetHeader', () => { expect(container.props.collapsable).toBe(false); expect(container.props.className).toContain('px-4'); expect(container.props.style).toBeUndefined(); - const wrapper = findSideInsetWrapper(renderer.root); + const wrapper = findSafeAreaWrapper(renderer.root); expect(wrapper.props.style).toEqual({ paddingLeft: 47, paddingRight: 59 }); const cancel = pressablesByLabel(renderer.root, 'Cancel')[0]; const done = pressablesByLabel(renderer.root, 'Done')[0]; @@ -400,4 +433,142 @@ describe('SheetHeader', () => { renderer.unmount(); }); + + it('clears the status bar when the sheet reaches the top safe area', async () => { + safeArea.top = 24; + const renderer = await mount({ + title: 'Voice language', + onDone: () => undefined, + onCancel: () => undefined, + }); + + const container = findHeaderContainer(renderer.root); + // The safe area adds clearance rather than replacing the outer gutter. + // Cancel, the title, and Done remain together inside the protected row. + expect(container.props.className).toContain('pt-4'); + const wrapper = findSafeAreaWrapper(renderer.root); + expect(wrapper.props.style).toEqual({ paddingTop: 24 }); + expect(pressablesByLabel(renderer.root, 'Cancel')[0]?.parent?.parent).toBe(wrapper); + expect(pressablesByLabel(renderer.root, 'Done')[0]?.parent?.parent).toBe(wrapper); + + renderer.unmount(); + }); + + it.each(['ios', 'android'])( + 'falls back to the synchronous status-bar height while the top inset is unresolved (%s)', + async os => { + rn.os = os; + rn.statusHeight = 48; + const renderer = await mount({ + title: 'Voice language', + onDone: () => undefined, + onCancel: () => undefined, + }); + + // The frame a freshly presented sheet lays out can report top: 0; the + // synchronous status-bar height keeps the Done pill below the icons. + const wrapper = findSafeAreaWrapper(renderer.root); + expect(wrapper.props.style).toEqual({ paddingTop: 48 }); + + renderer.unmount(); + } + ); + + it.each(['ios', 'android'])( + 'prefers a resolved top inset over the status-bar fallback (%s)', + async os => { + rn.os = os; + rn.statusHeight = 48; + safeArea.top = 24; + const renderer = await mount({ + title: 'Voice language', + onDone: () => undefined, + onCancel: () => undefined, + }); + + const wrapper = findSafeAreaWrapper(renderer.root); + expect(wrapper.props.style).toEqual({ paddingTop: 24 }); + + renderer.unmount(); + } + ); + + it.each(['ios', 'android'])('drops the top clearance for a bottom formSheet (%s)', async os => { + // p7: a bottom-anchored sheet never draws under the status bar, so the + // resolved window inset is only a dead band above the header — one rule + // reserves nothing on either platform. + rn.os = os; + rn.statusHeight = 48; + safeArea.top = 24; + const renderer = await mount({ + title: 'Voice language', + onDone: () => undefined, + onCancel: () => undefined, + topInset: 'bottom-form-sheet', + }); + + const wrapper = findSafeAreaWrapper(renderer.root); + expect(wrapper.props.style).toBeUndefined(); + + renderer.unmount(); + }); + + it('reserves the same bottom-form-sheet top clearance regardless of Platform.OS', async () => { + // Regression for the removed platform fork: with a resolved top inset the + // old iOS branch kept 59 while Android dropped it. One implementation now + // drops it under identical inputs on both OS values. + const iosStyle = await bottomFormSheetTopInsetStyle('ios'); + const androidStyle = await bottomFormSheetTopInsetStyle('android'); + + expect(iosStyle).toBeUndefined(); + expect(androidStyle).toBe(iosStyle); + }); + + it.each(['ios', 'android'])( + 'keeps landscape side insets for a bottom formSheet while dropping the top clearance (%s)', + async os => { + rn.os = os; + rn.statusHeight = 48; + safeArea.top = 24; + safeArea.left = 47; + safeArea.right = 59; + const renderer = await mount({ + title: 'Voice language', + onDone: () => undefined, + onCancel: () => undefined, + topInset: 'bottom-form-sheet', + }); + + const wrapper = findSafeAreaWrapper(renderer.root); + expect(wrapper.props.style).toEqual({ paddingLeft: 47, paddingRight: 59 }); + + renderer.unmount(); + } + ); + + it.each(['ios', 'android'])( + 'passes the bottom-form-sheet top-inset mode through PickerSheet (%s)', + async os => { + // The picker shells are bottom formSheets: they sit below the status + // bar, so the shell must not reserve the window's top inset. + rn.os = os; + rn.statusHeight = 48; + safeArea.top = 24; + const renderer = await mountElement( + createElement( + PickerSheet, + { title: 'Voice language', onDone: () => undefined }, + createElement('Text', null, 'Picker content') + ) + ); + + const wrapper = findSafeAreaWrapper(renderer.root); + // The wrapper must be SheetHeader's own inset wrapper, not PickerSheet's + // className-less shell, or this assertion is vacuous. + expect(pressablesByLabel(renderer.root, 'Done')[0]?.parent?.parent).toBe(wrapper); + expect(wrapper.props.style).toBeUndefined(); + + renderer.unmount(); + } + ); }); diff --git a/apps/mobile/src/components/sheet-header.tsx b/apps/mobile/src/components/sheet-header.tsx index 4594cae1a4..e6a986510e 100644 --- a/apps/mobile/src/components/sheet-header.tsx +++ b/apps/mobile/src/components/sheet-header.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { Pressable, View } from 'react-native'; +import { Pressable, StatusBar, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Share } from '@/components/ui/icons'; @@ -7,6 +7,20 @@ import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; +/** + * How much top clearance the header reserves: + * + * - 'always': the surface owns the top of the window (full-screen modals, + * pageSheets), so the status-bar inset applies whenever it is non-zero. + * - 'bottom-form-sheet': a bottom-anchored formSheet never draws under the + * status bar, so it reserves no top clearance on either platform — the + * window inset would only be a dead band above the header (p7). Android + * caps the detents just below the inset (useFormSheetDetents) and the iOS + * sheet clears the top edge with its grabber, so the same rule holds + * everywhere. + */ +export type SheetHeaderTopInset = 'always' | 'bottom-form-sheet'; + export function SheetHeader({ title, titleEllipsis = 'tail', @@ -17,6 +31,7 @@ export function SheetHeader({ onShare, sharing = false, disabled = false, + topInset = 'always', }: { title: string; /** @@ -35,21 +50,31 @@ export function SheetHeader({ onShare?: () => void; sharing?: boolean; disabled?: boolean; + topInset?: SheetHeaderTopInset; }) { const { t } = useTranslation(); const colors = useThemeColors(); const insets = useSafeAreaInsets(); const resolvedDoneLabel = doneLabel ?? t('common.done'); const resolvedCancelLabel = cancelLabel ?? t('common.cancel'); - // Landscape side safe areas (notch/Dynamic Island, Android cutouts) shift the - // header row off the sensor on full-width sheets. They go on an inner wrapper - // so they ADD to the `px-4` gutter: an inline padding on the container would - // beat the className (inline style wins in React Native) and swallow the - // gutter. Zero insets collapse the wrapper style to `undefined`, so portrait - // pixels are byte-identical and a rotation never moves anything vertically. - const sideInsetStyle = - insets.left > 0 || insets.right > 0 + // Reserve top clearance as well as landscape cutout clearance inside the + // gutters. Keeping the inset on an inner wrapper preserves the header's own + // padding. Android can report top: 0 for the frame a freshly presented + // sheet first lays out (before the insets propagate); fall back to the + // synchronous status-bar height the same way the form-sheet detents do. + // `StatusBar.currentHeight` is an Android-only API and `undefined` on iOS, + // so the nullish fallback yields the status-bar height on both platforms + // without branching on Platform.OS. + const statusBarHeight = StatusBar.currentHeight ?? 0; + const resolvedTopInset = insets.top > 0 ? insets.top : statusBarHeight; + // A bottom formSheet is anchored below the status bar on both platforms, so + // it reserves no top clearance; any resolved window inset would be a dead + // band above the header. + const topInsetHeight = topInset === 'bottom-form-sheet' ? 0 : resolvedTopInset; + const safeAreaStyle = + topInsetHeight > 0 || insets.left > 0 || insets.right > 0 ? { + ...(topInsetHeight > 0 ? { paddingTop: topInsetHeight } : undefined), ...(insets.left > 0 ? { paddingLeft: insets.left } : undefined), ...(insets.right > 0 ? { paddingRight: insets.right } : undefined), } @@ -61,7 +86,7 @@ export function SheetHeader({ // view by finding the header at the screen content's subview index 0 — a // flattened header breaks that native pass and the list paints over it. - + {onShare !== undefined ? ( void; }; -const push = vi.hoisted(() => vi.fn()); -const gatewayTranscription = vi.hoisted(() => ({ - enabled: false, - hasLoaded: true, - setEnabled: vi.fn(), -})); -const selection = vi.hoisted(() => { - const current: SelectionState = { +function emptySelection(): SelectionState { + return { status: 'off', model: null, models: [], @@ -33,10 +33,18 @@ const selection = vi.hoisted(() => { isFetching: false, refetch: vi.fn<() => void>(), }; - return { current }; -}); +} + +const push = vi.hoisted(() => vi.fn()); +const gatewayTranscription = vi.hoisted(() => ({ + enabled: false, + hasLoaded: true, + setEnabled: vi.fn(), +})); +const selection = vi.hoisted(() => ({ current: emptySelection() })); const selectionArgs = vi.hoisted(() => ({ organizationId: undefined as string | undefined })); const organization = vi.hoisted(() => ({ organizationId: 'org-1' as string | null })); +const voiceLanguage = vi.hoisted(() => ({ chosen: null as string | null, loaded: true })); vi.mock('react-native', () => ({ Switch: 'Switch', @@ -48,6 +56,7 @@ vi.mock('expo-router', () => ({ })); vi.mock('@/components/ui/icons', () => ({ Cpu: 'Cpu', + Globe: 'Globe', Mic: 'Mic', })); vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null })); @@ -56,12 +65,26 @@ vi.mock('@/components/ui/configure-row', () => ({ ConfigureRow: 'ConfigureRow' } vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' })); vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/voice-test-field', () => ({ VoiceTestField: 'VoiceTestField' })); vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ secondaryForeground: '#000000', mutedForeground: '#000000' }), })); vi.mock('@/lib/organization-context', () => ({ useOrganization: () => ({ organizationId: organization.organizationId }), })); +// The real module resolves endonyms through `@/i18n/resolve-language`, which +// imports the native localization/recognition modules; stub the native edges +// so the row's display name runs for real without the device services. +vi.mock('expo-localization', () => ({ + getLocales: () => [{ languageTag: 'en-US' }], +})); +vi.mock('expo-speech-recognition', () => ({ + ExpoSpeechRecognitionModule: { getSupportedLocales: vi.fn() }, +})); +vi.mock('@/lib/voice-input/voice-input-language-preference', () => ({ + useVoiceInputLanguage: () => voiceLanguage.chosen, + useVoiceInputLanguageLoaded: () => voiceLanguage.loaded, +})); vi.mock('@/lib/voice-input/gateway/gateway-transcription-preference', () => ({ useGatewayTranscriptionPreference: () => ({ gatewayTranscriptionEnabled: gatewayTranscription.enabled, @@ -85,50 +108,6 @@ function setSelection(patch: Partial): void { selection.current = { ...selection.current, ...patch }; } -let view: Awaited> | undefined = undefined; -async function mountVoiceInput(): Promise { - view = await renderWithProviders(); - await act(async () => { - await vi.dynamicImportSettled(); - }); - return view.renderer; -} -function findConfigureRow(renderer: ReactTestRenderer, title: string) { - const rows = renderer.root.findAll( - node => typeof node.type === 'string' && (node.type as string) === 'ConfigureRow' - ); - const row = rows.find(item => item.props.title === title); - if (!row) { - throw new Error(`ConfigureRow for ${title} not found`); - } - return row; -} -function findGatewaySwitch(renderer: ReactTestRenderer) { - const found = renderer.root.findAll( - node => typeof node.type === 'string' && (node.type as string) === 'Switch' - ); - const foundSwitch = found.find(sw => sw.props.accessibilityLabel === 'Gateway transcription'); - if (!foundSwitch) { - throw new Error('Gateway transcription switch not found'); - } - return foundSwitch; -} -function findTexts(renderer: ReactTestRenderer): string[] { - return renderer.root - .findAll( - node => - typeof node.type === 'string' && - (node.type as string) === 'Text' && - typeof node.props.children === 'string' - ) - .map(node => node.props.children as string); -} -function findQueryErrors(renderer: ReactTestRenderer) { - return renderer.root.findAll( - node => typeof node.type === 'string' && (node.type as string) === 'QueryError' - ); -} - beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.resetAllMocks(); @@ -136,24 +115,17 @@ beforeEach(() => { gatewayTranscription.hasLoaded = true; organization.organizationId = 'org-1'; selectionArgs.organizationId = undefined; - selection.current = { - status: 'off', - model: null, - models: [], - isLoading: false, - isError: false, - isFetching: false, - refetch: vi.fn<() => void>(), - }; + voiceLanguage.chosen = null; + voiceLanguage.loaded = true; + selection.current = emptySelection(); }); afterEach(() => { - view?.unmount(); - view = undefined; + unmountVoiceInputSettingsScreen(); }); describe('VoiceInputSettingsScreen', () => { it('renders the gateway transcription switch and its title and subtitle', async () => { - const renderer = await mountVoiceInput(); + const renderer = await mountVoiceInputSettingsScreen(); expect(findGatewaySwitch(renderer).props).toMatchObject({ value: false, disabled: false }); @@ -166,14 +138,14 @@ describe('VoiceInputSettingsScreen', () => { it('scopes the model catalogue read to the selected organization', async () => { organization.organizationId = 'org-42'; - await mountVoiceInput(); + await mountVoiceInputSettingsScreen(); expect(selectionArgs.organizationId).toBe('org-42'); }); it('reads the catalogue unscoped for a personal account', async () => { organization.organizationId = null; - await mountVoiceInput(); + await mountVoiceInputSettingsScreen(); expect(selectionArgs.organizationId).toBeUndefined(); }); @@ -182,20 +154,23 @@ describe('VoiceInputSettingsScreen', () => { // The catalogue is loaded, so the hook reports a model; the off status must // still win and keep the caption unset rather than naming that model. setSelection({ status: 'off', model: MODELS[0], models: MODELS }); - const renderer = await mountVoiceInput(); + const renderer = await mountVoiceInputSettingsScreen(); expect(findConfigureRow(renderer, 'Transcription model').props).toMatchObject({ icon: 'Cpu', subtitle: 'None chosen', disabled: true, - last: true, }); + // The language row is now the group's final row, so the model row must not + // carry the divider-suppressing `last`. + expect(findConfigureRow(renderer, 'Transcription model').props.last).toBeUndefined(); + expect(findConfigureRow(renderer, 'Language').props.last).toBe(true); }); it('shows the loading caption disabled while the catalogue settles', async () => { gatewayTranscription.enabled = true; setSelection({ status: 'loading', isLoading: true }); - const renderer = await mountVoiceInput(); + const renderer = await mountVoiceInputSettingsScreen(); expect(findConfigureRow(renderer, 'Transcription model').props).toMatchObject({ subtitle: 'Loading…', @@ -207,7 +182,7 @@ describe('VoiceInputSettingsScreen', () => { it('shows the auto-selected model and opens the picker when ready', async () => { gatewayTranscription.enabled = true; setSelection({ status: 'ready', model: MODELS[0], models: MODELS }); - const renderer = await mountVoiceInput(); + const renderer = await mountVoiceInputSettingsScreen(); const row = findConfigureRow(renderer, 'Transcription model'); expect(row.props).toMatchObject({ subtitle: 'Whisper Large v3', disabled: false }); @@ -221,7 +196,7 @@ describe('VoiceInputSettingsScreen', () => { it('keeps an unavailable stored model, opens the picker, and shows the notice with no retry', async () => { gatewayTranscription.enabled = true; setSelection({ status: 'unavailable', model: MODELS[0], models: MODELS }); - const renderer = await mountVoiceInput(); + const renderer = await mountVoiceInputSettingsScreen(); const row = findConfigureRow(renderer, 'Transcription model'); expect(row.props).toMatchObject({ subtitle: 'Whisper Large v3', disabled: false }); @@ -248,7 +223,7 @@ describe('VoiceInputSettingsScreen', () => { gatewayTranscription.enabled = true; const refetch = vi.fn<() => void>(); setSelection({ status: 'error', isLoading: false, isError: true, refetch }); - const renderer = await mountVoiceInput(); + const renderer = await mountVoiceInputSettingsScreen(); // The row is disabled and carries no failure copy: the state block below is // the single message, so the same failure is never read twice. @@ -277,7 +252,7 @@ describe('VoiceInputSettingsScreen', () => { gatewayTranscription.enabled = true; const refetch = vi.fn<() => void>(); setSelection({ status: 'empty', isLoading: false, isError: false, models: [], refetch }); - const renderer = await mountVoiceInput(); + const renderer = await mountVoiceInputSettingsScreen(); // The row is disabled and carries no failure copy: the state block below is // the single message, so the same failure is never read twice. @@ -307,7 +282,7 @@ describe('VoiceInputSettingsScreen', () => { async status => { gatewayTranscription.enabled = true; setSelection({ status, model: MODELS[0], models: MODELS }); - const renderer = await mountVoiceInput(); + const renderer = await mountVoiceInputSettingsScreen(); expect(findTexts(renderer)).not.toContain('None chosen'); } @@ -315,8 +290,76 @@ describe('VoiceInputSettingsScreen', () => { it('keeps the gateway switch disabled until the preference has loaded', async () => { gatewayTranscription.hasLoaded = false; - const renderer = await mountVoiceInput(); + const renderer = await mountVoiceInputSettingsScreen(); expect(findGatewaySwitch(renderer).props.disabled).toBe(true); }); + + it('shows the automatic language and opens the picker when no choice is stored', async () => { + voiceLanguage.chosen = null; + const renderer = await mountVoiceInputSettingsScreen(); + + const row = findConfigureRow(renderer, 'Language'); + expect(row.props).toMatchObject({ + icon: 'Globe', + subtitle: 'Automatic', + disabled: false, + }); + + act(() => { + (row.props.onPress as () => void)(); + }); + expect(push).toHaveBeenCalledWith('/(app)/voice-language-picker'); + }); + + it.each([ + ['de-DE', 'Deutsch'], + // Android's speech service stores the choice as `cmn-Hans-CN`; the row + // must read like the picker rows (p16, p4) instead of quoting the tag. + ['cmn-Hans-CN', '简体中文'], + ])('names the stored voice language %s by its endonym', async (chosen, endonym) => { + voiceLanguage.chosen = chosen; + const renderer = await mountVoiceInputSettingsScreen(); + + expect(findConfigureRow(renderer, 'Language').props).toMatchObject({ + subtitle: endonym, + disabled: false, + }); + }); + + it('shows the loading caption and disables the language row until the choice has loaded', async () => { + voiceLanguage.loaded = false; + voiceLanguage.chosen = 'de-DE'; + const renderer = await mountVoiceInputSettingsScreen(); + + expect(findConfigureRow(renderer, 'Language').props).toMatchObject({ + subtitle: 'Loading…', + disabled: true, + }); + }); + + it('renders the voice testing field below the model states', async () => { + const renderer = await mountVoiceInputSettingsScreen(); + + const fields = renderer.root.findAll( + node => typeof node.type === 'string' && (node.type as string) === 'VoiceTestField' + ); + expect(fields).toHaveLength(1); + }); + + it('insets the scroll content and persists taps on the test-field controls', async () => { + const renderer = await mountVoiceInputSettingsScreen(); + + const [scroll] = renderer.root.findAll( + node => typeof node.type === 'string' && (node.type as string) === 'ScrollView' + ); + if (!scroll) { + throw new Error('ScrollView not found'); + } + expect(scroll.props.automaticallyAdjustKeyboardInsets).toBe(true); + // With the keyboard up, the default ('never') spends the first tap on the + // Clear control dismissing the keyboard, so the text survives the tap + // (e3, 2026-09-12). 'handled' hands the tap to the control itself. + expect(scroll.props.keyboardShouldPersistTaps).toBe('handled'); + }); }); diff --git a/apps/mobile/src/components/voice-input-settings-screen.test-helpers.ts b/apps/mobile/src/components/voice-input-settings-screen.test-helpers.ts new file mode 100644 index 0000000000..0205ebcf2b --- /dev/null +++ b/apps/mobile/src/components/voice-input-settings-screen.test-helpers.ts @@ -0,0 +1,71 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer types for the mounted-test finders (same pattern as the sibling mounted tests) */ +import { createElement } from 'react'; +import { act, type ReactTestRenderer } from 'react-test-renderer'; +import { vi } from 'vitest'; + +import { VoiceInputSettingsScreen } from '@/components/voice-input-settings-screen'; +import { renderWithProviders } from '@/test/render-with-providers'; + +let mountedView: Awaited> | undefined = undefined; + +/** + * Mounts the voice input settings screen with its providers and lets pending + * dynamic imports settle. The rendered tree is tracked so the caller can unmount + * it from `afterEach`. + */ +export async function mountVoiceInputSettingsScreen(): Promise { + const view = await renderWithProviders(createElement(VoiceInputSettingsScreen)); + await act(async () => { + await vi.dynamicImportSettled(); + }); + mountedView = view; + return view.renderer; +} + +export function unmountVoiceInputSettingsScreen(): void { + mountedView?.unmount(); + mountedView = undefined; +} + +/** + * Finders shared by the voice input settings mounted tests. They live here so + * the test file stays under the repository's file-length limit. + */ +export function findConfigureRow(renderer: ReactTestRenderer, title: string) { + const rows = renderer.root.findAll( + node => typeof node.type === 'string' && (node.type as string) === 'ConfigureRow' + ); + const row = rows.find(item => item.props.title === title); + if (!row) { + throw new Error(`ConfigureRow for ${title} not found`); + } + return row; +} + +export function findGatewaySwitch(renderer: ReactTestRenderer) { + const found = renderer.root.findAll( + node => typeof node.type === 'string' && (node.type as string) === 'Switch' + ); + const foundSwitch = found.find(sw => sw.props.accessibilityLabel === 'Gateway transcription'); + if (!foundSwitch) { + throw new Error('Gateway transcription switch not found'); + } + return foundSwitch; +} + +export function findTexts(renderer: ReactTestRenderer): string[] { + return renderer.root + .findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'Text' && + typeof node.props.children === 'string' + ) + .map(node => node.props.children as string); +} + +export function findQueryErrors(renderer: ReactTestRenderer) { + return renderer.root.findAll( + node => typeof node.type === 'string' && (node.type as string) === 'QueryError' + ); +} diff --git a/apps/mobile/src/components/voice-input-settings-screen.tsx b/apps/mobile/src/components/voice-input-settings-screen.tsx index 24ebabc504..04d8e2b384 100644 --- a/apps/mobile/src/components/voice-input-settings-screen.tsx +++ b/apps/mobile/src/components/voice-input-settings-screen.tsx @@ -1,5 +1,5 @@ import { type Href, useRouter } from 'expo-router'; -import { Cpu, Mic } from '@/components/ui/icons'; +import { Cpu, Globe, Mic } from '@/components/ui/icons'; import { useTranslation } from 'react-i18next'; import { View } from 'react-native'; @@ -9,9 +9,15 @@ import { TabScreenScrollView } from '@/components/tab-screen'; import { ConfigureRow } from '@/components/ui/configure-row'; import { PreferenceRow } from '@/components/ui/preference-row'; import { Text } from '@/components/ui/text'; +import { VoiceTestField } from '@/components/voice-test-field'; import { useOrganization } from '@/lib/organization-context'; import { useGatewayTranscriptionModelSelection } from '@/lib/voice-input/gateway/gateway-transcription-model-selection'; import { useGatewayTranscriptionPreference } from '@/lib/voice-input/gateway/gateway-transcription-preference'; +import { voiceInputLanguageDisplayName } from '@/lib/voice-input/voice-input-language'; +import { + useVoiceInputLanguage, + useVoiceInputLanguageLoaded, +} from '@/lib/voice-input/voice-input-language-preference'; /** * Neutral row value while the catalogue is missing (failed or empty). The @@ -36,6 +42,8 @@ export function VoiceInputSettingsScreen() { const { status, model, isFetching, refetch } = useGatewayTranscriptionModelSelection( organizationId ?? undefined ); + const chosen = useVoiceInputLanguage(); + const languageLoaded = useVoiceInputLanguageLoaded(); const { t } = useTranslation(); // Only a live choice can open the picker; while off, loading, or without a @@ -58,13 +66,32 @@ export function VoiceInputSettingsScreen() { modelSubtitle = model.name; } + // The stored tag is a BCP-47 value the picker wrote, so naming it by its + // endonym reads like the picker rows. Until the SecureStore read resolves, + // the row shows the loading caption instead of claiming "Automatic" for a + // choice that may exist. + let languageSubtitle = t('voiceLanguage.automatic'); + if (!languageLoaded) { + languageSubtitle = t('common.loading'); + } else if (chosen) { + languageSubtitle = voiceInputLanguageDisplayName(chosen); + } + return ( { router.push('/(app)/transcription-model-picker' as Href); }} /> + { + router.push('/(app)/voice-language-picker' as Href); + }} + /> {status === 'unavailable' ? ( // A stored model the live catalogue dropped is kept (the engine still // has an id) but retry cannot restore it, so the enabled row above @@ -113,6 +150,7 @@ export function VoiceInputSettingsScreen() { isRetrying={isFetching} /> ) : null} + ); diff --git a/apps/mobile/src/components/voice-language-picker-sheet.mounted.test.tsx b/apps/mobile/src/components/voice-language-picker-sheet.mounted.test.tsx new file mode 100644 index 0000000000..fa5cc2f9e5 --- /dev/null +++ b/apps/mobile/src/components/voice-language-picker-sheet.mounted.test.tsx @@ -0,0 +1,248 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as transcription-model-picker-sheet.mounted.test.tsx) */ +import { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + deviceState, + findByType, + mountSheet, + preferenceState, + resetVoiceLanguagePickerMocks, + routerBack, + useVoiceRecognitionLanguagesMock, +} from '@/components/voice-language-picker-sheet.test-helpers'; + +describe('VoiceLanguagePickerSheet', () => { + beforeEach(resetVoiceLanguagePickerMocks); + + it('lists the app languages in gateway mode and writes the pressed tag', async () => { + preferenceState.gatewayEnabled = true; + const renderer = await mountSheet(); + + const rows = findByType(renderer.root, 'ChoiceRow'); + const german = rows.find(row => row.props.label === 'Deutsch'); + if (!german) { + throw new Error('Deutsch row not found'); + } + expect(german.props.description).toBe('German'); + // Gateway mode is static: it must not fetch the device's locale list. + expect(useVoiceRecognitionLanguagesMock).not.toHaveBeenCalled(); + + act(() => { + (german.props.onPress as () => void)(); + }); + expect(preferenceState.writeLanguage).toHaveBeenCalledWith('de'); + expect(routerBack).toHaveBeenCalledTimes(1); + + renderer.unmount(); + }); + + it('lists the device locales first with Automatic in device mode', async () => { + deviceState.current = { + languages: ['de-DE', 'nl-NL'], + isLoading: false, + isError: false, + refetch: vi.fn<() => void>(), + }; + const renderer = await mountSheet(); + + const rows = findByType(renderer.root, 'ChoiceRow'); + expect(rows[0]?.props.label).toBe('Automatic'); + expect(rows[1]?.props).toMatchObject({ label: 'Deutsch', description: 'de-DE' }); + expect(rows[2]?.props).toMatchObject({ label: 'Nederlands', description: 'nl-NL' }); + expect(useVoiceRecognitionLanguagesMock).toHaveBeenCalled(); + + renderer.unmount(); + }); + + it('marks the stored tag and writes it on press', async () => { + preferenceState.language = 'nl-NL'; + deviceState.current = { + languages: ['de-DE', 'nl-NL'], + isLoading: false, + isError: false, + refetch: vi.fn<() => void>(), + }; + const renderer = await mountSheet(); + + const rows = findByType(renderer.root, 'ChoiceRow'); + expect(rows[0]?.props.selected).toBe(false); + expect(rows[2]?.props.selected).toBe(true); + + const german = rows.find(row => row.props.label === 'Deutsch'); + if (!german) { + throw new Error('Deutsch row not found'); + } + act(() => { + (german.props.onPress as () => void)(); + }); + expect(preferenceState.writeLanguage).toHaveBeenCalledWith('de-DE'); + expect(routerBack).toHaveBeenCalledTimes(1); + + renderer.unmount(); + }); + + it('checks the device locale that shares the stored gateway language', async () => { + // A gateway choice (`zh-Hans`) is an app tag, but device mode offers OS + // locales; the same-language match must be the one checked. + preferenceState.language = 'zh-Hans'; + deviceState.current = { + languages: ['zh-CN', 'de-DE'], + isLoading: false, + isError: false, + refetch: vi.fn<() => void>(), + }; + const renderer = await mountSheet(); + + const rows = findByType(renderer.root, 'ChoiceRow'); + const selected = rows.filter(row => row.props.selected === true); + expect(selected).toHaveLength(1); + expect(selected[0]?.props).toMatchObject({ description: 'zh-CN' }); + + renderer.unmount(); + }); + + it('checks the app language that shares the stored device locale in gateway mode', async () => { + preferenceState.gatewayEnabled = true; + preferenceState.language = 'de-DE'; + const renderer = await mountSheet(); + + const rows = findByType(renderer.root, 'ChoiceRow'); + const selected = rows.filter(row => row.props.selected === true); + expect(selected).toHaveLength(1); + expect(selected[0]?.props).toMatchObject({ label: 'Deutsch', description: 'German' }); + + renderer.unmount(); + }); + + it('checks the app Chinese script for the Android Mandarin tag in gateway mode', async () => { + // p16: the device speech service stores the explicit choice as `cmn-Hans-CN` + // (ISO 639-3 Mandarin). The gateway list offers the app languages, so the + // picker must check the 简体中文 row — the same language the settings row + // names — instead of falling back to Automatic with nothing checked. + preferenceState.gatewayEnabled = true; + preferenceState.language = 'cmn-Hans-CN'; + const renderer = await mountSheet(); + + const rows = findByType(renderer.root, 'ChoiceRow'); + const selected = rows.filter(row => row.props.selected === true); + expect(selected).toHaveLength(1); + expect(selected[0]?.props).toMatchObject({ + label: '简体中文', + description: 'Chinese (Simplified)', + }); + + renderer.unmount(); + }); + + it('checks Automatic when the stored tag has no option in the active mode', async () => { + preferenceState.language = 'fil-PH'; + deviceState.current = { + languages: ['de-DE'], + isLoading: false, + isError: false, + refetch: vi.fn<() => void>(), + }; + const renderer = await mountSheet(); + + const rows = findByType(renderer.root, 'ChoiceRow'); + const selected = rows.filter(row => row.props.selected === true); + expect(selected).toHaveLength(1); + expect(selected[0]?.props.label).toBe('Automatic'); + + renderer.unmount(); + }); + + it('shows the retryable error state and retries through refetch', async () => { + const refetch = vi.fn<() => void>(); + deviceState.current = { + languages: [], + isLoading: false, + isError: true, + refetch, + }; + const renderer = await mountSheet(); + + const errorState = findByType(renderer.root, 'QueryError')[0]; + if (!errorState) { + throw new Error('QueryError not found'); + } + expect(errorState.props.title).toBe("Couldn't load the languages this device supports."); + expect(findByType(renderer.root, 'ChoiceRow')).toHaveLength(0); + + act(() => { + (errorState.props.onRetry as () => void)(); + }); + expect(refetch).toHaveBeenCalledTimes(1); + + renderer.unmount(); + }); + + it('shows the non-retryable empty state with no retry when the service reports zero locales', async () => { + deviceState.current = { + languages: [], + isLoading: false, + isError: false, + refetch: vi.fn<() => void>(), + }; + const renderer = await mountSheet(); + + const emptyState = findByType(renderer.root, 'EmptyState')[0]; + expect(emptyState?.props).toMatchObject({ + title: 'No supported languages', + description: "This device's speech recognition reports no supported languages.", + }); + expect(findByType(renderer.root, 'QueryError')).toHaveLength(0); + expect(findByType(renderer.root, 'FlatList')).toHaveLength(0); + + renderer.unmount(); + }); + + it('holds skeleton rows while the device fetch is loading', async () => { + deviceState.current = { + languages: [], + isLoading: true, + isError: false, + refetch: vi.fn<() => void>(), + }; + const renderer = await mountSheet(); + + expect(findByType(renderer.root, 'Skeleton')).toHaveLength(12); + const skeletonRows = findByType(renderer.root, 'View').filter( + node => + typeof node.props.className === 'string' && + node.props.className.includes('min-h-11') && + node.props.className.includes('py-3') + ); + expect(skeletonRows).toHaveLength(6); + expect(findByType(renderer.root, 'ChoiceRow')).toHaveLength(0); + + renderer.unmount(); + }); + + it('holds skeleton rows until the preference store settles', async () => { + preferenceState.languageLoaded = false; + const renderer = await mountSheet(); + + expect(findByType(renderer.root, 'Skeleton')).toHaveLength(12); + expect(findByType(renderer.root, 'ChoiceRow')).toHaveLength(0); + expect(useVoiceRecognitionLanguagesMock).not.toHaveBeenCalled(); + + renderer.unmount(); + }); + + it('keeps Automatic first in the row list', async () => { + deviceState.current = { + languages: ['en-US'], + isLoading: false, + isError: false, + refetch: vi.fn<() => void>(), + }; + const renderer = await mountSheet(); + + const rows = findByType(renderer.root, 'ChoiceRow'); + expect(rows[0]?.props).toMatchObject({ label: 'Automatic', description: 'Device language' }); + + renderer.unmount(); + }); +}); diff --git a/apps/mobile/src/components/voice-language-picker-sheet.search.mounted.test.tsx b/apps/mobile/src/components/voice-language-picker-sheet.search.mounted.test.tsx new file mode 100644 index 0000000000..c6b524b376 --- /dev/null +++ b/apps/mobile/src/components/voice-language-picker-sheet.search.mounted.test.tsx @@ -0,0 +1,81 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as transcription-model-picker-sheet.mounted.test.tsx) */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + deviceState, + mountSheet, + preferenceState, + resetVoiceLanguagePickerMocks, + rowLabels, + setQuery, +} from '@/components/voice-language-picker-sheet.test-helpers'; + +const DEVICE_LOCALES = ['de-DE', 'es-ES', 'nl-NL']; + +describe('VoiceLanguagePickerSheet search', () => { + beforeEach(resetVoiceLanguagePickerMocks); + + it('finds a device language by its English name', async () => { + deviceState.current = { + languages: DEVICE_LOCALES, + isLoading: false, + isError: false, + refetch: vi.fn<() => void>(), + }; + const renderer = await mountSheet(); + + setQuery(renderer, 'german'); + + expect(rowLabels(renderer)).toEqual(['Deutsch']); + + renderer.unmount(); + }); + + it('finds a device language from a diacritic-free query', async () => { + deviceState.current = { + languages: DEVICE_LOCALES, + isLoading: false, + isError: false, + refetch: vi.fn<() => void>(), + }; + const renderer = await mountSheet(); + + setQuery(renderer, 'espanol'); + + expect(rowLabels(renderer)).toEqual(['Español']); + + renderer.unmount(); + }); + + it('finds a gateway language from a diacritic-free query on its endonym', async () => { + preferenceState.gatewayEnabled = true; + const renderer = await mountSheet(); + + setQuery(renderer, 'turkce'); + + expect(rowLabels(renderer)).toContain('Türkçe'); + + renderer.unmount(); + }); + + it('finds a gateway language by its tag, like the canonical app language picker', async () => { + preferenceState.gatewayEnabled = true; + const renderer = await mountSheet(); + + setQuery(renderer, 'zh-Hant'); + + expect(rowLabels(renderer)).toEqual(['繁體中文']); + + renderer.unmount(); + }); + + it('collates the gateway language list by endonym', async () => { + preferenceState.gatewayEnabled = true; + const renderer = await mountSheet(); + + const labels = rowLabels(renderer).slice(1); + expect(labels).toEqual(labels.toSorted((a, b) => a.localeCompare(b))); + + renderer.unmount(); + }); +}); diff --git a/apps/mobile/src/components/voice-language-picker-sheet.test-helpers.tsx b/apps/mobile/src/components/voice-language-picker-sheet.test-helpers.tsx new file mode 100644 index 0000000000..e5eda534ee --- /dev/null +++ b/apps/mobile/src/components/voice-language-picker-sheet.test-helpers.tsx @@ -0,0 +1,157 @@ +/* eslint-disable typescript-eslint/no-deprecated, max-lines -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest; this one harness mocks every native module the sheet reaches. */ +import { createElement, Fragment, type ReactNode } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { vi } from 'vitest'; + +import '@/i18n'; +import { VoiceLanguagePickerSheet } from '@/components/voice-language-picker-sheet'; + +// ── Hoisted mocks ────────────────────────────────────────────────────────── + +const routerBack = vi.hoisted(() => vi.fn()); + +const preferenceState = vi.hoisted(() => ({ + gatewayEnabled: false, + gatewayLoaded: true, + language: null as string | null, + languageLoaded: true, + writeLanguage: vi.fn<(tag: string | null) => void>(), +})); + +const deviceState = vi.hoisted(() => ({ + current: { + languages: [] as string[], + isLoading: false, + isError: false, + refetch: vi.fn<() => void>(), + }, +})); + +const useVoiceRecognitionLanguagesMock = vi.hoisted(() => vi.fn()); + +export { deviceState, preferenceState, routerBack, useVoiceRecognitionLanguagesMock }; + +vi.mock('@/lib/voice-input/gateway/gateway-transcription-preference', () => ({ + useGatewayTranscriptionPreference: () => ({ + gatewayTranscriptionEnabled: preferenceState.gatewayEnabled, + hasLoaded: preferenceState.gatewayLoaded, + setGatewayTranscriptionEnabled: vi.fn<(value: boolean) => void>(), + }), +})); + +vi.mock('@/lib/voice-input/voice-input-language-preference', () => ({ + useVoiceInputLanguage: () => preferenceState.language, + useVoiceInputLanguageLoaded: () => preferenceState.languageLoaded, + writeVoiceInputLanguage: preferenceState.writeLanguage, +})); + +vi.mock('@/lib/voice-input/use-voice-recognition-languages', () => ({ + useVoiceRecognitionLanguages: useVoiceRecognitionLanguagesMock, +})); + +vi.mock('expo-router', () => ({ + useRouter: () => ({ back: routerBack, push: vi.fn() }), +})); +// The sheet reaches `voiceInputLanguageDisplayName`, which imports both native +// modules; mock them so the node environment never loads the native packages. +vi.mock('expo-localization', () => ({ + getLocales: () => [{ languageTag: 'en-US' }], +})); +vi.mock('expo-speech-recognition', () => ({ + ExpoSpeechRecognitionModule: { getSupportedLocales: vi.fn() }, +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#888888' }), +})); + +// FlatList renders through a callback, so a host-string mock would drop every +// row. This mock calls the render props so the row assertions still see rows. +const flatListMock = vi.hoisted( + () => + ({ + data, + renderItem, + keyExtractor, + ListFooterComponent, + }: { + data: readonly unknown[]; + renderItem: (info: { item: unknown; index: number }) => ReactNode; + keyExtractor: (item: unknown, index: number) => string; + ListFooterComponent?: ReactNode; + }) => { + const rows = data.map((item, index) => + createElement(Fragment, { key: keyExtractor(item, index) }, renderItem({ item, index })) + ); + return createElement('FlatList', null, ...rows, ListFooterComponent); + } +); +vi.mock('react-native', () => ({ + FlatList: flatListMock, + View: 'View', + TextInput: 'TextInput', + I18nManager: { isRTL: false }, +})); + +vi.mock('@/components/picker-sheet', () => ({ + PickerSheet: (props: { children?: ReactNode; headerContent?: ReactNode }) => + createElement('PickerSheet', props, props.headerContent, props.children), +})); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/ui/choice-row', () => ({ ChoiceRow: 'ChoiceRow' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/icons', () => ({ Mic: 'Mic', SearchX: 'SearchX' })); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +export function findByType(root: TestRenderer.ReactTestInstance, type: string) { + return root.findAll(node => typeof node.type === 'string' && node.type === type); +} + +export async function mountSheet(): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + ref.current = TestRenderer.create(createElement(VoiceLanguagePickerSheet)); + await Promise.resolve(); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +export function setQuery(renderer: TestRenderer.ReactTestRenderer, text: string): void { + const input = findByType(renderer.root, 'TextInput')[0]; + if (!input) { + throw new Error('search input not found'); + } + act(() => { + (input.props.onChangeText as (value: string) => void)(text); + }); +} + +export function rowLabels(renderer: TestRenderer.ReactTestRenderer): string[] { + return findByType(renderer.root, 'ChoiceRow').map(row => row.props.label as string); +} + +export function resetVoiceLanguagePickerMocks(): void { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + routerBack.mockClear(); + preferenceState.writeLanguage.mockClear(); + preferenceState.gatewayEnabled = false; + preferenceState.gatewayLoaded = true; + preferenceState.language = null; + preferenceState.languageLoaded = true; + deviceState.current = { + languages: [], + isLoading: false, + isError: false, + refetch: vi.fn<() => void>(), + }; + useVoiceRecognitionLanguagesMock.mockClear(); + useVoiceRecognitionLanguagesMock.mockImplementation(() => deviceState.current); +} diff --git a/apps/mobile/src/components/voice-language-picker-sheet.tsx b/apps/mobile/src/components/voice-language-picker-sheet.tsx new file mode 100644 index 0000000000..8cde74e2a1 --- /dev/null +++ b/apps/mobile/src/components/voice-language-picker-sheet.tsx @@ -0,0 +1,297 @@ +import { type TFunction } from 'i18next'; +import { type ReactNode, useCallback, useState } from 'react'; +import { useRouter } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { FlatList, I18nManager, TextInput, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { EmptyState } from '@/components/empty-state'; +import { PickerSheet } from '@/components/picker-sheet'; +import { QueryError } from '@/components/query-error'; +import { ChoiceRow } from '@/components/ui/choice-row'; +import { Mic, SearchX } from '@/components/ui/icons'; +import { Skeleton } from '@/components/ui/skeleton'; +import { foldForSearch } from '@/i18n/fold-for-search'; +import { languageRows } from '@/i18n/language-rows'; +import { SUPPORTED_LANGUAGES } from '@/i18n/languages'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { + reconcileVoiceInputLanguageTag, + voiceInputLanguageDisplayName, + voiceInputLanguageEnglishName, +} from '@/lib/voice-input/voice-input-language'; +import { + useVoiceInputLanguage, + useVoiceInputLanguageLoaded, + writeVoiceInputLanguage, +} from '@/lib/voice-input/voice-input-language-preference'; +import { useGatewayTranscriptionPreference } from '@/lib/voice-input/gateway/gateway-transcription-preference'; +import { useVoiceRecognitionLanguages } from '@/lib/voice-input/use-voice-recognition-languages'; + +const SEARCH_RTL = { textAlign: 'right' } as const; + +// Static skeleton rows: count and shape match the real ChoiceRow rows (name +// line + caption; the trailing check is transparent unless selected, so the +// skeleton carries no trailing control) so the swap never moves layout and +// never shows a shape the loaded row will not have. +const SKELETON_ROW_COUNT = 6; + +function SkeletonRows() { + return ( + + {Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => ( + // eslint-disable-next-line react/no-array-index-key -- static skeleton rows, no reordering + + + + + + + ))} + + ); +} + +type VoiceLanguageOption = { + /** `null` is the Automatic row: resolve the tag from the app and device. */ + tag: string | null; + label: string; + description: string; + /** Extra terms the search matches, e.g. the English name in device mode. */ + searchTerms: readonly string[]; +}; + +function automaticOption(t: TFunction): VoiceLanguageOption { + return { + tag: null, + label: t('voiceLanguage.automatic'), + // Automatic resolves from the active app language and the device's + // locales, so the device wording names what the row actually does. + description: t('language.deviceLanguage'), + searchTerms: [], + }; +} + +function matchesQuery(option: VoiceLanguageOption, query: string): boolean { + // Fold like the app language picker so "espanol" finds "Español". + const needle = foldForSearch(query.trim()); + if (needle.length === 0) { + return true; + } + return [option.label, option.description, ...option.searchTerms].some(value => + foldForSearch(value).includes(needle) + ); +} + +function VoiceLanguageList({ + options, + chosen, + query, + onSelect, +}: Readonly<{ + options: VoiceLanguageOption[]; + chosen: string | null; + query: string; + onSelect: (tag: string | null) => void; +}>) { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const filtered = options.filter(option => matchesQuery(option, query)); + + if (filtered.length === 0) { + return ( + + ); + } + + return ( + option.tag ?? 'automatic'} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + contentContainerClassName="px-4 pb-4" + ListFooterComponent={} + renderItem={({ item, index }) => ( + { + onSelect(item.tag); + }} + /> + )} + /> + ); +} + +/** + * Device-mode body. Split from the sheet so `useVoiceRecognitionLanguages` — + * and therefore the locale fetch — only runs while the gateway transcription + * switch is off. The gateway list is static and must not trigger a fetch. + */ +function DeviceVoiceLanguages({ + chosen, + query, + onSelect, +}: Readonly<{ + chosen: string | null; + query: string; + onSelect: (tag: string | null) => void; +}>) { + const { t } = useTranslation(); + const { languages, isLoading, isError, refetch } = useVoiceRecognitionLanguages(); + + if (isLoading) { + return ; + } + if (isError) { + // Retryable: the service call failed, so a retry can succeed. + return ; + } + if (languages.length === 0) { + // Non-retryable: the service answered with zero supported languages, and + // retrying cannot make it report languages it does not have. + return ( + + ); + } + + const options: VoiceLanguageOption[] = [ + automaticOption(t), + ...languages.map(tag => { + const englishName = voiceInputLanguageEnglishName(tag); + return { + tag, + label: voiceInputLanguageDisplayName(tag), + description: tag, + // The endonym and the tag are already searchable; the English name is + // what a user who does not read the native name will type. + searchTerms: englishName ? [englishName] : [], + }; + }), + ]; + // The stored tag may have been chosen in gateway mode (an app language), so + // map it onto the device's locales before checking a row: otherwise no row + // is checked while the settings row still names a language. + return ( + + ); +} + +/** + * Picks the voice-input language. In gateway mode the choices are the app's + * supported languages with no fetch; in device mode they are the recognition + * service's locales. Writes the SecureStore-backed store directly — no picker + * bridge — and dismisses on selection, mirroring the model picker's route + * shell. + */ +export function VoiceLanguagePickerSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const colors = useThemeColors(); + const { gatewayTranscriptionEnabled, hasLoaded: gatewayTranscriptionLoaded } = + useGatewayTranscriptionPreference(); + const chosen = useVoiceInputLanguage(); + const chosenLoaded = useVoiceInputLanguageLoaded(); + const [query, setQuery] = useState(''); + const isRtl = I18nManager.isRTL; + + const onSelect = useCallback( + (tag: string | null) => { + writeVoiceInputLanguage(tag); + router.back(); + }, + [router] + ); + + // The SecureStore reads resolve after mount; until both settle the mode and + // the current check are unknown, so hold the skeletons (same row height as + // ChoiceRow) and render the rows once, correctly checked. The device-mode + // body is only mounted when the gateway switch is off, so the locale fetch + // never runs in gateway mode. + let content: ReactNode = ; + if (gatewayTranscriptionLoaded && chosenLoaded) { + if (gatewayTranscriptionEnabled) { + // The canonical app language picker's list: every supported language + // collated by endonym, with the English name as the secondary line. + const options: VoiceLanguageOption[] = [ + automaticOption(t), + ...languageRows('').map(row => ({ + tag: row.tag, + label: row.endonym, + description: row.englishName, + // The row already shows the endonym and English name; the tag is + // searchable too, so "zh-Hant" finds a language whose endonym the + // user cannot type. + searchTerms: [row.tag], + })), + ]; + content = ( + + ); + } else { + content = ; + } + } + + return ( + { + router.back(); + }} + onCancel={() => { + router.back(); + }} + scrollable={false} + headerContent={ + + + + } + > + {content} + + ); +} diff --git a/apps/mobile/src/components/voice-test-field.mounted.test.tsx b/apps/mobile/src/components/voice-test-field.mounted.test.tsx new file mode 100644 index 0000000000..bc82e37d6d --- /dev/null +++ b/apps/mobile/src/components/voice-test-field.mounted.test.tsx @@ -0,0 +1,277 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as preferences-screen.mounted.test.tsx) */ +import { act, type ReactTestRenderer } from 'react-test-renderer'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import { VoiceTestField } from '@/components/voice-test-field'; +import { renderWithProviders } from '@/test/render-with-providers'; + +type VoiceInputOptions = { + disabled: boolean; + getDraft: () => string; + onDraftChange: (draft: string) => void; +}; + +const voice = vi.hoisted(() => ({ + options: undefined as VoiceInputOptions | undefined, + available: true, + feedback: null as { + action: 'none' | 'open-settings' | 'open-transcription-settings'; + availability: 'available' | 'unavailable'; + message: string; + retryable: boolean; + } | null, + isActive: false, + status: 'idle' as 'idle' | 'starting' | 'listening' | 'transcribing' | 'stopping', + abort: vi.fn<() => Promise>(), + toggle: vi.fn<() => Promise>(), +})); +const applyVoiceDraftToInput = vi.hoisted(() => vi.fn()); + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + TextInput: 'TextInput', + View: 'View', +})); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' })); +vi.mock('@/components/voice-input-control', () => ({ + VoiceInputButton: 'VoiceInputButton', + VoiceInputStatus: 'VoiceInputStatus', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ foreground: '#000000', mutedForeground: '#000000' }), +})); +vi.mock('@/lib/voice-input/use-voice-input', () => ({ + useVoiceInput: (options: VoiceInputOptions) => { + voice.options = options; + return { + abort: voice.abort, + available: voice.available, + feedback: voice.feedback, + isActive: voice.isActive, + settleBeforeSubmit: vi.fn<() => Promise>(), + status: voice.status, + toggle: voice.toggle, + }; + }, +})); +vi.mock('@/lib/voice-input/voice-input-draft', () => ({ + applyVoiceDraftToInput, +})); + +let view: Awaited> | undefined = undefined; +async function mountVoiceTestField(): Promise { + view = await renderWithProviders(); + await act(async () => { + await vi.dynamicImportSettled(); + }); + return view.renderer; +} + +function findByType(renderer: ReactTestRenderer, type: string) { + return renderer.root.findAll(node => typeof node.type === 'string' && node.type === type); +} + +function findByLabel(renderer: ReactTestRenderer, label: string) { + const node = findByType(renderer, 'Pressable').find( + item => item.props.accessibilityLabel === label + ); + if (!node) { + throw new Error(`Pressable ${label} not found`); + } + return node; +} + +function textValues(renderer: ReactTestRenderer): string[] { + return findByType(renderer, 'Text') + .map(node => node.props.children) + .filter((child): child is string => typeof child === 'string'); +} + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.resetAllMocks(); + voice.options = undefined; + voice.available = true; + voice.feedback = null; + voice.isActive = false; + voice.status = 'idle'; +}); +afterEach(() => { + view?.unmount(); + view = undefined; +}); + +describe('VoiceTestField', () => { + it('renders the title and the placeholder in a multiline input with no value', async () => { + const renderer = await mountVoiceTestField(); + + expect(textValues(renderer)).toContain('Test voice input'); + + const [input] = findByType(renderer, 'TextInput'); + if (!input) { + throw new Error('TextInput not found'); + } + expect(input.props).toMatchObject({ + defaultValue: '', + multiline: true, + placeholder: 'Tap the microphone and start speaking.', + textAlignVertical: 'top', + }); + // Uncontrolled on iOS: text flows through the native draft writes, never a + // controlled `value` prop. + expect(input.props.value).toBeUndefined(); + }); + + it('starts and stops the shared voice session from the mic button', async () => { + const renderer = await mountVoiceTestField(); + + const [button] = findByType(renderer, 'VoiceInputButton'); + if (!button) { + throw new Error('VoiceInputButton not found'); + } + expect(button.props).toMatchObject({ size: 'md', status: 'idle', disabled: false }); + + act(() => { + (button.props.onPress as () => void)(); + }); + expect(voice.toggle).toHaveBeenCalledTimes(1); + }); + + it('routes live transcripts through applyVoiceDraftToInput', async () => { + await mountVoiceTestField(); + + if (!voice.options) { + throw new Error('useVoiceInput options were not captured'); + } + expect(voice.options.disabled).toBe(false); + expect(voice.options.getDraft()).toBe(''); + + act(() => { + voice.options?.onDraftChange('hello'); + }); + expect(applyVoiceDraftToInput).toHaveBeenCalledWith( + expect.objectContaining({ draft: 'hello' }) + ); + }); + + it('aborts an active session and empties the field from Clear', async () => { + voice.isActive = true; + const renderer = await mountVoiceTestField(); + + const clear = findByLabel(renderer, 'Clear text'); + expect(clear.props.disabled).toBe(false); + + act(() => { + (clear.props.onPress as () => void)(); + }); + expect(voice.abort).toHaveBeenCalledTimes(1); + }); + + it('keeps Clear present but inert while there is nothing to clear', async () => { + const renderer = await mountVoiceTestField(); + + const clear = findByLabel(renderer, 'Clear text'); + expect(clear.props.disabled).toBe(true); + }); + + it('gives Clear a 44pt effective touch target distinct from its disabled state', async () => { + const renderer = await mountVoiceTestField(); + + const clear = findByLabel(renderer, 'Clear text'); + const className = clear.props.className as string; + // DESIGN.md: touch surfaces keep at least a 44px target. An arbitrary px + // value, not the rem-scaled min-h-11/min-w-11: NativeWind's rem is ~14px + // on device, so those render ~38.5pt tall (e14, 2026-09-12). + expect(className).toContain('min-h-[44px]'); + expect(className).toContain('min-w-[44px]'); + expect(className).toContain('items-center'); + expect(className).toContain('justify-center'); + expect(className).toContain('disabled:opacity-50'); + }); + + it('renders a transient failure inline, where the accessibility tree can see it', async () => { + voice.feedback = { + action: 'none', + availability: 'available', + message: "Couldn't reach the Kilo gateway. Check your connection and try again.", + retryable: true, + }; + const renderer = await mountVoiceTestField(); + + const [status] = findByType(renderer, 'AccessibleStatus'); + expect(status?.props).toMatchObject({ + message: "Couldn't reach the Kilo gateway. Check your connection and try again.", + tone: 'error', + }); + expect(textValues(renderer)).not.toContain('Listening...'); + }); + + it('leaves alert-backed feedback to its own surface', async () => { + voice.feedback = { + action: 'open-settings', + availability: 'available', + message: 'Microphone access is off.', + retryable: false, + }; + const renderer = await mountVoiceTestField(); + + expect(findByType(renderer, 'AccessibleStatus')).toHaveLength(0); + }); + + it.each(['', 'Keep these words'])( + 'keeps draft %j unchanged on no-speech and leaves the microphone available to retry', + async draft => { + const renderer = await mountVoiceTestField(); + const [input] = findByType(renderer, 'TextInput'); + if (!input) { + throw new Error('TextInput not found'); + } + act(() => { + (input.props.onChangeText as (text: string) => void)(draft); + }); + + voice.feedback = { + action: 'none', + availability: 'available', + message: 'No speech detected. Tap the microphone to try again.', + retryable: true, + }; + if (!view) { + throw new Error('VoiceTestField was not mounted'); + } + const { queryClient } = view; + act(() => { + renderer.update( + + + + ); + }); + + expect(voice.options?.getDraft()).toBe(draft); + expect(applyVoiceDraftToInput).not.toHaveBeenCalled(); + expect(findByType(renderer, 'AccessibleStatus')[0]?.props.message).toBe( + voice.feedback.message + ); + const [button] = findByType(renderer, 'VoiceInputButton'); + if (!button) { + throw new Error('VoiceInputButton not found'); + } + expect(button.props.disabled).toBe(false); + expect(findByLabel(renderer, 'Clear text').props.disabled).toBe(draft.length === 0); + act(() => { + (button.props.onPress as () => void)(); + }); + expect(voice.toggle).toHaveBeenCalledOnce(); + } + ); + + it('shows no failure line while the session is healthy', async () => { + const renderer = await mountVoiceTestField(); + + expect(findByType(renderer, 'AccessibleStatus')).toHaveLength(0); + }); +}); diff --git a/apps/mobile/src/components/voice-test-field.tsx b/apps/mobile/src/components/voice-test-field.tsx new file mode 100644 index 0000000000..82fd926ecb --- /dev/null +++ b/apps/mobile/src/components/voice-test-field.tsx @@ -0,0 +1,119 @@ +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, TextInput, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { AccessibleStatus } from '@/components/ui/accessible-status'; +import { VoiceInputButton, VoiceInputStatus } from '@/components/voice-input-control'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { applyVoiceDraftToInput } from '@/lib/voice-input/voice-input-draft'; +import { resolveVoiceInputFeedbackPresentation } from '@/lib/voice-input/voice-input-feedback'; +import { useVoiceInput } from '@/lib/voice-input/use-voice-input'; + +/** + * Requirement 3 surface: a free-text area in voice settings that shows the + * live effect of the current configuration. Interim and final transcripts — + * gateway streaming segments and OS interim results alike — arrive through + * `onDraftChange`, so the user can confirm the chosen language and model + * without leaving the screen. + * + * The input stays uncontrolled on iOS (AGENTS.md): text lives in `textRef`, + * `hasText` only drives the Clear affordance, and every speech write goes + * through `applyVoiceDraftToInput`. Feedback for a failed or refused start + * stays in the shared voice stack: transient failures are mirrored inline + * below the field (sonner-native toasts sit outside the accessibility tree), + * while alert-backed failures render on their own alert surface. + */ +export function VoiceTestField(): React.ReactElement { + const { t } = useTranslation(); + const colors = useThemeColors(); + const inputRef = useRef(null); + const textRef = useRef(''); + const [hasText, setHasText] = useState(false); + + const handleTextChange = (text: string) => { + textRef.current = text; + setHasText(text.length > 0); + }; + + const voice = useVoiceInput({ + disabled: false, + getDraft: () => textRef.current, + onDraftChange: draft => { + applyVoiceDraftToInput({ draft, input: inputRef.current, onChangeText: handleTextChange }); + }, + }); + + const handleClear = () => { + if (voice.isActive) { + void voice.abort(); + } + // `clear()` drives the native `setTextAndSelection` command with the + // most-recent event count. `setNativeProps({ text: '' })` leaves the + // native text in place on Android/Fabric, so the field kept its text + // after the tap (e14, 2026-09-12). + inputRef.current?.clear(); + handleTextChange(''); + }; + + // Rendered disabled rather than hidden: the control row keeps its height + // while there is nothing to clear, so starting a session never shifts it. + const clearDisabled = !hasText && !voice.isActive; + + // Transient failures (a dropped gateway, no speech) keep the message next to + // the microphone as well as in the toast. sonner-native draws the toast + // outside the accessibility hierarchy, so this inline `AccessibleStatus` is + // the only copy a screen reader — or the on-device hierarchy digest the e2 + // scenario reads — can see. Alert-backed feedback already has its own + // surface, so it is not mirrored here. + const inlineFailure = + voice.feedback && resolveVoiceInputFeedbackPresentation(voice.feedback).kind === 'toast' + ? voice.feedback + : null; + + return ( + + {t('voiceInput.testTitle')} + + + void voice.toggle()} + size="md" + status={voice.status} + /> + + {inlineFailure ? ( + + ) : null} + + {t('voiceInput.testClear')} + + + + ); +} diff --git a/apps/mobile/src/i18n/fold-for-search.test.ts b/apps/mobile/src/i18n/fold-for-search.test.ts new file mode 100644 index 0000000000..84b373260e --- /dev/null +++ b/apps/mobile/src/i18n/fold-for-search.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { foldForSearch } from './fold-for-search'; + +describe('foldForSearch', () => { + it('strips diacritics and folds case', () => { + expect(foldForSearch('Türkçe')).toBe('turkce'); + expect(foldForSearch('Español')).toBe('espanol'); + }); + + it('folds case without consulting the device locale', () => { + // A tr/az device lowers 'I' to 'ı', so a locale-sensitive fold would make + // a search for "indonesian" miss "Indonesian". The fold must not use the + // device locale. + const toLocaleLowerCase = vi + .spyOn(String.prototype, 'toLocaleLowerCase') + .mockImplementation(() => 'ındonesıan'); + try { + expect(foldForSearch('Indonesian')).toBe('indonesian'); + } finally { + toLocaleLowerCase.mockRestore(); + } + }); +}); diff --git a/apps/mobile/src/i18n/fold-for-search.ts b/apps/mobile/src/i18n/fold-for-search.ts new file mode 100644 index 0000000000..400e5fcfab --- /dev/null +++ b/apps/mobile/src/i18n/fold-for-search.ts @@ -0,0 +1,11 @@ +/** + * Fold case and strip diacritics so a search for "espanol" finds "Español" + * and "turkce" finds "Türkçe". Shared by the app language picker and the + * voice language picker so both search language names the same way. + */ +export function foldForSearch(value: string): string { + return value + .normalize('NFD') + .replaceAll(/\p{Diacritic}/gu, '') + .toLowerCase(); +} diff --git a/apps/mobile/src/i18n/language-rows.ts b/apps/mobile/src/i18n/language-rows.ts index 445602749a..481ae25797 100644 --- a/apps/mobile/src/i18n/language-rows.ts +++ b/apps/mobile/src/i18n/language-rows.ts @@ -1,3 +1,4 @@ +import { foldForSearch } from './fold-for-search'; import { LANGUAGE_ENDONYMS, LANGUAGE_ENGLISH_NAMES, @@ -24,13 +25,6 @@ export type LanguagePickerItem = * Fold case and strip diacritics so a search for "espanol" finds "Español" * and "turkce" finds "Türkçe". */ -function foldForSearch(value: string): string { - return value - .normalize('NFD') - .replaceAll(/\p{Diacritic}/gu, '') - .toLocaleLowerCase(); -} - const ALL_ROWS: readonly LanguageRow[] = SUPPORTED_LANGUAGES.map(tag => ({ tag, endonym: LANGUAGE_ENDONYMS[tag], diff --git a/apps/mobile/src/i18n/locales/af.json b/apps/mobile/src/i18n/locales/af.json index d3e6ba9dee..d49831f11a 100644 --- a/apps/mobile/src/i18n/locales/af.json +++ b/apps/mobile/src/i18n/locales/af.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Hierdie transkripsiemodel is nie beskikbaar nie. Kies 'n ander een in Voorkeure.", "gatewayModelUnavailableNotice": "Hierdie model word nie meer aangebied nie. Tik op $t(transcriptionModel.title) om 'n ander een te kies.", "gatewaySignInRequired": "Teken in om gateway-transkripsie te gebruik.", - "gatewayNoModel": "Kies eers 'n transkripsiemodel in Voorkeure." + "gatewayNoModel": "Kies eers 'n transkripsiemodel in Voorkeure.", + "testTitle": "Toets steminvoer", + "testPlaceholder": "Tik op die mikrofoon en begin praat.", + "testClear": "Vee teks uit" }, "share": { "title": "Deel met Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Geen transkripsiemodelle nie", "emptyDescription": "Die Kilo Gateway bied tans geen transkripsiemodelle nie.", "loadFailed": "Die transkripsiemodelle kon nie gelaai word nie." + }, + "voiceLanguage": { + "title": "Stemtaal", + "automatic": "Outomaties", + "loadFailed": "Kon nie die tale laai wat hierdie toestel ondersteun nie.", + "emptyTitle": "Geen ondersteunde tale", + "emptyDescription": "Hierdie toestel se spraakherkenning rapporteer geen ondersteunde tale nie." } } diff --git a/apps/mobile/src/i18n/locales/am.json b/apps/mobile/src/i18n/locales/am.json index d91dfc0bc8..0ff3bbf8a5 100644 --- a/apps/mobile/src/i18n/locales/am.json +++ b/apps/mobile/src/i18n/locales/am.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "ይህ የድምጽ ወደ ጽሑፍ ሞዴል አይገኝም። በምርጫዎች ውስጥ ሌላ ይምረጡ።", "gatewayModelUnavailableNotice": "ይህ ሞዴል ከእንግዲህ አይቀርብም። ሌላ ለመምረጥ $t(transcriptionModel.title) ይንኩ።", "gatewaySignInRequired": "በKilo Gateway ለመቀየር ይግቡ።", - "gatewayNoModel": "በምርጫዎች ውስጥ መጀመሪያ የድምጽ ወደ ጽሑፍ ሞዴል ይምረጡ።" + "gatewayNoModel": "በምርጫዎች ውስጥ መጀመሪያ የድምጽ ወደ ጽሑፍ ሞዴል ይምረጡ።", + "testTitle": "የድምፅ ግብዓትን ይፈትሹ", + "testPlaceholder": "ማይክሮፎኑን ነክተው መናገር ይጀምሩ።", + "testClear": "ጽሑፉን አጽዳ" }, "share": { "title": "ወደ Kilo ማጋራት", @@ -2970,5 +2973,12 @@ "emptyTitle": "ምንም የድምጽ ወደ ጽሑፍ ሞዴሎች የሉም", "emptyDescription": "Kilo Gateway በአሁኑ ሰዓት ምንም የድምጽ ወደ ጽሑፍ ሞዴሎችን አያቀርብም።", "loadFailed": "የድምጽ ወደ ጽሑፍ ሞዴሎችን መጫን አልተቻለም።" + }, + "voiceLanguage": { + "title": "የድምፅ ግብዓት ቋንቋ", + "automatic": "ራስ-ሰር", + "loadFailed": "ይህ መሣሪያ የሚደግፋቸውን ቋንቋዎች መጫን አልተቻለም።", + "emptyTitle": "የሚደገፉ ቋንቋዎች የሉም", + "emptyDescription": "የዚህ መሣሪያ የድምፅ መለያ የሚደገፍ ቋንቋ አልዘረዘረም።" } } diff --git a/apps/mobile/src/i18n/locales/ar.json b/apps/mobile/src/i18n/locales/ar.json index eeca6cc4bd..cdef72a19e 100644 --- a/apps/mobile/src/i18n/locales/ar.json +++ b/apps/mobile/src/i18n/locales/ar.json @@ -2203,7 +2203,10 @@ "gatewayModelUnavailable": "نموذج التفريغ الصوتي هذا غير متاح. اختر نموذجًا آخر من التفضيلات.", "gatewayModelUnavailableNotice": "لم يعد هذا النموذج معروضًا. اضغط على $t(transcriptionModel.title) لاختيار نموذج آخر.", "gatewaySignInRequired": "سجّل الدخول لاستخدام التفريغ الصوتي عبر Kilo Gateway.", - "gatewayNoModel": "اختر نموذج التفريغ الصوتي في التفضيلات أولًا." + "gatewayNoModel": "اختر نموذج التفريغ الصوتي في التفضيلات أولًا.", + "testTitle": "اختبار الإدخال الصوتي", + "testPlaceholder": "اضغط على الميكروفون وابدأ التحدث.", + "testClear": "مسح النص" }, "share": { "title": "المشاركة مع Kilo", @@ -3058,5 +3061,12 @@ "emptyTitle": "لا توجد نماذج تفريغ صوتي", "emptyDescription": "لا يقدّم Kilo Gateway أي نماذج تفريغ صوتي في الوقت الحالي.", "loadFailed": "تعذّر تحميل نماذج التفريغ الصوتي." + }, + "voiceLanguage": { + "title": "لغة الإدخال الصوتي", + "automatic": "تلقائي", + "loadFailed": "تعذّر تحميل اللغات التي يدعمها هذا الجهاز.", + "emptyTitle": "لا توجد لغات مدعومة", + "emptyDescription": "لا يفيد التعرّف على الكلام في هذا الجهاز بأي لغة مدعومة." } } diff --git a/apps/mobile/src/i18n/locales/az.json b/apps/mobile/src/i18n/locales/az.json index 0284c8ab60..611d333c5d 100644 --- a/apps/mobile/src/i18n/locales/az.json +++ b/apps/mobile/src/i18n/locales/az.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Bu səsin mətnə çevrilməsi modeli əlçatan deyil. Seçimlərdə başqasını seçin.", "gatewayModelUnavailableNotice": "Bu model artıq təklif edilmir. Başqasını seçmək üçün $t(transcriptionModel.title) üzərinə toxunun.", "gatewaySignInRequired": "Kilo Gateway ilə mətnə çevirməkdən istifadə etmək üçün hesabınıza daxil olun.", - "gatewayNoModel": "Əvvəlcə Seçimlərdə bir səsin mətnə çevrilməsi modeli seçin." + "gatewayNoModel": "Əvvəlcə Seçimlərdə bir səsin mətnə çevrilməsi modeli seçin.", + "testTitle": "Səslə daxiletməni yoxla", + "testPlaceholder": "Mikrofona toxunun və danışmağa başlayın.", + "testClear": "Mətni təmizlə" }, "share": { "title": "Kilo-ya göndər", @@ -2970,5 +2973,12 @@ "emptyTitle": "Səsin mətnə çevrilməsi modeli yoxdur", "emptyDescription": "Kilo Gateway hazırda səsin mətnə çevrilməsi modeli təklif etmir.", "loadFailed": "Səsin mətnə çevrilməsi modellərini yükləmək mümkün olmadı." + }, + "voiceLanguage": { + "title": "Səslə daxiletmə dili", + "automatic": "Avtomatik", + "loadFailed": "Bu cihazın dəstəklədiyi dillər yüklənə bilmədi.", + "emptyTitle": "Dəstəklənən dil yoxdur", + "emptyDescription": "Bu cihazın nitq tanıma funksiyası heç bir dəstəklənən dil bildirmir." } } diff --git a/apps/mobile/src/i18n/locales/be.json b/apps/mobile/src/i18n/locales/be.json index 01b8346a2d..3dc6aee061 100644 --- a/apps/mobile/src/i18n/locales/be.json +++ b/apps/mobile/src/i18n/locales/be.json @@ -2623,7 +2623,10 @@ "gatewayModelUnavailable": "Гэта мадэль распазнавання маўлення недаступная. Абярыце іншую ў наладах.", "gatewayModelUnavailableNotice": "Гэта мадэль больш не прапануецца. Націсніце $t(transcriptionModel.title), каб выбраць іншую.", "gatewaySignInRequired": "Увайдзіце, каб карыстацца распазнаваннем маўлення праз Kilo Gateway.", - "gatewayNoModel": "Спачатку абярыце мадэль распазнавання маўлення ў наладах." + "gatewayNoModel": "Спачатку абярыце мадэль распазнавання маўлення ў наладах.", + "testTitle": "Праверыць галасавы ўвод", + "testPlaceholder": "Дакраніцеся да мікрафона і пачніце гаварыць.", + "testClear": "Ачысціць тэкст" }, "share": { "title": "Адпраўка ў Kilo", @@ -3014,5 +3017,12 @@ "emptyTitle": "Няма мадэляў распазнавання маўлення", "emptyDescription": "Зараз Kilo Gateway не прапануе мадэляў распазнавання маўлення.", "loadFailed": "Не атрымалася загрузіць мадэлі распазнавання маўлення." + }, + "voiceLanguage": { + "title": "Мова галасавога ўводу", + "automatic": "Аўтаматычна", + "loadFailed": "Не ўдалося загрузіць мовы, якія падтрымлівае гэта прылада.", + "emptyTitle": "Няма падтрымліваемых моў", + "emptyDescription": "Распазнаванне маўлення гэтай прылады не паведамляе ніводнай падтрымліваемай мовы." } } diff --git a/apps/mobile/src/i18n/locales/bg.json b/apps/mobile/src/i18n/locales/bg.json index 1bcde3736e..e4440d9a8e 100644 --- a/apps/mobile/src/i18n/locales/bg.json +++ b/apps/mobile/src/i18n/locales/bg.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Този модел за транскрипция не е наличен. Избери друг в Предпочитания.", "gatewayModelUnavailableNotice": "Този модел вече не се предлага. Докосни $t(transcriptionModel.title), за да избереш друг.", "gatewaySignInRequired": "Влез, за да използваш транскрипция през Kilo Gateway.", - "gatewayNoModel": "Първо избери модел за транскрипция в Предпочитания." + "gatewayNoModel": "Първо избери модел за транскрипция в Предпочитания.", + "testTitle": "Тестване на гласовото въвеждане", + "testPlaceholder": "Докоснете микрофона и започнете да говорите.", + "testClear": "Изчистване на текста" }, "share": { "title": "Споделяне в Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Няма модели за транскрипция", "emptyDescription": "Kilo Gateway в момента не предлага модели за транскрипция.", "loadFailed": "Моделите за транскрипция не се заредиха." + }, + "voiceLanguage": { + "title": "Език на гласовото въвеждане", + "automatic": "Автоматично", + "loadFailed": "Езиците, поддържани от това устройство, не можаха да се заредят.", + "emptyTitle": "Няма поддържани езици", + "emptyDescription": "Разпознаването на реч на това устройство не отчита поддържани езици." } } diff --git a/apps/mobile/src/i18n/locales/bn.json b/apps/mobile/src/i18n/locales/bn.json index 5679c4ca18..2d928327d3 100644 --- a/apps/mobile/src/i18n/locales/bn.json +++ b/apps/mobile/src/i18n/locales/bn.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "কথা থেকে লেখার এই মডেলটি পাওয়া যাচ্ছে না। পছন্দের সেটিংস থেকে আরেকটি বেছে নিন।", "gatewayModelUnavailableNotice": "এই মডেলটি আর দেওয়া হয় না। অন্য একটি বেছে নিতে $t(transcriptionModel.title) স্পর্শ করুন।", "gatewaySignInRequired": "Kilo Gateway দিয়ে কথা থেকে লেখা ব্যবহার করতে সাইন ইন করুন।", - "gatewayNoModel": "প্রথমে পছন্দের সেটিংস থেকে কথা থেকে লেখার একটি মডেল বেছে নিন।" + "gatewayNoModel": "প্রথমে পছন্দের সেটিংস থেকে কথা থেকে লেখার একটি মডেল বেছে নিন।", + "testTitle": "ভয়েস ইনপুট পরীক্ষা করুন", + "testPlaceholder": "মাইক্রোফোনে ট্যাপ করুন এবং কথা বলা শুরু করুন।", + "testClear": "লেখা মুছুন" }, "share": { "title": "Kilo-তে শেয়ার", @@ -2970,5 +2973,12 @@ "emptyTitle": "কথা থেকে লেখার কোনো মডেল নেই", "emptyDescription": "Kilo Gateway এই মুহূর্তে কথা থেকে লেখার কোনো মডেল দিচ্ছে না।", "loadFailed": "কথা থেকে লেখার মডেল লোড করা যায়নি।" + }, + "voiceLanguage": { + "title": "ভয়েস ইনপুটের ভাষা", + "automatic": "স্বয়ংক্রিয়", + "loadFailed": "এই ডিভাইস সমর্থন করে এমন ভাষাগুলি লোড করা যায়নি।", + "emptyTitle": "কোনও সমর্থিত ভাষা নেই", + "emptyDescription": "এই ডিভাইসের বাক্‌ শনাক্তকরণ কোনও সমর্থিত ভাষা জানায় না।" } } diff --git a/apps/mobile/src/i18n/locales/bs.json b/apps/mobile/src/i18n/locales/bs.json index 7ff8a51470..dad8ddce9e 100644 --- a/apps/mobile/src/i18n/locales/bs.json +++ b/apps/mobile/src/i18n/locales/bs.json @@ -2602,7 +2602,10 @@ "gatewayModelUnavailable": "Ovaj model za transkripciju nije dostupan. Odaberi drugi u Postavkama.", "gatewayModelUnavailableNotice": "Ovaj model se više ne nudi. Dodirni $t(transcriptionModel.title) da odabereš drugi.", "gatewaySignInRequired": "Prijavi se da koristiš transkripciju putem Kilo Gatewaya.", - "gatewayNoModel": "Prvo odaberi model za transkripciju u Postavkama." + "gatewayNoModel": "Prvo odaberi model za transkripciju u Postavkama.", + "testTitle": "Testiraj glasovni unos", + "testPlaceholder": "Dodirnite mikrofon i počnite govoriti.", + "testClear": "Očisti tekst" }, "share": { "title": "Podijeli s aplikacijom Kilo", @@ -2992,5 +2995,12 @@ "emptyTitle": "Nema modela za transkripciju", "emptyDescription": "Kilo Gateway trenutno ne nudi modele za transkripciju.", "loadFailed": "Nije moguće učitati modele za transkripciju." + }, + "voiceLanguage": { + "title": "Jezik glasovnog unosa", + "automatic": "Automatski", + "loadFailed": "Nije bilo moguće učitati jezike koje ovaj uređaj podržava.", + "emptyTitle": "Nema podržanih jezika", + "emptyDescription": "Prepoznavanje govora na ovom uređaju ne prijavljuje nijedan podržani jezik." } } diff --git a/apps/mobile/src/i18n/locales/ca.json b/apps/mobile/src/i18n/locales/ca.json index b06d675ba5..4314f22236 100644 --- a/apps/mobile/src/i18n/locales/ca.json +++ b/apps/mobile/src/i18n/locales/ca.json @@ -2602,7 +2602,10 @@ "gatewayModelUnavailable": "Aquest model de transcripció no està disponible. Tria'n un altre a les Preferències.", "gatewayModelUnavailableNotice": "Aquest model ja no s'ofereix. Toca $t(transcriptionModel.title) per triar-ne un altre.", "gatewaySignInRequired": "Inicia la sessió per utilitzar la transcripció amb Kilo Gateway.", - "gatewayNoModel": "Primer tria un model de transcripció a les Preferències." + "gatewayNoModel": "Primer tria un model de transcripció a les Preferències.", + "testTitle": "Prova el dictat", + "testPlaceholder": "Toca el micròfon i comença a parlar.", + "testClear": "Esborra el text" }, "share": { "title": "Compartir amb Kilo", @@ -2992,5 +2995,12 @@ "emptyTitle": "Cap model de transcripció", "emptyDescription": "Kilo Gateway no ofereix cap model de transcripció ara mateix.", "loadFailed": "No s'han pogut carregar els models de transcripció." + }, + "voiceLanguage": { + "title": "Idioma del dictat", + "automatic": "Automàtic", + "loadFailed": "No s'han pogut carregar els idiomes que admet aquest dispositiu.", + "emptyTitle": "Cap idioma admès", + "emptyDescription": "El reconeixement de veu d'aquest dispositiu no indica cap idioma admès." } } diff --git a/apps/mobile/src/i18n/locales/ckb.json b/apps/mobile/src/i18n/locales/ckb.json index 6cf8060853..3cb64f19a2 100644 --- a/apps/mobile/src/i18n/locales/ckb.json +++ b/apps/mobile/src/i18n/locales/ckb.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "ئەم مۆدێلەی گۆڕینی دەنگ بۆ دەق بەردەست نییە. لە ڕێکخستنە کەسییەکان یەکی تر هەڵبژێرە.", "gatewayModelUnavailableNotice": "ئەم مۆدێلە چیتر پێشکەش ناکرێت. دەست بدە بە $t(transcriptionModel.title) بۆ هەڵبژاردنی یەکی تر.", "gatewaySignInRequired": "بچۆ ژوورەوە بۆ بەکارهێنانی گۆڕینی دەنگ بە Kilo Gateway.", - "gatewayNoModel": "سەرەتا لە ڕێکخستنە کەسییەکان مۆدێلێکی گۆڕینی دەنگ بۆ دەق هەڵبژێرە." + "gatewayNoModel": "سەرەتا لە ڕێکخستنە کەسییەکان مۆدێلێکی گۆڕینی دەنگ بۆ دەق هەڵبژێرە.", + "testTitle": "تاقیکردنەوەی نووسین بە دەنگ", + "testPlaceholder": "مایکرۆفۆن دابگرە و دەست بکە بە قسەکردن.", + "testClear": "دەق پاک بکەرەوە" }, "share": { "title": "هاوبەشکردن بۆ Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "هیچ مۆدێلێکی گۆڕینی دەنگ بۆ دەق نییە", "emptyDescription": "Kilo Gateway ئێستا هیچ مۆدێلێکی گۆڕینی دەنگ بۆ دەقی نییە.", "loadFailed": "نەتوانرا مۆدێلەکانی گۆڕینی دەنگ بۆ دەق بار بکرێن." + }, + "voiceLanguage": { + "title": "زمانی نووسین بە دەنگ", + "automatic": "خۆکار", + "loadFailed": "زمانەکانی پشتگیریکراو لەلایەن ئەم ئامێرە بار نەکران.", + "emptyTitle": "هیچ زمانی پشتگیریکراو نییە", + "emptyDescription": "ناسینەوەی دەنگ لەم ئامێرە هیچ زمانی پشتگیریکراو ڕانەگەیەنێت." } } diff --git a/apps/mobile/src/i18n/locales/cs.json b/apps/mobile/src/i18n/locales/cs.json index 81f2cbdc1f..c403eee1df 100644 --- a/apps/mobile/src/i18n/locales/cs.json +++ b/apps/mobile/src/i18n/locales/cs.json @@ -2623,7 +2623,10 @@ "gatewayModelUnavailable": "Tento model přepisu není dostupný. Vyberte jiný v Předvolbách.", "gatewayModelUnavailableNotice": "Tento model se již nenabízí. Klepnutím na $t(transcriptionModel.title) vyberte jiný.", "gatewaySignInRequired": "Pro použití přepisu přes Kilo Gateway se přihlaste.", - "gatewayNoModel": "Nejprve vyberte model přepisu v Předvolbách." + "gatewayNoModel": "Nejprve vyberte model přepisu v Předvolbách.", + "testTitle": "Otestovat diktování", + "testPlaceholder": "Klepněte na mikrofon a začněte mluvit.", + "testClear": "Vymazat text" }, "share": { "title": "Sdílení do Kilo", @@ -3014,5 +3017,12 @@ "emptyTitle": "Žádné modely přepisu", "emptyDescription": "Kilo Gateway nyní nenabízí žádné modely přepisu.", "loadFailed": "Modely přepisu se nepodařilo načíst." + }, + "voiceLanguage": { + "title": "Jazyk diktování", + "automatic": "Automaticky", + "loadFailed": "Nepodařilo se načíst jazyky, které toto zařízení podporuje.", + "emptyTitle": "Žádné podporované jazyky", + "emptyDescription": "Rozpoznávání řeči tohoto zařízení nehlásí žádné podporované jazyky." } } diff --git a/apps/mobile/src/i18n/locales/cy.json b/apps/mobile/src/i18n/locales/cy.json index ce90952027..c13fa468b3 100644 --- a/apps/mobile/src/i18n/locales/cy.json +++ b/apps/mobile/src/i18n/locales/cy.json @@ -2665,7 +2665,10 @@ "gatewayModelUnavailable": "Nid yw'r model trawsgrifio hwn ar gael. Dewiswch un arall yn y Dewisiadau.", "gatewayModelUnavailableNotice": "Nid yw'r model hwn yn cael ei gynnig mwyach. Tapiwch $t(transcriptionModel.title) i ddewis un arall.", "gatewaySignInRequired": "Mewngofnodwch i ddefnyddio trawsgrifio drwy Kilo Gateway.", - "gatewayNoModel": "Dewiswch fodel trawsgrifio yn y Dewisiadau yn gyntaf." + "gatewayNoModel": "Dewiswch fodel trawsgrifio yn y Dewisiadau yn gyntaf.", + "testTitle": "Profwch fewnbwn llais", + "testPlaceholder": "Tapiwch y meicroffon a dechreuwch siarad.", + "testClear": "Clirio'r testun" }, "share": { "title": "Rhannu â Kilo", @@ -3058,5 +3061,12 @@ "emptyTitle": "Dim modelau trawsgrifio", "emptyDescription": "Does dim modelau trawsgrifio ar gael gan Kilo Gateway ar hyn o bryd.", "loadFailed": "Doedd dim modd llwytho'r modelau trawsgrifio." + }, + "voiceLanguage": { + "title": "Iaith mewnbwn llais", + "automatic": "Awtomatig", + "loadFailed": "Methwyd llwytho'r ieithoedd y mae'r ddyfais hon yn eu cefnogi.", + "emptyTitle": "Dim ieithoedd wedi'u cefnogi", + "emptyDescription": "Nid yw adnabyddiaeth lafar y ddyfais hon yn nodi unrhyw iaith a gefnogir." } } diff --git a/apps/mobile/src/i18n/locales/da.json b/apps/mobile/src/i18n/locales/da.json index 67bd86fa4b..1fe75c18e9 100644 --- a/apps/mobile/src/i18n/locales/da.json +++ b/apps/mobile/src/i18n/locales/da.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Denne transskriptionsmodel er ikke tilgængelig. Vælg en anden under Indstillinger.", "gatewayModelUnavailableNotice": "Denne model tilbydes ikke længere. Tryk på $t(transcriptionModel.title) for at vælge en anden.", "gatewaySignInRequired": "Log ind for at bruge transskription via Kilo Gateway.", - "gatewayNoModel": "Vælg først en transskriptionsmodel under Indstillinger." + "gatewayNoModel": "Vælg først en transskriptionsmodel under Indstillinger.", + "testTitle": "Test diktering", + "testPlaceholder": "Tryk på mikrofonen, og begynd at tale.", + "testClear": "Ryd tekst" }, "share": { "title": "Del med Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Ingen transskriptionsmodeller", "emptyDescription": "Kilo Gateway tilbyder ikke nogen transskriptionsmodeller lige nu.", "loadFailed": "Transskriptionsmodellerne kunne ikke indlæses." + }, + "voiceLanguage": { + "title": "Dikteringssprog", + "automatic": "Automatisk", + "loadFailed": "Kunne ikke indlæse de sprog, denne enhed understøtter.", + "emptyTitle": "Ingen understøttede sprog", + "emptyDescription": "Denne enheds talegenkendelse rapporterer ingen understøttede sprog." } } diff --git a/apps/mobile/src/i18n/locales/de.json b/apps/mobile/src/i18n/locales/de.json index 41e197c559..b9a11645a4 100644 --- a/apps/mobile/src/i18n/locales/de.json +++ b/apps/mobile/src/i18n/locales/de.json @@ -2147,7 +2147,10 @@ "gatewayModelUnavailable": "Dieses Transkriptionsmodell ist nicht verfügbar. Wähle ein anderes in den Einstellungen.", "gatewayModelUnavailableNotice": "Dieses Modell wird nicht mehr angeboten. Tippe auf $t(transcriptionModel.title), um ein anderes zu wählen.", "gatewaySignInRequired": "Melde dich an, um die Gateway-Transkription zu nutzen.", - "gatewayNoModel": "Wähle zuerst in den Einstellungen ein Transkriptionsmodell." + "gatewayNoModel": "Wähle zuerst in den Einstellungen ein Transkriptionsmodell.", + "testTitle": "Spracheingabe testen", + "testPlaceholder": "Tippe auf das Mikrofon und sprich.", + "testClear": "Text löschen" }, "share": { "title": "Mit Kilo teilen", @@ -2970,5 +2973,12 @@ "emptyTitle": "Keine Transkriptionsmodelle", "emptyDescription": "Kilo Gateway bietet derzeit keine Transkriptionsmodelle.", "loadFailed": "Die Transkriptionsmodelle konnten nicht geladen werden." + }, + "voiceLanguage": { + "title": "Sprache der Spracheingabe", + "automatic": "Automatisch", + "loadFailed": "Die von diesem Gerät unterstützten Sprachen konnten nicht geladen werden.", + "emptyTitle": "Keine unterstützten Sprachen", + "emptyDescription": "Die Spracherkennung dieses Geräts meldet keine unterstützten Sprachen." } } diff --git a/apps/mobile/src/i18n/locales/el.json b/apps/mobile/src/i18n/locales/el.json index b000606229..6bdaacaa64 100644 --- a/apps/mobile/src/i18n/locales/el.json +++ b/apps/mobile/src/i18n/locales/el.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Αυτό το μοντέλο απομαγνητοφώνησης δεν είναι διαθέσιμο. Διάλεξε άλλο στις Προτιμήσεις.", "gatewayModelUnavailableNotice": "Αυτό το μοντέλο δεν προσφέρεται πλέον. Πατήστε $t(transcriptionModel.title) για να διαλέξετε άλλο.", "gatewaySignInRequired": "Συνδέσου για να χρησιμοποιήσεις την απομαγνητοφώνηση μέσω Kilo Gateway.", - "gatewayNoModel": "Διάλεξε πρώτα ένα μοντέλο απομαγνητοφώνησης στις Προτιμήσεις." + "gatewayNoModel": "Διάλεξε πρώτα ένα μοντέλο απομαγνητοφώνησης στις Προτιμήσεις.", + "testTitle": "Δοκιμή φωνητικής πληκτρολόγησης", + "testPlaceholder": "Πατήστε το μικρόφωνο και αρχίστε να μιλάτε.", + "testClear": "Εκκαθάριση κειμένου" }, "share": { "title": "Κοινοποίηση στο Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Δεν υπάρχουν μοντέλα απομαγνητοφώνησης", "emptyDescription": "Το Kilo Gateway δεν προσφέρει αυτή τη στιγμή μοντέλα απομαγνητοφώνησης.", "loadFailed": "Δεν ήταν δυνατή η φόρτωση των μοντέλων απομαγνητοφώνησης." + }, + "voiceLanguage": { + "title": "Γλώσσα φωνητικής πληκτρολόγησης", + "automatic": "Αυτόματη", + "loadFailed": "Δεν ήταν δυνατή η φόρτωση των γλωσσών που υποστηρίζει αυτή η συσκευή.", + "emptyTitle": "Καμία υποστηριζόμενη γλώσσα", + "emptyDescription": "Η αναγνώριση ομιλίας αυτής της συσκευής δεν αναφέρει καμία υποστηριζόμενη γλώσσα." } } diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 638b2fe493..24751520bd 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -2597,7 +2597,10 @@ "gatewayModelUnavailable": "This transcription model isn't available. Pick another one in Preferences.", "gatewayModelUnavailableNotice": "This model is no longer offered. Tap $t(transcriptionModel.title) to choose another.", "gatewaySignInRequired": "Sign in to use gateway transcription.", - "gatewayNoModel": "Choose a transcription model in Preferences first." + "gatewayNoModel": "Choose a transcription model in Preferences first.", + "testTitle": "Test voice input", + "testPlaceholder": "Tap the microphone and start speaking.", + "testClear": "Clear text" }, "share": { "title": "Share to Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "No transcription models", "emptyDescription": "The gateway offers no transcription models right now.", "loadFailed": "Couldn't load transcription models." + }, + "voiceLanguage": { + "title": "Voice language", + "automatic": "Automatic", + "loadFailed": "Couldn't load the languages this device supports.", + "emptyTitle": "No supported languages", + "emptyDescription": "This device's speech recognition reports no supported languages." } } diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json index cafd283c42..87c3d3c436 100644 --- a/apps/mobile/src/i18n/locales/es.json +++ b/apps/mobile/src/i18n/locales/es.json @@ -2161,7 +2161,10 @@ "gatewayModelUnavailable": "Este modelo de transcripción no está disponible. Elige otro en Preferencias.", "gatewayModelUnavailableNotice": "Este modelo ya no se ofrece. Toca $t(transcriptionModel.title) para elegir otro.", "gatewaySignInRequired": "Inicia sesión para usar la transcripción con Kilo Gateway.", - "gatewayNoModel": "Elige primero un modelo de transcripción en Preferencias." + "gatewayNoModel": "Elige primero un modelo de transcripción en Preferencias.", + "testTitle": "Probar el dictado", + "testPlaceholder": "Toca el micrófono y empieza a hablar.", + "testClear": "Borrar texto" }, "share": { "title": "Compartir en Kilo", @@ -2992,5 +2995,12 @@ "emptyTitle": "No hay modelos de transcripción", "emptyDescription": "Kilo Gateway no ofrece modelos de transcripción en este momento.", "loadFailed": "No se pudieron cargar los modelos de transcripción." + }, + "voiceLanguage": { + "title": "Idioma del dictado", + "automatic": "Automático", + "loadFailed": "No se pudieron cargar los idiomas que admite este dispositivo.", + "emptyTitle": "No hay idiomas compatibles", + "emptyDescription": "El reconocimiento de voz de este dispositivo no informa de ningún idioma compatible." } } diff --git a/apps/mobile/src/i18n/locales/et.json b/apps/mobile/src/i18n/locales/et.json index f43bd9ad18..9a09245922 100644 --- a/apps/mobile/src/i18n/locales/et.json +++ b/apps/mobile/src/i18n/locales/et.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Seda kõnetuvastuse mudelit pole saadaval. Vali Eelistustes mõni teine.", "gatewayModelUnavailableNotice": "Seda mudelit enam ei pakuta. Puuduta $t(transcriptionModel.title), et valida teine.", "gatewaySignInRequired": "Logi sisse, et kasutada Kilo Gateway kõnetuvastust.", - "gatewayNoModel": "Vali kõigepealt Eelistustes kõnetuvastuse mudel." + "gatewayNoModel": "Vali kõigepealt Eelistustes kõnetuvastuse mudel.", + "testTitle": "Testi häälsisestust", + "testPlaceholder": "Puuduta mikrofoni ja hakka rääkima.", + "testClear": "Tühjenda tekst" }, "share": { "title": "Kilosse jagamine", @@ -2970,5 +2973,12 @@ "emptyTitle": "Kõnetuvastuse mudelid puuduvad", "emptyDescription": "Kilo Gateway ei paku praegu ühtegi kõnetuvastuse mudelit.", "loadFailed": "Kõnetuvastuse mudeleid ei õnnestunud laadida." + }, + "voiceLanguage": { + "title": "Häälsisestuse keel", + "automatic": "Automaatne", + "loadFailed": "Selle seadme toetatavaid keeli ei õnnestunud laadida.", + "emptyTitle": "Toetatud keeli pole", + "emptyDescription": "Selle seadme kõnetuvastus ei teata ühtegi toetatud keelt." } } diff --git a/apps/mobile/src/i18n/locales/eu.json b/apps/mobile/src/i18n/locales/eu.json index eabdc8c73f..d82bf1ab97 100644 --- a/apps/mobile/src/i18n/locales/eu.json +++ b/apps/mobile/src/i18n/locales/eu.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Transkripzio-eredu hau ez dago erabilgarri. Hautatu besteren bat Hobespenetan.", "gatewayModelUnavailableNotice": "Eredu hau jada ez da eskaintzen. Sakatu $t(transcriptionModel.title) beste bat hautatzeko.", "gatewaySignInRequired": "Hasi saioa Kilo Gateway bidezko transkripzioa erabiltzeko.", - "gatewayNoModel": "Hautatu lehenik transkripzio-eredu bat Hobespenetan." + "gatewayNoModel": "Hautatu lehenik transkripzio-eredu bat Hobespenetan.", + "testTitle": "Probatu ahots-sarrera", + "testPlaceholder": "Sakatu mikrofonoa eta hasi hitz egiten.", + "testClear": "Garbitu testua" }, "share": { "title": "Partekatu Kilo-n", @@ -2970,5 +2973,12 @@ "emptyTitle": "Ez dago transkripzio-eredurik", "emptyDescription": "Kilo Gateway-k ez du transkripzio-eredurik eskaintzen oraintxe.", "loadFailed": "Ezin izan dira transkripzio-ereduak kargatu." + }, + "voiceLanguage": { + "title": "Ahots-sarreraren hizkuntza", + "automatic": "Automatikoa", + "loadFailed": "Ezin izan dira gailu honek onartzen dituen hizkuntzak kargatu.", + "emptyTitle": "Ez dago hizkuntza onarturik", + "emptyDescription": "Gailu honen ahots-ezagutzak ez du onartutako hizkuntzarik jakinarazten." } } diff --git a/apps/mobile/src/i18n/locales/fa.json b/apps/mobile/src/i18n/locales/fa.json index 1c892d3aa3..9c0f7ee062 100644 --- a/apps/mobile/src/i18n/locales/fa.json +++ b/apps/mobile/src/i18n/locales/fa.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "این مدل تبدیل گفتار به متن در دسترس نیست. در تنظیمات دلخواه مدل دیگری انتخاب کنید.", "gatewayModelUnavailableNotice": "این مدل دیگر ارائه نمی‌شود. برای انتخاب مدل دیگر روی $t(transcriptionModel.title) ضربه بزنید.", "gatewaySignInRequired": "برای استفاده از تبدیل گفتار به متن با Kilo Gateway وارد شوید.", - "gatewayNoModel": "ابتدا در تنظیمات دلخواه یک مدل تبدیل گفتار به متن انتخاب کنید." + "gatewayNoModel": "ابتدا در تنظیمات دلخواه یک مدل تبدیل گفتار به متن انتخاب کنید.", + "testTitle": "آزمایش ورودی صوتی", + "testPlaceholder": "میکروفون را لمس کنید و شروع به صحبت کنید.", + "testClear": "پاک کردن متن" }, "share": { "title": "اشتراک‌گذاری با Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "مدل تبدیل گفتار به متن وجود ندارد", "emptyDescription": "Kilo Gateway در حال حاضر مدلی برای تبدیل گفتار به متن ارائه نمی‌دهد.", "loadFailed": "بارگذاری مدل‌های تبدیل گفتار به متن ناموفق بود." + }, + "voiceLanguage": { + "title": "زبان ورودی صوتی", + "automatic": "خودکار", + "loadFailed": "زبان‌هایی که این دستگاه پشتیبانی می‌کند بارگذاری نشدند.", + "emptyTitle": "هیچ زبان پشتیبانی‌شده‌ای نیست", + "emptyDescription": "تشخیص گفتار این دستگاه هیچ زبان پشتیبانی‌شده‌ای گزارش نمی‌کند." } } diff --git a/apps/mobile/src/i18n/locales/fi.json b/apps/mobile/src/i18n/locales/fi.json index bf759dbad0..526e373039 100644 --- a/apps/mobile/src/i18n/locales/fi.json +++ b/apps/mobile/src/i18n/locales/fi.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Tätä puheentunnistusmallia ei ole käytettävissä. Valitse toinen Asetuksissa.", "gatewayModelUnavailableNotice": "Tätä mallia ei enää tarjota. Valitse toinen napauttamalla $t(transcriptionModel.title).", "gatewaySignInRequired": "Kirjaudu sisään käyttääksesi Kilo Gatewayn puheentunnistusta.", - "gatewayNoModel": "Valitse ensin puheentunnistusmalli Asetuksissa." + "gatewayNoModel": "Valitse ensin puheentunnistusmalli Asetuksissa.", + "testTitle": "Testaa puheentunnistusta", + "testPlaceholder": "Napauta mikrofonia ja ala puhua.", + "testClear": "Tyhjennä teksti" }, "share": { "title": "Jaa Kiloon", @@ -2970,5 +2973,12 @@ "emptyTitle": "Ei puheentunnistusmalleja", "emptyDescription": "Kilo Gateway ei juuri nyt tarjoa puheentunnistusmalleja.", "loadFailed": "Puheentunnistusmallien lataaminen epäonnistui." + }, + "voiceLanguage": { + "title": "Puheentunnistuksen kieli", + "automatic": "Automaattinen", + "loadFailed": "Tämän laitteen tukemia kieliä ei voitu ladata.", + "emptyTitle": "Ei tuettuja kieliä", + "emptyDescription": "Tämän laitteen puheentunnistus ei ilmoita tuettuja kieliä." } } diff --git a/apps/mobile/src/i18n/locales/fil.json b/apps/mobile/src/i18n/locales/fil.json index 9a7674b10c..eae1e81394 100644 --- a/apps/mobile/src/i18n/locales/fil.json +++ b/apps/mobile/src/i18n/locales/fil.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Hindi magagamit ang modelong ito para sa pagsasalin ng boses sa teksto. Pumili ng iba sa Mga kagustuhan.", "gatewayModelUnavailableNotice": "Hindi na inaalok ang modelong ito. I-tap ang $t(transcriptionModel.title) para pumili ng iba.", "gatewaySignInRequired": "Mag-sign in para gamitin ang pagsasalin ng boses sa teksto gamit ang Kilo Gateway.", - "gatewayNoModel": "Pumili muna ng modelo para sa pagsasalin ng boses sa teksto sa Mga kagustuhan." + "gatewayNoModel": "Pumili muna ng modelo para sa pagsasalin ng boses sa teksto sa Mga kagustuhan.", + "testTitle": "Subukan ang pagdikta", + "testPlaceholder": "I-tap ang mikropono at magsimulang magsalita.", + "testClear": "I-clear ang text" }, "share": { "title": "Pagbabahagi sa Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Walang modelo para sa pagsasalin ng boses sa teksto", "emptyDescription": "Walang iniaalok na modelo para sa pagsasalin ng boses sa teksto ang Kilo Gateway ngayon.", "loadFailed": "Hindi ma-load ang mga modelo para sa pagsasalin ng boses sa teksto." + }, + "voiceLanguage": { + "title": "Wika ng pagdikta", + "automatic": "Awtomatiko", + "loadFailed": "Hindi ma-load ang mga wikang sinusuportahan ng device na ito.", + "emptyTitle": "Walang sinusuportahang wika", + "emptyDescription": "Walang sinusuportahang wikang iniuulat ng pagkilala sa pagsasalita ng device na ito." } } diff --git a/apps/mobile/src/i18n/locales/fr.json b/apps/mobile/src/i18n/locales/fr.json index 1156a4138a..f0a3fb158c 100644 --- a/apps/mobile/src/i18n/locales/fr.json +++ b/apps/mobile/src/i18n/locales/fr.json @@ -2092,7 +2092,10 @@ "gatewayModelUnavailable": "Ce modèle de transcription n'est pas disponible. Choisissez-en un autre dans les Préférences.", "gatewayModelUnavailableNotice": "Ce modèle n'est plus proposé. Touchez $t(transcriptionModel.title) pour en choisir un autre.", "gatewaySignInRequired": "Connectez-vous pour utiliser la transcription via Kilo Gateway.", - "gatewayNoModel": "Choisissez d'abord un modèle de transcription dans les Préférences." + "gatewayNoModel": "Choisissez d'abord un modèle de transcription dans les Préférences.", + "testTitle": "Tester la saisie vocale", + "testPlaceholder": "Touchez le microphone et commencez à parler.", + "testClear": "Effacer le texte" }, "share": { "title": "Partager avec Kilo", @@ -2992,5 +2995,12 @@ "emptyTitle": "Aucun modèle de transcription", "emptyDescription": "Kilo Gateway ne propose actuellement aucun modèle de transcription.", "loadFailed": "Impossible de charger les modèles de transcription." + }, + "voiceLanguage": { + "title": "Langue de la saisie vocale", + "automatic": "Automatique", + "loadFailed": "Impossible de charger les langues prises en charge par cet appareil.", + "emptyTitle": "Aucune langue prise en charge", + "emptyDescription": "La reconnaissance vocale de cet appareil ne signale aucune langue prise en charge." } } diff --git a/apps/mobile/src/i18n/locales/ga.json b/apps/mobile/src/i18n/locales/ga.json index 78621869df..14b9e9345c 100644 --- a/apps/mobile/src/i18n/locales/ga.json +++ b/apps/mobile/src/i18n/locales/ga.json @@ -2644,7 +2644,10 @@ "gatewayModelUnavailable": "Níl an samhail tras-scríobh seo ar fáil. Roghnaigh ceann eile sna Roghanna.", "gatewayModelUnavailableNotice": "Ní thairgtear an tsamhail seo a thuilleadh. Tapáil $t(transcriptionModel.title) chun ceann eile a roghnú.", "gatewaySignInRequired": "Sínigh isteach chun tras-scríobh tríd an Kilo Gateway a úsáid.", - "gatewayNoModel": "Roghnaigh samhail tras-scríobh sna Roghanna ar dtús." + "gatewayNoModel": "Roghnaigh samhail tras-scríobh sna Roghanna ar dtús.", + "testTitle": "Tástáil ionchur gutha", + "testPlaceholder": "Tapáil an micreafón agus tosaigh ag labhairt.", + "testClear": "Glan an téacs" }, "share": { "title": "Comhroinnt le Kilo", @@ -3036,5 +3039,12 @@ "emptyTitle": "Níl samhail tras-scríobh ar bith ann", "emptyDescription": "Níl aon shamhail tras-scríobh ar fáil ón Kilo Gateway faoi láthair.", "loadFailed": "Níorbh fhéidir na samhlacha tras-scríobh a lódáil." + }, + "voiceLanguage": { + "title": "Teanga an ionchuir ghutha", + "automatic": "Uathoibríoch", + "loadFailed": "Níorbh fhéidir na teangacha a thacaíonn an gléas seo a lódáil.", + "emptyTitle": "Níl aon teanga thacaithe ann", + "emptyDescription": "Ní thuairiscíonn aithint cainte an ghléis seo aon teanga thacaithe." } } diff --git a/apps/mobile/src/i18n/locales/gl.json b/apps/mobile/src/i18n/locales/gl.json index bc3ad174a2..8af5e489f0 100644 --- a/apps/mobile/src/i18n/locales/gl.json +++ b/apps/mobile/src/i18n/locales/gl.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Este modelo de transcrición non está dispoñible. Escolle outro en Preferencias.", "gatewayModelUnavailableNotice": "Este modelo xa non se ofrece. Toca $t(transcriptionModel.title) para escoller outro.", "gatewaySignInRequired": "Inicia sesión para usar a transcrición con Kilo Gateway.", - "gatewayNoModel": "Escolle primeiro un modelo de transcrición en Preferencias." + "gatewayNoModel": "Escolle primeiro un modelo de transcrición en Preferencias.", + "testTitle": "Probar o ditado", + "testPlaceholder": "Toca o micrófono e comeza a falar.", + "testClear": "Borrar texto" }, "share": { "title": "Compartir en Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Non hai modelos de transcrición", "emptyDescription": "Kilo Gateway non ofrece modelos de transcrición neste momento.", "loadFailed": "Non se puideron cargar os modelos de transcrición." + }, + "voiceLanguage": { + "title": "Idioma do ditado", + "automatic": "Automático", + "loadFailed": "Non se puideron cargar os idiomas que admite este dispositivo.", + "emptyTitle": "Non hai idiomas compatibles", + "emptyDescription": "O recoñecemento de voz deste dispositivo non informa de ningún idioma compatible." } } diff --git a/apps/mobile/src/i18n/locales/gu.json b/apps/mobile/src/i18n/locales/gu.json index b5a8d3cb79..f1ea858a33 100644 --- a/apps/mobile/src/i18n/locales/gu.json +++ b/apps/mobile/src/i18n/locales/gu.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "અવાજને લખાણમાં ફેરવતું આ મોડેલ ઉપલબ્ધ નથી. પસંદગીઓમાં બીજું પસંદ કરો.", "gatewayModelUnavailableNotice": "આ મોડેલ હવે ઑફર કરવામાં આવતું નથી. બીજું પસંદ કરવા $t(transcriptionModel.title) પર ટૅપ કરો.", "gatewaySignInRequired": "Kilo Gateway દ્વારા લખાણ વાપરવા સાઇન ઇન કરો.", - "gatewayNoModel": "પહેલા પસંદગીઓમાં અવાજને લખાણમાં ફેરવતું મોડેલ પસંદ કરો." + "gatewayNoModel": "પહેલા પસંદગીઓમાં અવાજને લખાણમાં ફેરવતું મોડેલ પસંદ કરો.", + "testTitle": "વૉઇસ ઇનપુટનું પરીક્ષણ કરો", + "testPlaceholder": "માઇક્રોફોન પર ટૅપ કરો અને બોલવાનું શરૂ કરો.", + "testClear": "લખાણ સાફ કરો" }, "share": { "title": "Kilo પર શેર", @@ -2970,5 +2973,12 @@ "emptyTitle": "અવાજને લખાણમાં ફેરવતાં કોઈ મોડેલ નથી", "emptyDescription": "Kilo Gateway હાલમાં અવાજને લખાણમાં ફેરવતાં કોઈ મોડેલ આપતું નથી.", "loadFailed": "અવાજને લખાણમાં ફેરવતાં મોડેલ લોડ કરી શકાયાં નહીં." + }, + "voiceLanguage": { + "title": "વૉઇસ ઇનપુટની ભાષા", + "automatic": "આપમેળે", + "loadFailed": "આ ઉપકરણ સમર્થન આપે છે તે ભાષાઓ લોડ કરી શકાઈ નથી.", + "emptyTitle": "કોઈ સમર્થિત ભાષા નથી", + "emptyDescription": "આ ઉપકરણની વાણી ઓળખ કોઈ સમર્થિત ભાષા નોંધાવતી નથી." } } diff --git a/apps/mobile/src/i18n/locales/ha.json b/apps/mobile/src/i18n/locales/ha.json index f1993b3044..49d18a49d2 100644 --- a/apps/mobile/src/i18n/locales/ha.json +++ b/apps/mobile/src/i18n/locales/ha.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Wannan samfurin mayar da magana zuwa rubutu ba ya nan. Zaɓi wani a Zaɓuɓɓuka.", "gatewayModelUnavailableNotice": "Ba a ƙara ba da wannan samfuri ba. Matsa $t(transcriptionModel.title) don zaɓar wani.", "gatewaySignInRequired": "Shiga don amfani da mayar da magana ta Kilo Gateway.", - "gatewayNoModel": "Da farko zaɓi samfurin mayar da magana zuwa rubutu a Zaɓuɓɓuka." + "gatewayNoModel": "Da farko zaɓi samfurin mayar da magana zuwa rubutu a Zaɓuɓɓuka.", + "testTitle": "Gwada shigar da murya", + "testPlaceholder": "Danna makirufo ka fara magana.", + "testClear": "Share rubutu" }, "share": { "title": "Raba zuwa Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Babu samfuran mayar da magana zuwa rubutu", "emptyDescription": "Kilo Gateway bai ba da samfuran mayar da magana zuwa rubutu a halin yanzu.", "loadFailed": "An kasa loda samfuran mayar da magana zuwa rubutu." + }, + "voiceLanguage": { + "title": "Harshen shigar da murya", + "automatic": "Na atomatik", + "loadFailed": "An kasa loda harsunan da wannan na'urar ke goyan baya.", + "emptyTitle": "Babu harsunan da ake goyan baya", + "emptyDescription": "Fahimtar magana ta wannan na'urar ba ta bayar da wani harshe da ake goyan baya ba." } } diff --git a/apps/mobile/src/i18n/locales/he.json b/apps/mobile/src/i18n/locales/he.json index 68f3c687ce..4e2d76a602 100644 --- a/apps/mobile/src/i18n/locales/he.json +++ b/apps/mobile/src/i18n/locales/he.json @@ -2161,7 +2161,10 @@ "gatewayModelUnavailable": "מודל התמלול הזה אינו זמין. בחר מודל אחר בהעדפות.", "gatewayModelUnavailableNotice": "המודל הזה כבר לא מוצע. הקש על $t(transcriptionModel.title) כדי לבחור אחר.", "gatewaySignInRequired": "היכנס כדי להשתמש בתמלול דרך Kilo Gateway.", - "gatewayNoModel": "בחר קודם מודל תמלול בהעדפות." + "gatewayNoModel": "בחר קודם מודל תמלול בהעדפות.", + "testTitle": "בדיקת הכתבה", + "testPlaceholder": "הקישו על המיקרופון והתחילו לדבר.", + "testClear": "נקה טקסט" }, "share": { "title": "שיתוף עם Kilo", @@ -2992,5 +2995,12 @@ "emptyTitle": "אין מודלי תמלול", "emptyDescription": "Kilo Gateway לא מציע כרגע מודלי תמלול.", "loadFailed": "לא הצלחנו לטעון את מודלי התמלול." + }, + "voiceLanguage": { + "title": "שפת ההכתבה", + "automatic": "אוטומטי", + "loadFailed": "לא ניתן היה לטעון את השפות שהמכשיר הזה תומך בהן.", + "emptyTitle": "אין שפות נתמכות", + "emptyDescription": "זיהוי הדיבור של המכשיר הזה אינו מדווח על שפות נתמכות." } } diff --git a/apps/mobile/src/i18n/locales/hi.json b/apps/mobile/src/i18n/locales/hi.json index 15f6c7fb88..5b8447ceb0 100644 --- a/apps/mobile/src/i18n/locales/hi.json +++ b/apps/mobile/src/i18n/locales/hi.json @@ -2147,7 +2147,10 @@ "gatewayModelUnavailable": "आवाज़ को टेक्स्ट में बदलने का यह मॉडल उपलब्ध नहीं है। पसंद में दूसरा चुनें।", "gatewayModelUnavailableNotice": "यह मॉडल अब पेश नहीं किया जाता। दूसरा चुनने के लिए $t(transcriptionModel.title) पर टैप करें।", "gatewaySignInRequired": "Kilo Gateway से आवाज़ को टेक्स्ट में बदलने के लिए साइन इन करें।", - "gatewayNoModel": "पहले पसंद में आवाज़ को टेक्स्ट में बदलने का मॉडल चुनें।" + "gatewayNoModel": "पहले पसंद में आवाज़ को टेक्स्ट में बदलने का मॉडल चुनें।", + "testTitle": "वॉइस इनपुट का परीक्षण करें", + "testPlaceholder": "माइक्रोफ़ोन पर टैप करें और बोलना शुरू करें।", + "testClear": "टेक्स्ट साफ़ करें" }, "share": { "title": "Kilo पर साझा करें", @@ -2970,5 +2973,12 @@ "emptyTitle": "आवाज़ को टेक्स्ट में बदलने का कोई मॉडल नहीं", "emptyDescription": "Kilo Gateway फिलहाल आवाज़ को टेक्स्ट में बदलने के लिए कोई मॉडल नहीं दे रहा।", "loadFailed": "आवाज़ को टेक्स्ट में बदलने वाले मॉडल लोड नहीं हो सके।" + }, + "voiceLanguage": { + "title": "वॉइस इनपुट की भाषा", + "automatic": "स्वचालित", + "loadFailed": "इस डिवाइस द्वारा समर्थित भाषाएँ लोड नहीं हो सकीं।", + "emptyTitle": "कोई समर्थित भाषा नहीं", + "emptyDescription": "इस डिवाइस की वाक् पहचान कोई समर्थित भाषा नहीं बताती।" } } diff --git a/apps/mobile/src/i18n/locales/hr.json b/apps/mobile/src/i18n/locales/hr.json index 54ba7639eb..65658a0cfa 100644 --- a/apps/mobile/src/i18n/locales/hr.json +++ b/apps/mobile/src/i18n/locales/hr.json @@ -2602,7 +2602,10 @@ "gatewayModelUnavailable": "Ovaj model za transkripciju nije dostupan. Odaberi drugi u Postavkama.", "gatewayModelUnavailableNotice": "Ovaj se model više ne nudi. Dodirni $t(transcriptionModel.title) da odabereš drugi.", "gatewaySignInRequired": "Prijavi se za korištenje transkripcije putem Kilo Gatewaya.", - "gatewayNoModel": "Najprije odaberi model za transkripciju u Postavkama." + "gatewayNoModel": "Najprije odaberi model za transkripciju u Postavkama.", + "testTitle": "Testiraj glasovni unos", + "testPlaceholder": "Dodirnite mikrofon i počnite govoriti.", + "testClear": "Očisti tekst" }, "share": { "title": "Dijeljenje u aplikaciju Kilo", @@ -2992,5 +2995,12 @@ "emptyTitle": "Nema modela za transkripciju", "emptyDescription": "Kilo Gateway trenutačno ne nudi modele za transkripciju.", "loadFailed": "Nije moguće učitati modele za transkripciju." + }, + "voiceLanguage": { + "title": "Jezik glasovnog unosa", + "automatic": "Automatski", + "loadFailed": "Nije moguće učitati jezike koje ovaj uređaj podržava.", + "emptyTitle": "Nema podržanih jezika", + "emptyDescription": "Prepoznavanje govora na ovom uređaju ne prijavljuje nijedan podržani jezik." } } diff --git a/apps/mobile/src/i18n/locales/ht.json b/apps/mobile/src/i18n/locales/ht.json index 1e2a972def..ee1d05bcbe 100644 --- a/apps/mobile/src/i18n/locales/ht.json +++ b/apps/mobile/src/i18n/locales/ht.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Modèl transkripsyon sa a pa disponib. Chwazi yon lòt nan Preferans yo.", "gatewayModelUnavailableNotice": "Modèl sa a pa ofri ankò. Tape sou $t(transcriptionModel.title) pou chwazi yon lòt.", "gatewaySignInRequired": "Konekte pou sèvi ak transkripsyon Kilo Gateway.", - "gatewayNoModel": "Chwazi yon modèl transkripsyon nan Preferans yo an premye." + "gatewayNoModel": "Chwazi yon modèl transkripsyon nan Preferans yo an premye.", + "testTitle": "Teste dikte", + "testPlaceholder": "Tape sou mikwofòn nan epi kòmanse pale.", + "testClear": "Efase tèks" }, "share": { "title": "Pataj nan Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Pa gen modèl transkripsyon", "emptyDescription": "Kilo Gateway pa ofri okenn modèl transkripsyon kounye a.", "loadFailed": "Nou pa t kapab chaje modèl transkripsyon yo." + }, + "voiceLanguage": { + "title": "Lang dikte a", + "automatic": "Otomatik", + "loadFailed": "Pa t kapab chaje lang aparèy sa a sipòte yo.", + "emptyTitle": "Pa gen lang ki sipòte", + "emptyDescription": "Rekonesans vwa aparèy sa a pa rapòte okenn lang ki sipòte." } } diff --git a/apps/mobile/src/i18n/locales/hu.json b/apps/mobile/src/i18n/locales/hu.json index f615ee98a0..362fed0246 100644 --- a/apps/mobile/src/i18n/locales/hu.json +++ b/apps/mobile/src/i18n/locales/hu.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Ez az átírási modell nem érhető el. Válassz egy másikat a Beállításokban.", "gatewayModelUnavailableNotice": "Ez a modell már nem elérhető. Koppints a $t(transcriptionModel.title) elemre egy másik kiválasztásához.", "gatewaySignInRequired": "Jelentkezz be a Kilo Gateway-es átírás használatához.", - "gatewayNoModel": "Először válassz egy átírási modellt a Beállításokban." + "gatewayNoModel": "Először válassz egy átírási modellt a Beállításokban.", + "testTitle": "Hangbevitel tesztelése", + "testPlaceholder": "Érintse meg a mikrofont, és kezdjen el beszélni.", + "testClear": "Szöveg törlése" }, "share": { "title": "Megosztás a Kilo alkalmazással", @@ -2970,5 +2973,12 @@ "emptyTitle": "Nincsenek átírási modellek", "emptyDescription": "A Kilo Gateway jelenleg nem kínál átírási modelleket.", "loadFailed": "Nem sikerült betölteni az átírási modelleket." + }, + "voiceLanguage": { + "title": "Hangbevitel nyelve", + "automatic": "Automatikus", + "loadFailed": "Nem sikerült betölteni az eszköz által támogatott nyelveket.", + "emptyTitle": "Nincsenek támogatott nyelvek", + "emptyDescription": "Az eszköz beszédfelismerése nem jelez támogatott nyelvet." } } diff --git a/apps/mobile/src/i18n/locales/hy.json b/apps/mobile/src/i18n/locales/hy.json index e2c15cf473..0c9a69857d 100644 --- a/apps/mobile/src/i18n/locales/hy.json +++ b/apps/mobile/src/i18n/locales/hy.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Այս տեքստի վերածման մոդելը հասանելի չէ։ Ընտրեք մեկ այլ՝ Նախապատվություններում։", "gatewayModelUnavailableNotice": "Այս մոդելը այլևս չի առաջարկվում։ Մեկ այլ ընտրելու համար հպեք $t(transcriptionModel.title)։", "gatewaySignInRequired": "Մուտք գործեք՝ Kilo Gateway-ի վերածումն օգտագործելու համար։", - "gatewayNoModel": "Նախ ընտրեք տեքստի վերածման մոդել՝ Նախապատվություններում։" + "gatewayNoModel": "Նախ ընտրեք տեքստի վերածման մոդել՝ Նախապատվություններում։", + "testTitle": "Փորձարկել ձայնային մուտքագրումը", + "testPlaceholder": "Հպեք խոսափողին և սկսեք խոսել:", + "testClear": "Մաքրել տեքստը" }, "share": { "title": "Ուղարկում Kilo-ին", @@ -2970,5 +2973,12 @@ "emptyTitle": "Տեքստի վերածման մոդելներ չկան", "emptyDescription": "Kilo Gateway-ը հիմա տեքստի վերածման մոդելներ չի առաջարկում։", "loadFailed": "Չհաջողվեց բեռնել տեքստի վերածման մոդելները։" + }, + "voiceLanguage": { + "title": "Ձայնային մուտքագրման լեզու", + "automatic": "Ավտոմատ", + "loadFailed": "Չհաջողվեց բեռնել այս սարքի աջակցվող լեզուները:", + "emptyTitle": "Աջակցվող լեզուներ չկան", + "emptyDescription": "Այս սարքի խոսքի ճանաչումը ոչ մի աջակցվող լեզու չի հաղորդում:" } } diff --git a/apps/mobile/src/i18n/locales/id.json b/apps/mobile/src/i18n/locales/id.json index 27b9d94345..c5bf2030e1 100644 --- a/apps/mobile/src/i18n/locales/id.json +++ b/apps/mobile/src/i18n/locales/id.json @@ -2147,7 +2147,10 @@ "gatewayModelUnavailable": "Model transkripsi ini tidak tersedia. Pilih yang lain di Preferensi.", "gatewayModelUnavailableNotice": "Model ini tidak lagi ditawarkan. Ketuk $t(transcriptionModel.title) untuk memilih yang lain.", "gatewaySignInRequired": "Masuk untuk menggunakan transkripsi Kilo Gateway.", - "gatewayNoModel": "Pilih model transkripsi di Preferensi terlebih dahulu." + "gatewayNoModel": "Pilih model transkripsi di Preferensi terlebih dahulu.", + "testTitle": "Uji masukan suara", + "testPlaceholder": "Ketuk mikrofon dan mulai berbicara.", + "testClear": "Hapus teks" }, "share": { "title": "Bagikan ke Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Tidak ada model transkripsi", "emptyDescription": "Kilo Gateway tidak menawarkan model transkripsi saat ini.", "loadFailed": "Tidak dapat memuat model transkripsi." + }, + "voiceLanguage": { + "title": "Bahasa masukan suara", + "automatic": "Otomatis", + "loadFailed": "Tidak dapat memuat bahasa yang didukung perangkat ini.", + "emptyTitle": "Tidak ada bahasa yang didukung", + "emptyDescription": "Pengenalan suara perangkat ini tidak melaporkan bahasa yang didukung." } } diff --git a/apps/mobile/src/i18n/locales/ig.json b/apps/mobile/src/i18n/locales/ig.json index 6057842a14..3850af2d90 100644 --- a/apps/mobile/src/i18n/locales/ig.json +++ b/apps/mobile/src/i18n/locales/ig.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Ihe nlereanya a maka ịtụgharị okwu adịghị. Họrọ ọzọ na Mmasị.", "gatewayModelUnavailableNotice": "A naghịzi enye ihe nlereanya a. Pịa $t(transcriptionModel.title) iji họrọ ọzọ.", "gatewaySignInRequired": "Banye ka i jiri ịtụgharị okwu site na Kilo Gateway.", - "gatewayNoModel": "Họrọ ihe nlereanya maka ịtụgharị okwu na Mmasị ụzọ." + "gatewayNoModel": "Họrọ ihe nlereanya maka ịtụgharị okwu na Mmasị ụzọ.", + "testTitle": "Nwalee ntinye olu", + "testPlaceholder": "Pịa igwe okwu wee malite ikwu okwu.", + "testClear": "Hichapụ ederede" }, "share": { "title": "Kekọrịta na Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Enweghị ihe nlereanya maka ịtụgharị okwu ka ọ bụrụ ederede", "emptyDescription": "Kilo Gateway enyeghị ihe nlereanya ọ bụla maka ịtụgharị okwu ugbu a.", "loadFailed": "Enweghị ike ibudata ihe nlereanya maka ịtụgharị okwu." + }, + "voiceLanguage": { + "title": "Asụsụ ntinye olu", + "automatic": "Akpaaka", + "loadFailed": "Enweghị ike ibudata asụsụ ngwaọrụ a na-akwado.", + "emptyTitle": "Enweghị asụsụ akwadoro", + "emptyDescription": "Nchọpụta okwu nke ngwaọrụ a anaghị ekwupụta asụsụ ọ bụla akwadoro." } } diff --git a/apps/mobile/src/i18n/locales/is.json b/apps/mobile/src/i18n/locales/is.json index f314dc8482..474862a50e 100644 --- a/apps/mobile/src/i18n/locales/is.json +++ b/apps/mobile/src/i18n/locales/is.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Þetta umritunarlíkan er ekki í boði. Veldu annað í Kjörstillingum.", "gatewayModelUnavailableNotice": "Þetta líkan er ekki lengur í boði. Ýttu á $t(transcriptionModel.title) til að velja annað.", "gatewaySignInRequired": "Skráðu þig inn til að nota umritun í gegnum Kilo Gateway.", - "gatewayNoModel": "Veldu fyrst umritunarlíkan í Kjörstillingum." + "gatewayNoModel": "Veldu fyrst umritunarlíkan í Kjörstillingum.", + "testTitle": "Prófa raddinnslátt", + "testPlaceholder": "Ýttu á hljóðnemann og byrjaðu að tala.", + "testClear": "Hreinsa texta" }, "share": { "title": "Deila með Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Engin umritunarlíkan", "emptyDescription": "Kilo Gateway býður ekki upp á umritunarlíkan um þessar mundir.", "loadFailed": "Ekki tókst að sækja umritunarlíkin." + }, + "voiceLanguage": { + "title": "Tungumál raddinnsláttar", + "automatic": "Sjálfvirkt", + "loadFailed": "Ekki tókst að hlaða tungumálunum sem þetta tæki styður.", + "emptyTitle": "Engin studd tungumál", + "emptyDescription": "Talgreining þessa tækis tilkynnir engin studd tungumál." } } diff --git a/apps/mobile/src/i18n/locales/it.json b/apps/mobile/src/i18n/locales/it.json index a1c573738b..50effe29b3 100644 --- a/apps/mobile/src/i18n/locales/it.json +++ b/apps/mobile/src/i18n/locales/it.json @@ -2161,7 +2161,10 @@ "gatewayModelUnavailable": "Questo modello di trascrizione non è disponibile. Scegline un altro in Preferenze.", "gatewayModelUnavailableNotice": "Questo modello non è più offerto. Tocca $t(transcriptionModel.title) per sceglierne un altro.", "gatewaySignInRequired": "Accedi per usare la trascrizione con Kilo Gateway.", - "gatewayNoModel": "Scegli prima un modello di trascrizione in Preferenze." + "gatewayNoModel": "Scegli prima un modello di trascrizione in Preferenze.", + "testTitle": "Prova la dettatura", + "testPlaceholder": "Tocca il microfono e inizia a parlare.", + "testClear": "Cancella testo" }, "share": { "title": "Condivisione su Kilo", @@ -2992,5 +2995,12 @@ "emptyTitle": "Nessun modello di trascrizione", "emptyDescription": "Kilo Gateway non offre modelli di trascrizione in questo momento.", "loadFailed": "Impossibile caricare i modelli di trascrizione." + }, + "voiceLanguage": { + "title": "Lingua della dettatura", + "automatic": "Automatico", + "loadFailed": "Impossibile caricare le lingue supportate da questo dispositivo.", + "emptyTitle": "Nessuna lingua supportata", + "emptyDescription": "Il riconoscimento vocale di questo dispositivo non segnala lingue supportate." } } diff --git a/apps/mobile/src/i18n/locales/ja.json b/apps/mobile/src/i18n/locales/ja.json index de708b2f9e..bb085f6136 100644 --- a/apps/mobile/src/i18n/locales/ja.json +++ b/apps/mobile/src/i18n/locales/ja.json @@ -2147,7 +2147,10 @@ "gatewayModelUnavailable": "この文字起こしモデルは利用できません。設定で別のモデルを選択してください。", "gatewayModelUnavailableNotice": "このモデルは提供が終了しました。別のモデルを選ぶには$t(transcriptionModel.title)をタップしてください。", "gatewaySignInRequired": "Kilo Gatewayの文字起こしを使うにはサインインしてください。", - "gatewayNoModel": "まず設定で文字起こしモデルを選択してください。" + "gatewayNoModel": "まず設定で文字起こしモデルを選択してください。", + "testTitle": "音声入力をテスト", + "testPlaceholder": "マイクをタップして話し始めてください。", + "testClear": "テキストを消去" }, "share": { "title": "Kiloに共有", @@ -2970,5 +2973,12 @@ "emptyTitle": "文字起こしモデルがありません", "emptyDescription": "現在、Kilo Gatewayは文字起こしモデルを提供していません。", "loadFailed": "文字起こしモデルを読み込めませんでした。" + }, + "voiceLanguage": { + "title": "音声入力の言語", + "automatic": "自動", + "loadFailed": "このデバイスがサポートする言語を読み込めませんでした。", + "emptyTitle": "サポートされている言語がありません", + "emptyDescription": "このデバイスの音声認識は、サポートされている言語を報告しません。" } } diff --git a/apps/mobile/src/i18n/locales/ka.json b/apps/mobile/src/i18n/locales/ka.json index 1288699e0b..091427edf9 100644 --- a/apps/mobile/src/i18n/locales/ka.json +++ b/apps/mobile/src/i18n/locales/ka.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "ტრანსკრიფციის ეს მოდელი მიუწვდომელია. აირჩიეთ სხვა პარამეტრებში.", "gatewayModelUnavailableNotice": "ეს მოდელი აღარ არის ხელმისაწვდომი. სხვის ასარჩევად შეეხეთ $t(transcriptionModel.title)-ს.", "gatewaySignInRequired": "შედით, რომ გამოიყენოთ ტრანსკრიფცია Kilo Gateway-ით.", - "gatewayNoModel": "ჯერ აირჩიეთ ტრანსკრიფციის მოდელი პარამეტრებში." + "gatewayNoModel": "ჯერ აირჩიეთ ტრანსკრიფციის მოდელი პარამეტრებში.", + "testTitle": "ხმოვანი შეყვანის ტესტი", + "testPlaceholder": "შეეხეთ მიკროფონს და დაიწყეთ საუბარი.", + "testClear": "ტექსტის გასუფთავება" }, "share": { "title": "Kilo-ში გაზიარება", @@ -2970,5 +2973,12 @@ "emptyTitle": "ტრანსკრიფციის მოდელები არ არის", "emptyDescription": "Kilo Gateway ამჟამად არ სთავაზობს ტრანსკრიფციის მოდელებს.", "loadFailed": "ტრანსკრიფციის მოდელების ჩატვირთვა ვერ მოხერხდა." + }, + "voiceLanguage": { + "title": "ხმოვანი შეყვანის ენა", + "automatic": "ავტომატური", + "loadFailed": "ამ მოწყობილობის მხარდაჭერილი ენები ვერ ჩაიტვირთა.", + "emptyTitle": "მხარდაჭერილი ენები არ არის", + "emptyDescription": "ამ მოწყობილობის მეტყველების ამოცნობა მხარდაჭერილ ენებს არ აცნობებს." } } diff --git a/apps/mobile/src/i18n/locales/kk.json b/apps/mobile/src/i18n/locales/kk.json index d7808a940f..5984897041 100644 --- a/apps/mobile/src/i18n/locales/kk.json +++ b/apps/mobile/src/i18n/locales/kk.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Бұл дауысты мәтінге айналдыру моделі қолжетімсіз. Параметрлерден басқасын таңдаңыз.", "gatewayModelUnavailableNotice": "Бұл модель енді ұсынылмайды. Басқасын таңдау үшін $t(transcriptionModel.title) түртіңіз.", "gatewaySignInRequired": "Kilo Gateway арқылы айналдыруды қолдану үшін жүйеге кіріңіз.", - "gatewayNoModel": "Алдымен Параметрлерден дауысты мәтінге айналдыру моделін таңдаңыз." + "gatewayNoModel": "Алдымен Параметрлерден дауысты мәтінге айналдыру моделін таңдаңыз.", + "testTitle": "Дауыстық енгізуді тексеру", + "testPlaceholder": "Микрофонды түртіп, сөйлеуді бастаңыз.", + "testClear": "Мәтінді тазалау" }, "share": { "title": "Kilo-ға жіберу", @@ -2970,5 +2973,12 @@ "emptyTitle": "Дауысты мәтінге айналдыру модельдері жоқ", "emptyDescription": "Kilo Gateway қазір дауысты мәтінге айналдыру модельдерін ұсынбайды.", "loadFailed": "Дауысты мәтінге айналдыру модельдерін жүктеу мүмкін болмады." + }, + "voiceLanguage": { + "title": "Дауыстық енгізу тілі", + "automatic": "Авто", + "loadFailed": "Осы құрылғы қолдайтын тілдер жүктелмеді.", + "emptyTitle": "Қолдау көрсетілетін тілдер жоқ", + "emptyDescription": "Бұл құрылғының сөйлеуді тануы қолдау көрсетілетін тілдер туралы хабарламайды." } } diff --git a/apps/mobile/src/i18n/locales/km.json b/apps/mobile/src/i18n/locales/km.json index f4430411d0..08725eea73 100644 --- a/apps/mobile/src/i18n/locales/km.json +++ b/apps/mobile/src/i18n/locales/km.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "ម៉ូដែលបម្លែងសំឡេងជាអត្ថបទនេះមិនមានទេ។ សូមជ្រើសរើសម៉ូដែលផ្សេងទៀតក្នុងចំណូលចិត្ត។", "gatewayModelUnavailableNotice": "ម៉ូដែលនេះលែងផ្តល់ជូនទៀតទេ។ ចុច $t(transcriptionModel.title) ដើម្បីជ្រើសរើសមួយផ្សេងទៀត។", "gatewaySignInRequired": "ចូលគណនីដើម្បីប្រើការបម្លែងសំឡេងជាអត្ថបទតាម Kilo Gateway។", - "gatewayNoModel": "សូមជ្រើសរើសម៉ូដែលបម្លែងសំឡេងជាអត្ថបទក្នុងចំណូលចិត្តជាមុនសិន។" + "gatewayNoModel": "សូមជ្រើសរើសម៉ូដែលបម្លែងសំឡេងជាអត្ថបទក្នុងចំណូលចិត្តជាមុនសិន។", + "testTitle": "សាកល្បងការបញ្ចូលដោយសំឡេង", + "testPlaceholder": "ប៉ះមីក្រូហ្វូន ហើយចាប់ផ្តើមនិយាយ។", + "testClear": "សម្អាតអត្ថបទ" }, "share": { "title": "ចែករំលែកទៅ Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "គ្មានម៉ូដែលបម្លែងសំឡេងជាអត្ថបទ", "emptyDescription": "Kilo Gateway មិនផ្ដល់ម៉ូដែលបម្លែងសំឡេងជាអត្ថបទនៅពេលនេះទេ។", "loadFailed": "មិនអាចផ្ទុកម៉ូដែលបម្លែងសំឡេងជាអត្ថបទបានទេ។" + }, + "voiceLanguage": { + "title": "ភាសាបញ្ចូលដោយសំឡេង", + "automatic": "ស្វ័យប្រវត្តិ", + "loadFailed": "មិនអាចផ្ទុកភាសាដែលឧបករណ៍នេះគាំទ្របានទេ។", + "emptyTitle": "គ្មានភាសាដែលគាំទ្រ", + "emptyDescription": "ការស្គាល់សំឡេងនៃឧបករណ៍នេះមិនរាយការណ៍ភាសាដែលគាំទ្រណាមួយទេ។" } } diff --git a/apps/mobile/src/i18n/locales/kn.json b/apps/mobile/src/i18n/locales/kn.json index 26cd19f7fa..3e0be18900 100644 --- a/apps/mobile/src/i18n/locales/kn.json +++ b/apps/mobile/src/i18n/locales/kn.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "ಈ ಪರಿವರ್ತನಾ ಮಾದರಿ ಲಭ್ಯವಿಲ್ಲ. ಆದ್ಯತೆಗಳಲ್ಲಿ ಇನ್ನೊಂದನ್ನು ಆಯ್ಕೆಮಾಡಿ.", "gatewayModelUnavailableNotice": "ಈ ಮಾದರಿಯನ್ನು ಇನ್ನು ಮುಂದೆ ನೀಡಲಾಗುವುದಿಲ್ಲ. ಇನ್ನೊಂದನ್ನು ಆಯ್ಕೆಮಾಡಲು $t(transcriptionModel.title) ಅನ್ನು ಟ್ಯಾಪ್ ಮಾಡಿ.", "gatewaySignInRequired": "Kilo Gateway ಪರಿವರ್ತನೆಯನ್ನು ಬಳಸಲು ಸೈನ್ ಇನ್ ಮಾಡಿ.", - "gatewayNoModel": "ಮೊದಲು ಆದ್ಯತೆಗಳಲ್ಲಿ ಪರಿವರ್ತನಾ ಮಾದರಿಯನ್ನು ಆಯ್ಕೆಮಾಡಿ." + "gatewayNoModel": "ಮೊದಲು ಆದ್ಯತೆಗಳಲ್ಲಿ ಪರಿವರ್ತನಾ ಮಾದರಿಯನ್ನು ಆಯ್ಕೆಮಾಡಿ.", + "testTitle": "ಧ್ವನಿ ಇನ್‌ಪುಟ್ ಪರೀಕ್ಷಿಸಿ", + "testPlaceholder": "ಮೈಕ್ರೋಫೋನ್ ಟ್ಯಾಪ್ ಮಾಡಿ ಮತ್ತು ಮಾತನಾಡಲು ಪ್ರಾರಂಭಿಸಿ.", + "testClear": "ಪಠ್ಯ ತೆರವುಗೊಳಿಸಿ" }, "share": { "title": "Kiloಗೆ ಹಂಚಿಕೆ", @@ -2970,5 +2973,12 @@ "emptyTitle": "ಮಾತನ್ನು ಪಠ್ಯಕ್ಕೆ ಪರಿವರ್ತಿಸುವ ಯಾವುದೇ ಮಾದರಿಗಳಿಲ್ಲ", "emptyDescription": "Kilo Gateway ಪ್ರಸ್ತುತ ಮಾತನ್ನು ಪಠ್ಯಕ್ಕೆ ಪರಿವರ್ತಿಸುವ ಯಾವುದೇ ಮಾದರಿಗಳನ್ನು ಒದಗಿಸುತ್ತಿಲ್ಲ.", "loadFailed": "ಮಾತನ್ನು ಪಠ್ಯಕ್ಕೆ ಪರಿವರ್ತಿಸುವ ಮಾದರಿಗಳನ್ನು ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ." + }, + "voiceLanguage": { + "title": "ಧ್ವನಿ ಇನ್‌ಪುಟ್ ಭಾಷೆ", + "automatic": "ಸ್ವಯಂ", + "loadFailed": "ಈ ಸಾಧನವು ಬೆಂಬಲಿಸುವ ಭಾಷೆಗಳನ್ನು ಲೋಡ್ ಮಾಡಲಾಗಲಿಲ್ಲ.", + "emptyTitle": "ಬೆಂಬಲಿತ ಭಾಷೆಗಳಿಲ್ಲ", + "emptyDescription": "ಈ ಸಾಧನದ ಮಾತು ಗುರುತಿಸುವಿಕೆ ಯಾವುದೇ ಬೆಂಬಲಿತ ಭಾಷೆಗಳನ್ನು ವರದಿ ಮಾಡುವುದಿಲ್ಲ." } } diff --git a/apps/mobile/src/i18n/locales/ko.json b/apps/mobile/src/i18n/locales/ko.json index 4ec675056d..d97bf63ff5 100644 --- a/apps/mobile/src/i18n/locales/ko.json +++ b/apps/mobile/src/i18n/locales/ko.json @@ -2147,7 +2147,10 @@ "gatewayModelUnavailable": "이 음성 텍스트 변환 모델은 사용할 수 없습니다. 환경설정에서 다른 모델을 선택하세요.", "gatewayModelUnavailableNotice": "이 모델은 더 이상 제공되지 않습니다. 다른 모델을 선택하려면 $t(transcriptionModel.title)을 탭하세요.", "gatewaySignInRequired": "Kilo Gateway 변환을 사용하려면 로그인하세요.", - "gatewayNoModel": "먼저 환경설정에서 음성 텍스트 변환 모델을 선택하세요." + "gatewayNoModel": "먼저 환경설정에서 음성 텍스트 변환 모델을 선택하세요.", + "testTitle": "음성 입력 테스트", + "testPlaceholder": "마이크를 탭하고 말하기 시작하세요.", + "testClear": "텍스트 지우기" }, "share": { "title": "Kilo로 공유", @@ -2970,5 +2973,12 @@ "emptyTitle": "음성 텍스트 변환 모델 없음", "emptyDescription": "지금 Kilo Gateway에서 제공하는 음성 텍스트 변환 모델이 없습니다.", "loadFailed": "음성 텍스트 변환 모델을 불러오지 못했습니다." + }, + "voiceLanguage": { + "title": "음성 입력 언어", + "automatic": "자동", + "loadFailed": "이 기기가 지원하는 언어를 로드하지 못했습니다.", + "emptyTitle": "지원하는 언어 없음", + "emptyDescription": "이 기기의 음성 인식은 지원하는 언어를 보고하지 않습니다." } } diff --git a/apps/mobile/src/i18n/locales/lo.json b/apps/mobile/src/i18n/locales/lo.json index d353393a05..67838e2c23 100644 --- a/apps/mobile/src/i18n/locales/lo.json +++ b/apps/mobile/src/i18n/locales/lo.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "ໂມເດວຖອດສຽງນີ້ບໍ່ມີໃຫ້ໃຊ້. ເລືອກໂມເດວອື່ນໃນການຕັ້ງຄ່າສ່ວນຕົວ.", "gatewayModelUnavailableNotice": "ໂມເດວນີ້ບໍ່ໄດ້ສະເໜີອີກຕໍ່ໄປ. ແຕະ $t(transcriptionModel.title) ເພື່ອເລືອກອັນອື່ນ.", "gatewaySignInRequired": "ເຂົ້າສູ່ລະບົບເພື່ອໃຊ້ການຖອດສຽງຜ່ານ Kilo Gateway.", - "gatewayNoModel": "ເລືອກໂມເດວຖອດສຽງໃນການຕັ້ງຄ່າສ່ວນຕົວກ່ອນ." + "gatewayNoModel": "ເລືອກໂມເດວຖອດສຽງໃນການຕັ້ງຄ່າສ່ວນຕົວກ່ອນ.", + "testTitle": "ທົດສອບການປ້ອນດ້ວຍສຽງ", + "testPlaceholder": "ແຕະໄມໂຄຣໂຟນ ແລ້ວເລີ່ມເວົ້າ.", + "testClear": "ລ້າງຂໍ້ຄວາມ" }, "share": { "title": "ແບ່ງປັນໄປທີ່ Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "ບໍ່ມີໂມເດວຖອດສຽງເປັນຂໍ້ຄວາມ", "emptyDescription": "ຕອນນີ້ Kilo Gateway ບໍ່ມີໂມເດວຖອດສຽງເປັນຂໍ້ຄວາມ.", "loadFailed": "ບໍ່ສາມາດໂຫຼດໂມເດວຖອດສຽງເປັນຂໍ້ຄວາມໄດ້." + }, + "voiceLanguage": { + "title": "ພາສາຂອງການປ້ອນດ້ວຍສຽງ", + "automatic": "ອັດຕະໂນມັດ", + "loadFailed": "ບໍ່ສາມາດໂຫຼດພາສາທີ່ອຸປະກອນນີ້ຮອງຮັບໄດ້.", + "emptyTitle": "ບໍ່ມີພາສາທີ່ຮອງຮັບ", + "emptyDescription": "ການຮັບຮູ້ສຽງເວົ້າຂອງອຸປະກອນນີ້ບໍ່ລາຍງານພາສາທີ່ຮອງຮັບໃດໆ." } } diff --git a/apps/mobile/src/i18n/locales/lt.json b/apps/mobile/src/i18n/locales/lt.json index 011089132c..2b3ac21129 100644 --- a/apps/mobile/src/i18n/locales/lt.json +++ b/apps/mobile/src/i18n/locales/lt.json @@ -2623,7 +2623,10 @@ "gatewayModelUnavailable": "Šis transkribavimo modelis nepasiekiamas. Pasirinkite kitą Nuostatose.", "gatewayModelUnavailableNotice": "Šis modelis nebeteikiamas. Palieskite $t(transcriptionModel.title), kad pasirinktumėte kitą.", "gatewaySignInRequired": "Prisijunkite, kad naudotumėte transkribavimą per Kilo Gateway.", - "gatewayNoModel": "Pirmiausia pasirinkite transkribavimo modelį Nuostatose." + "gatewayNoModel": "Pirmiausia pasirinkite transkribavimo modelį Nuostatose.", + "testTitle": "Išbandyti balso įvestį", + "testPlaceholder": "Bakstelėkite mikrofoną ir pradėkite kalbėti.", + "testClear": "Išvalyti tekstą" }, "share": { "title": "Bendrinimas su Kilo", @@ -3014,5 +3017,12 @@ "emptyTitle": "Nėra transkribavimo modelių", "emptyDescription": "Šiuo metu Kilo Gateway neteikia jokių transkribavimo modelių.", "loadFailed": "Nepavyko įkelti transkribavimo modelių." + }, + "voiceLanguage": { + "title": "Balso įvesties kalba", + "automatic": "Automatinė", + "loadFailed": "Nepavyko įkelti šio įrenginio palaikomų kalbų.", + "emptyTitle": "Nėra palaikomų kalbų", + "emptyDescription": "Šio įrenginio kalbos atpažinimas nepraneša jokių palaikomų kalbų." } } diff --git a/apps/mobile/src/i18n/locales/lv.json b/apps/mobile/src/i18n/locales/lv.json index f3232d2b88..a7e9633b4e 100644 --- a/apps/mobile/src/i18n/locales/lv.json +++ b/apps/mobile/src/i18n/locales/lv.json @@ -2602,7 +2602,10 @@ "gatewayModelUnavailable": "Šis transkripcijas modelis nav pieejams. Izvēlies citu Lietotāja iestatījumos.", "gatewayModelUnavailableNotice": "Šis modelis vairs netiek piedāvāts. Pieskaries $t(transcriptionModel.title), lai izvēlētos citu.", "gatewaySignInRequired": "Pieraksties, lai izmantotu transkripciju, izmantojot Kilo Gateway.", - "gatewayNoModel": "Vispirms izvēlies transkripcijas modeli Lietotāja iestatījumos." + "gatewayNoModel": "Vispirms izvēlies transkripcijas modeli Lietotāja iestatījumos.", + "testTitle": "Pārbaudīt balss ievadi", + "testPlaceholder": "Pieskarieties mikrofonam un sāciet runāt.", + "testClear": "Notīrīt tekstu" }, "share": { "title": "Kopīgošana lietotnē Kilo", @@ -2992,5 +2995,12 @@ "emptyTitle": "Nav transkripcijas modeļu", "emptyDescription": "Pašlaik Kilo Gateway nepiedāvā nevienu transkripcijas modeli.", "loadFailed": "Neizdevās ielādēt transkripcijas modeļus." + }, + "voiceLanguage": { + "title": "Balss ievades valoda", + "automatic": "Automātiska", + "loadFailed": "Neizdevās ielādēt šīs ierīces atbalstītās valodas.", + "emptyTitle": "Nav atbalstītu valodu", + "emptyDescription": "Šīs ierīces runas atpazīšana nenorāda nevienu atbalstītu valodu." } } diff --git a/apps/mobile/src/i18n/locales/mg.json b/apps/mobile/src/i18n/locales/mg.json index bb72796ab5..b8dac21b23 100644 --- a/apps/mobile/src/i18n/locales/mg.json +++ b/apps/mobile/src/i18n/locales/mg.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Tsy azo ampiasaina ity modely fanovana feo ho soratra ity. Safidio ny hafa ao amin'ny Safidy.", "gatewayModelUnavailableNotice": "Tsy atolotra intsony ity modely ity. Tsindrio $t(transcriptionModel.title) hisafidianana ny hafa.", "gatewaySignInRequired": "Midira mba hampiasa ny fanovana feo ho soratra amin'ny Kilo Gateway.", - "gatewayNoModel": "Safidio aloha ny modely fanovana feo ho soratra ao amin'ny Safidy." + "gatewayNoModel": "Safidio aloha ny modely fanovana feo ho soratra ao amin'ny Safidy.", + "testTitle": "Andramo ny fampidirana amin'ny feo", + "testPlaceholder": "Tsindrio ny mikrô ary manomboka miteny.", + "testClear": "Fafao ny soratra" }, "share": { "title": "Fizarana amin'ny Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Tsy misy modely fanovana feo ho soratra", "emptyDescription": "Tsy manome modely fanovana feo ho soratra amin'izao fotoana izao ny Kilo Gateway.", "loadFailed": "Tsy tafiditra ny modely fanovana feo ho soratra." + }, + "voiceLanguage": { + "title": "Fitenin'ny fampidirana amin'ny feo", + "automatic": "Ho azy", + "loadFailed": "Tsy afaka naka ny fiteny tohanan'ity fitaovana ity.", + "emptyTitle": "Tsy misy fiteny tohana", + "emptyDescription": "Ny fanekena feo amin'ity fitaovana ity dia tsy mitatitra fiteny tohana." } } diff --git a/apps/mobile/src/i18n/locales/mi.json b/apps/mobile/src/i18n/locales/mi.json index 2857f4299d..4151938310 100644 --- a/apps/mobile/src/i18n/locales/mi.json +++ b/apps/mobile/src/i18n/locales/mi.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Kāore e wātea ana tēnei tauira tuhi ā-reo. Kōwhiria tētahi atu i Ngā manakohanga.", "gatewayModelUnavailableNotice": "Kāore tēnei tauira e tukuna ana ināianei. Pāwhiritia $t(transcriptionModel.title) ki te kōwhiri i tētahi atu.", "gatewaySignInRequired": "Takiuru kia taea te tuhi ā-reo mā Kilo Gateway.", - "gatewayNoModel": "Tuatahi, kōwhiria he tauira tuhi ā-reo i Ngā manakohanga." + "gatewayNoModel": "Tuatahi, kōwhiria he tauira tuhi ā-reo i Ngā manakohanga.", + "testTitle": "Whakamātautau i te tāuru reo", + "testPlaceholder": "Pāwhiritia te hopuoro ka tīmata ki te kōrero.", + "testClear": "Ūkui i te tuhinga" }, "share": { "title": "Tohatoha ki Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Kāore he tauira tuhi ā-reo", "emptyDescription": "I tēnei wā, kāore he tauira tuhi ā-reo e wātea ana i te Kilo Gateway.", "loadFailed": "Kāore i taea te uta i ngā tauira tuhi ā-reo." + }, + "voiceLanguage": { + "title": "Reo o te tāuru reo", + "automatic": "Aunoa", + "loadFailed": "Kāore i taea ngā reo e tautokona ana e tēnei pūrere te uta.", + "emptyTitle": "Kāore he reo e tautokona ana", + "emptyDescription": "Kāore te āhukahuka reo o tēnei pūrere e pūrongo i tētahi reo e tautokona ana." } } diff --git a/apps/mobile/src/i18n/locales/mk.json b/apps/mobile/src/i18n/locales/mk.json index ec86b95f49..cbe3223d4f 100644 --- a/apps/mobile/src/i18n/locales/mk.json +++ b/apps/mobile/src/i18n/locales/mk.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Овој модел за транскрипција не е достапен. Одбери друг во Лични поставки.", "gatewayModelUnavailableNotice": "Овој модел веќе не се нуди. Допрете $t(transcriptionModel.title) за да изберете друг.", "gatewaySignInRequired": "Најави се за да користиш транскрипција преку Kilo Gateway.", - "gatewayNoModel": "Прво одбери модел за транскрипција во Лични поставки." + "gatewayNoModel": "Прво одбери модел за транскрипција во Лични поставки.", + "testTitle": "Тестирај гласовен внес", + "testPlaceholder": "Допрете го микрофонот и започнете да зборувате.", + "testClear": "Исчисти текст" }, "share": { "title": "Споделување во Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Нема модели за транскрипција", "emptyDescription": "Kilo Gateway моментално не нуди модели за транскрипција.", "loadFailed": "Моделите за транскрипција не се вчитаа." + }, + "voiceLanguage": { + "title": "Јазик на гласовниот внес", + "automatic": "Автоматски", + "loadFailed": "Не можеа да се вчитаат јазиците што ги поддржува овој уред.", + "emptyTitle": "Нема поддржани јазици", + "emptyDescription": "Препознавањето говор на овој уред не пријавува поддржани јазици." } } diff --git a/apps/mobile/src/i18n/locales/ml.json b/apps/mobile/src/i18n/locales/ml.json index 0fb6444f75..dd458c39bf 100644 --- a/apps/mobile/src/i18n/locales/ml.json +++ b/apps/mobile/src/i18n/locales/ml.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "ഈ ശബ്ദം ടെക്സ്റ്റാക്കുന്ന മോഡൽ ലഭ്യമല്ല. മുൻഗണനകളിൽ മറ്റൊന്ന് തിരഞ്ഞെടുക്കുക.", "gatewayModelUnavailableNotice": "ഈ മോഡൽ ഇനി ലഭ്യമല്ല. മറ്റൊന്ന് തിരഞ്ഞെടുക്കാൻ $t(transcriptionModel.title) ടാപ്പ് ചെയ്യുക.", "gatewaySignInRequired": "Kilo Gateway വഴിയുള്ള ടെക്സ്റ്റാക്കൽ ഉപയോഗിക്കാൻ സൈൻ ഇൻ ചെയ്യുക.", - "gatewayNoModel": "ആദ്യം മുൻഗണനകളിൽ ശബ്ദം ടെക്സ്റ്റാക്കുന്ന മോഡൽ തിരഞ്ഞെടുക്കുക." + "gatewayNoModel": "ആദ്യം മുൻഗണനകളിൽ ശബ്ദം ടെക്സ്റ്റാക്കുന്ന മോഡൽ തിരഞ്ഞെടുക്കുക.", + "testTitle": "ശബ്ദ ഇൻപുട്ട് പരിശോധിക്കുക", + "testPlaceholder": "മൈക്രോഫോൺ ടാപ്പ് ചെയ്ത് സംസാരിക്കാൻ തുടങ്ങുക.", + "testClear": "ടെക്സ്റ്റ് മായ്ക്കുക" }, "share": { "title": "Kilo-യിലേക്ക് പങ്കിടുക", @@ -2970,5 +2973,12 @@ "emptyTitle": "ശബ്ദം ടെക്സ്റ്റാക്കാൻ മോഡലുകളില്ല", "emptyDescription": "Kilo Gateway ഇപ്പോൾ ശബ്ദം ടെക്സ്റ്റാക്കാൻ മോഡലുകൾ നൽകുന്നില്ല.", "loadFailed": "ശബ്ദം ടെക്സ്റ്റാക്കുന്ന മോഡലുകൾ ലോഡ് ചെയ്യാനായില്ല." + }, + "voiceLanguage": { + "title": "ശബ്ദ ഇൻപുട്ടിന്റെ ഭാഷ", + "automatic": "ഓട്ടോ", + "loadFailed": "ഈ ഉപകരണം പിന്തുണയ്ക്കുന്ന ഭാഷകൾ ലോഡ് ചെയ്യാനായില്ല.", + "emptyTitle": "പിന്തുണയ്ക്കുന്ന ഭാഷകളില്ല", + "emptyDescription": "ഈ ഉപകരണത്തിന്റെ ശബ്ദ തിരിച്ചറിയൽ പിന്തുണയ്ക്കുന്ന ഭാഷകളൊന്നും റിപ്പോർട്ട് ചെയ്യുന്നില്ല." } } diff --git a/apps/mobile/src/i18n/locales/mn.json b/apps/mobile/src/i18n/locales/mn.json index eba6e9d3e4..2fa70192af 100644 --- a/apps/mobile/src/i18n/locales/mn.json +++ b/apps/mobile/src/i18n/locales/mn.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Энэ яриаг бичвэр болгох загвар боломжгүй. Тохиргооноос өөр загвар сонгоно уу.", "gatewayModelUnavailableNotice": "Энэ загварыг цаашид санал болгохгүй. Өөр загвар сонгохын тулд $t(transcriptionModel.title)-г товшино уу.", "gatewaySignInRequired": "Kilo Gateway-р бичвэр болгохыг ашиглахын тулд нэвтэрнэ үү.", - "gatewayNoModel": "Эхлээд Тохиргооноос яриаг бичвэр болгох загвар сонгоно уу." + "gatewayNoModel": "Эхлээд Тохиргооноос яриаг бичвэр болгох загвар сонгоно уу.", + "testTitle": "Дуугаар оруулахыг турших", + "testPlaceholder": "Микрофоныг товшоод ярьж эхэлнэ үү.", + "testClear": "Текстийг арилгах" }, "share": { "title": "Kilo руу хуваалцах", @@ -2970,5 +2973,12 @@ "emptyTitle": "Яриаг бичвэр болгох загвар байхгүй", "emptyDescription": "Kilo Gateway одоогоор яриаг бичвэр болгох загвар санал болгохгүй байна.", "loadFailed": "Яриаг бичвэр болгох загваруудыг ачаалж чадсангүй." + }, + "voiceLanguage": { + "title": "Дуугаар оруулах хэл", + "automatic": "Авто", + "loadFailed": "Энэ төхөөрөмжийн дэмждэг хэлүүдийг ачаалж чадсангүй.", + "emptyTitle": "Дэмжигддэг хэл байхгүй", + "emptyDescription": "Энэ төхөөрөмжийн яриа таних нь дэмжигддэг хэл гэж мэдээлэхгүй." } } diff --git a/apps/mobile/src/i18n/locales/mr.json b/apps/mobile/src/i18n/locales/mr.json index 7a299dba55..112e33f97e 100644 --- a/apps/mobile/src/i18n/locales/mr.json +++ b/apps/mobile/src/i18n/locales/mr.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "हे लिप्यंतरण मॉडेल उपलब्ध नाही. प्राधान्यांमध्ये दुसरे निवडा.", "gatewayModelUnavailableNotice": "हे मॉडेल आता उपलब्ध नाही. दुसरे निवडण्यासाठी $t(transcriptionModel.title) वर टॅप करा.", "gatewaySignInRequired": "Kilo Gateway लिप्यंतरण वापरण्यासाठी साइन इन करा.", - "gatewayNoModel": "प्राधान्यांमध्ये आधी लिप्यंतरण मॉडेल निवडा." + "gatewayNoModel": "प्राधान्यांमध्ये आधी लिप्यंतरण मॉडेल निवडा.", + "testTitle": "व्हॉइस इनपुटची चाचणी करा", + "testPlaceholder": "मायक्रोफोनवर टॅप करा आणि बोलण्यास सुरुवात करा.", + "testClear": "मजकूर साफ करा" }, "share": { "title": "Kilo वर शेअर करा", @@ -2970,5 +2973,12 @@ "emptyTitle": "लिप्यंतरणासाठी मॉडेल्स नाहीत", "emptyDescription": "Kilo Gateway सध्या लिप्यंतरणासाठी कोणतीही मॉडेल्स देत नाही.", "loadFailed": "लिप्यंतरण मॉडेल्स लोड करता आली नाहीत." + }, + "voiceLanguage": { + "title": "व्हॉइस इनपुटची भाषा", + "automatic": "स्वयंचलित", + "loadFailed": "हे उपकरण समर्थित करते त्या भाषा लोड करता आल्या नाहीत.", + "emptyTitle": "कोणतीही समर्थित भाषा नाही", + "emptyDescription": "या उपकरणाची वाक् ओळख कोणतीही समर्थित भाषा नोंदवत नाही." } } diff --git a/apps/mobile/src/i18n/locales/ms.json b/apps/mobile/src/i18n/locales/ms.json index aa9ce01a63..aeb302e657 100644 --- a/apps/mobile/src/i18n/locales/ms.json +++ b/apps/mobile/src/i18n/locales/ms.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Model transkripsi ini tidak tersedia. Pilih yang lain dalam Keutamaan.", "gatewayModelUnavailableNotice": "Model ini tidak lagi ditawarkan. Ketik $t(transcriptionModel.title) untuk memilih yang lain.", "gatewaySignInRequired": "Log masuk untuk menggunakan transkripsi Kilo Gateway.", - "gatewayNoModel": "Pilih model transkripsi dalam Keutamaan terlebih dahulu." + "gatewayNoModel": "Pilih model transkripsi dalam Keutamaan terlebih dahulu.", + "testTitle": "Uji input suara", + "testPlaceholder": "Ketik mikrofon dan mula bercakap.", + "testClear": "Kosongkan teks" }, "share": { "title": "Kongsi ke Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Tiada model transkripsi", "emptyDescription": "Kilo Gateway tidak menawarkan model transkripsi pada masa ini.", "loadFailed": "Tidak dapat memuatkan model transkripsi." + }, + "voiceLanguage": { + "title": "Bahasa input suara", + "automatic": "Automatik", + "loadFailed": "Bahasa yang disokong peranti ini tidak dapat dimuatkan.", + "emptyTitle": "Tiada bahasa yang disokong", + "emptyDescription": "Pengecaman pertuturan peranti ini tidak melaporkan sebarang bahasa yang disokong." } } diff --git a/apps/mobile/src/i18n/locales/mt.json b/apps/mobile/src/i18n/locales/mt.json index ae735592d2..42bae96df9 100644 --- a/apps/mobile/src/i18n/locales/mt.json +++ b/apps/mobile/src/i18n/locales/mt.json @@ -2644,7 +2644,10 @@ "gatewayModelUnavailable": "Dan il-mudell ta' traskrizzjoni mhux disponibbli. Agħżel ieħor fil-Preferenzi.", "gatewayModelUnavailableNotice": "Dan il-mudell m'għadux disponibbli. Agħfas $t(transcriptionModel.title) biex tagħżel ieħor.", "gatewaySignInRequired": "Idħol biex tuża t-traskrizzjoni permezz ta' Kilo Gateway.", - "gatewayNoModel": "L-ewwel agħżel mudell ta' traskrizzjoni fil-Preferenzi." + "gatewayNoModel": "L-ewwel agħżel mudell ta' traskrizzjoni fil-Preferenzi.", + "testTitle": "Ittestja d-dħul bil-vuċi", + "testPlaceholder": "Ikketti l-mikrofonu u ibda tkellem.", + "testClear": "Neħħi t-test" }, "share": { "title": "Aqsam ma' Kilo", @@ -3036,5 +3039,12 @@ "emptyTitle": "L-ebda mudell ta' traskrizzjoni", "emptyDescription": "Kilo Gateway bħalissa ma joffri l-ebda mudell ta' traskrizzjoni.", "loadFailed": "Ma setgħetx titgħabba l-mudelli ta' traskrizzjoni." + }, + "voiceLanguage": { + "title": "Lingwa tad-dħul bil-vuċi", + "automatic": "Awtomatika", + "loadFailed": "Il-lingwi li jappoġġja dan l-apparat ma setgħux jitgħabbu.", + "emptyTitle": "L-ebda lingwa appoġġjata", + "emptyDescription": "Ir-rikonoxximent tal-kitba ta' dan l-apparat ma jirrapporta l-ebda lingwa appoġġjata." } } diff --git a/apps/mobile/src/i18n/locales/my.json b/apps/mobile/src/i18n/locales/my.json index 685869ec66..860dc4ecc7 100644 --- a/apps/mobile/src/i18n/locales/my.json +++ b/apps/mobile/src/i18n/locales/my.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "ဤအသံမှစာသားပြောင်းမော်ဒယ် မရရှိနိုင်ပါ။ စိတ်ကြိုက်ဆက်တင်များတွင် အခြားတစ်ခုကို ရွေးချယ်ပါ။", "gatewayModelUnavailableNotice": "ဤမော်ဒယ်ကို ပေးအပ်ခြင်း မရှိတော့ပါ။ အခြားတစ်ခု ရွေးချယ်ရန် $t(transcriptionModel.title) ကို နှိပ်ပါ။", "gatewaySignInRequired": "Kilo Gateway စာသားပြောင်းခြင်းကို သုံးရန် အကောင့်ဝင်ပါ။", - "gatewayNoModel": "ပထမ စိတ်ကြိုက်ဆက်တင်များတွင် အသံမှစာသားပြောင်းမော်ဒယ်တစ်ခုကို ရွေးချယ်ပါ။" + "gatewayNoModel": "ပထမ စိတ်ကြိုက်ဆက်တင်များတွင် အသံမှစာသားပြောင်းမော်ဒယ်တစ်ခုကို ရွေးချယ်ပါ။", + "testTitle": "အသံဖြင့် စာထည့်သွင်းမှုကို စမ်းသပ်ပါ", + "testPlaceholder": "မိုက်ခရိုဖုန်းကို နှိပ်ပြီး စတင်ပြောပါ။", + "testClear": "စာသား ရှင်းလင်းပါ" }, "share": { "title": "Kilo သို့ မျှဝေခြင်း", @@ -2970,5 +2973,12 @@ "emptyTitle": "အသံမှစာသားပြောင်းမော်ဒယ်များ မရှိပါ", "emptyDescription": "Kilo Gateway သည် လက်ရှိအချိန်တွင် အသံမှစာသားပြောင်းမော်ဒယ်များ မပေးဆောင်ပါ။", "loadFailed": "အသံမှစာသားပြောင်းမော်ဒယ်များကို မရယူနိုင်ပါ။" + }, + "voiceLanguage": { + "title": "အသံဖြင့် စာထည့်သွင်းမှု၏ ဘာသာစကား", + "automatic": "အလိုအလျောက်", + "loadFailed": "ဤစက်ပံ့ပိုးသော ဘာသာစကားများကို မဖွင့်နိုင်ပါ။", + "emptyTitle": "ပံ့ပိုးသော ဘာသာစကား မရှိပါ", + "emptyDescription": "ဤစက်၏ အသံမှတ်မိမှုသည် ပံ့ပိုးသော ဘာသာစကား မည်မျှကိုမျှ မဖော်ပြပါ။" } } diff --git a/apps/mobile/src/i18n/locales/nb.json b/apps/mobile/src/i18n/locales/nb.json index 81d59fb3b7..e90277f75b 100644 --- a/apps/mobile/src/i18n/locales/nb.json +++ b/apps/mobile/src/i18n/locales/nb.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Denne transkripsjonsmodellen er ikke tilgjengelig. Velg en annen under Innstillinger.", "gatewayModelUnavailableNotice": "Denne modellen tilbys ikke lenger. Trykk på $t(transcriptionModel.title) for å velge en annen.", "gatewaySignInRequired": "Logg inn for å bruke transkripsjon via Kilo Gateway.", - "gatewayNoModel": "Velg en transkripsjonsmodell under Innstillinger først." + "gatewayNoModel": "Velg en transkripsjonsmodell under Innstillinger først.", + "testTitle": "Test diktering", + "testPlaceholder": "Trykk på mikrofonen og begynn å snakke.", + "testClear": "Fjern tekst" }, "share": { "title": "Del med Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Ingen transkripsjonsmodeller", "emptyDescription": "Kilo Gateway tilbyr ingen transkripsjonsmodeller akkurat nå.", "loadFailed": "Kunne ikke laste inn transkripsjonsmodellene." + }, + "voiceLanguage": { + "title": "Dikteringsspråk", + "automatic": "Automatisk", + "loadFailed": "Kunne ikke laste inn språkene denne enheten støtter.", + "emptyTitle": "Ingen støttede språk", + "emptyDescription": "Talegjenkjenningen på denne enheten rapporterer ingen støttede språk." } } diff --git a/apps/mobile/src/i18n/locales/ne.json b/apps/mobile/src/i18n/locales/ne.json index a1f3d06381..3b058c782a 100644 --- a/apps/mobile/src/i18n/locales/ne.json +++ b/apps/mobile/src/i18n/locales/ne.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "यो रूपान्तरण मोडेल उपलब्ध छैन। रुचिहरूमा अर्को छान्नुहोस्।", "gatewayModelUnavailableNotice": "यो मोडेल अब उपलब्ध छैन। अर्को छान्न $t(transcriptionModel.title) मा ट्याप गर्नुहोस्।", "gatewaySignInRequired": "Kilo Gateway को रूपान्तरण प्रयोग गर्न साइन इन गर्नुहोस्।", - "gatewayNoModel": "पहिले रुचिहरूमा बोलीलाई पाठमा रूपान्तरण गर्ने मोडेल छान्नुहोस्।" + "gatewayNoModel": "पहिले रुचिहरूमा बोलीलाई पाठमा रूपान्तरण गर्ने मोडेल छान्नुहोस्।", + "testTitle": "भ्वाइस इनपुट परीक्षण गर्नुहोस्", + "testPlaceholder": "माइक्रोफोनमा ट्याप गर्नुहोस् र बोल्न सुरु गर्नुहोस्।", + "testClear": "पाठ सफा गर्नुहोस्" }, "share": { "title": "Kilo मा सेयर", @@ -2970,5 +2973,12 @@ "emptyTitle": "बोलीलाई पाठमा रूपान्तरण गर्ने कुनै मोडेल छैन", "emptyDescription": "Kilo Gateway ले अहिले बोलीलाई पाठमा रूपान्तरण गर्ने कुनै मोडेल प्रदान गरिरहेको छैन।", "loadFailed": "बोलीलाई पाठमा रूपान्तरण गर्ने मोडेलहरू लोड गर्न सकिएन।" + }, + "voiceLanguage": { + "title": "भ्वाइस इनपुटको भाषा", + "automatic": "स्वचालित", + "loadFailed": "यो यन्त्रले समर्थन गर्ने भाषाहरू लोड गर्न सकिएन।", + "emptyTitle": "कुनै समर्थित भाषा छैन", + "emptyDescription": "यो यन्त्रको वाणी पहिचानले कुनै समर्थित भाषा जानकारी दिँदैन।" } } diff --git a/apps/mobile/src/i18n/locales/nl.json b/apps/mobile/src/i18n/locales/nl.json index d4a7ab6043..520726f1ea 100644 --- a/apps/mobile/src/i18n/locales/nl.json +++ b/apps/mobile/src/i18n/locales/nl.json @@ -2147,7 +2147,10 @@ "gatewayModelUnavailable": "Dit transcriptiemodel is niet beschikbaar. Kies een ander model bij Voorkeuren.", "gatewayModelUnavailableNotice": "Dit model wordt niet meer aangeboden. Tik op $t(transcriptionModel.title) om een ander te kiezen.", "gatewaySignInRequired": "Meld je aan om transcriptie via Kilo Gateway te gebruiken.", - "gatewayNoModel": "Kies eerst een transcriptiemodel bij Voorkeuren." + "gatewayNoModel": "Kies eerst een transcriptiemodel bij Voorkeuren.", + "testTitle": "Spraakinvoer testen", + "testPlaceholder": "Tik op de microfoon en begin te spreken.", + "testClear": "Tekst wissen" }, "share": { "title": "Delen met Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Geen transcriptiemodellen", "emptyDescription": "Kilo Gateway biedt op dit moment geen transcriptiemodellen.", "loadFailed": "De transcriptiemodellen konden niet worden geladen." + }, + "voiceLanguage": { + "title": "Taal van spraakinvoer", + "automatic": "Automatisch", + "loadFailed": "Kan de talen die dit apparaat ondersteunt niet laden.", + "emptyTitle": "Geen ondersteunde talen", + "emptyDescription": "De spraakherkenning van dit apparaat meldt geen ondersteunde talen." } } diff --git a/apps/mobile/src/i18n/locales/om.json b/apps/mobile/src/i18n/locales/om.json index ca03613fdc..43f5204e7c 100644 --- a/apps/mobile/src/i18n/locales/om.json +++ b/apps/mobile/src/i18n/locales/om.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Moodeeli sagalee jijjiiru kun hin jiru. Filannoowwan keessaa kan biroo filadhu.", "gatewayModelUnavailableNotice": "Moodeeli kun hin dhiyaatu. Kan biraa filachuuf $t(transcriptionModel.title) tuqi.", "gatewaySignInRequired": "Jijjiirraa sagalee Kilo Gateway'tti fayyadamuuf seeni.", - "gatewayNoModel": "Dursee Filannoowwan keessatti moodeela sagalee jijjiiru filadhu." + "gatewayNoModel": "Dursee Filannoowwan keessatti moodeela sagalee jijjiiru filadhu.", + "testTitle": "Sagaleen galchuu qori", + "testPlaceholder": "Maayikirofoon tuqiitii dubbachuu jalqabi.", + "testClear": "Barreeffama haqi" }, "share": { "title": "Gara Kilo qoodi", @@ -2970,5 +2973,12 @@ "emptyTitle": "Moodeeli sagalee jijjiiru hin jiru", "emptyDescription": "Kilo Gateway amma moodeela sagalee gara barreeffamaatti jijjiiru hin dhiyeessu.", "loadFailed": "Moodeelota sagalee jijjiiru fe'uun hin danda'amne." + }, + "voiceLanguage": { + "title": "Afaan sagaleen galchuu", + "automatic": "Ofumaan", + "loadFailed": "Afaan meeshaan kun deeggartu fe'amuu hin dandeenye.", + "emptyTitle": "Afaan deeggarame hin jiru", + "emptyDescription": "Meeshaan kun beekumsa sagalee afaan deeggarame hin ibsu." } } diff --git a/apps/mobile/src/i18n/locales/or.json b/apps/mobile/src/i18n/locales/or.json index 5f634108bd..83bb8b5cde 100644 --- a/apps/mobile/src/i18n/locales/or.json +++ b/apps/mobile/src/i18n/locales/or.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "ଏହି ଲେଖା ବଦଳ ମଡେଲ୍ ଉପଲବ୍ଧ ନୁହେଁ। ପସନ୍ଦରେ ଅନ୍ୟ ଏକ ମଡେଲ୍ ବାଛନ୍ତୁ।", "gatewayModelUnavailableNotice": "ଏହି ମଡେଲ୍ ଆଉ ଦିଆଯାଉନାହିଁ। ଅନ୍ୟଟିଏ ବାଛିବାକୁ $t(transcriptionModel.title) ଟ୍ୟାପ୍ କରନ୍ତୁ।", "gatewaySignInRequired": "Kilo Gateway ଲେଖା ବଦଳ ବ୍ୟବହାର ପାଇଁ ସାଇନ୍ ଇନ୍ କରନ୍ତୁ।", - "gatewayNoModel": "ପ୍ରଥମେ ପସନ୍ଦରେ ଏକ ଲେଖା ବଦଳ ମଡେଲ୍ ବାଛନ୍ତୁ।" + "gatewayNoModel": "ପ୍ରଥମେ ପସନ୍ଦରେ ଏକ ଲେଖା ବଦଳ ମଡେଲ୍ ବାଛନ୍ତୁ।", + "testTitle": "କହି ଲେଖିବା ପରୀକ୍ଷା କରନ୍ତୁ", + "testPlaceholder": "ମାଇକ୍ରୋଫୋନ୍‌ରେ ଟ୍ୟାପ୍ କରନ୍ତୁ ଏବଂ କହିବା ଆରମ୍ଭ କରନ୍ତୁ।", + "testClear": "ଲେଖା ସଫା କରନ୍ତୁ" }, "share": { "title": "Kilo କୁ ସେୟାର", @@ -2970,5 +2973,12 @@ "emptyTitle": "କଥାକୁ ଲେଖାରେ ବଦଳାଇବାର କୌଣସି ମଡେଲ୍ ନାହିଁ", "emptyDescription": "Kilo Gateway ବର୍ତ୍ତମାନ କଥାକୁ ଲେଖାରେ ବଦଳାଇବାର କୌଣସି ମଡେଲ୍ ଦେଉନାହିଁ।", "loadFailed": "କଥାକୁ ଲେଖାରେ ବଦଳାଇବାର ମଡେଲ୍ ଗୁଡ଼ିକୁ ଲୋଡ୍ କରାଯାଇପାରିଲା ନାହିଁ।" + }, + "voiceLanguage": { + "title": "କହି ଲେଖିବାର ଭାଷା", + "automatic": "ସ୍ୱୟଂଚାଳିତ", + "loadFailed": "ଏହି ଡିଭାଇସ୍ ସମର୍ଥନ କରୁଥିବା ଭାଷାଗୁଡ଼ିକ ଲୋଡ୍ ହୋଇପାରିଲା ନାହିଁ।", + "emptyTitle": "କୌଣସି ସମର୍ଥିତ ଭାଷା ନାହିଁ", + "emptyDescription": "ଏହି ଡିଭାଇସ୍‌ର ବାକ୍ ଚିହ୍ନଟ କୌଣସି ସମର୍ଥିତ ଭାଷା ଜଣାଏ ନାହିଁ।" } } diff --git a/apps/mobile/src/i18n/locales/pa.json b/apps/mobile/src/i18n/locales/pa.json index c071e8745c..0acaa8666d 100644 --- a/apps/mobile/src/i18n/locales/pa.json +++ b/apps/mobile/src/i18n/locales/pa.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "ਇਹ ਬਦਲਣ ਵਾਲਾ ਮਾਡਲ ਉਪਲਬਧ ਨਹੀਂ ਹੈ। ਤਰਜੀਹਾਂ ਵਿੱਚ ਕੋਈ ਹੋਰ ਚੁਣੋ।", "gatewayModelUnavailableNotice": "ਇਹ ਮਾਡਲ ਹੁਣ ਪੇਸ਼ ਨਹੀਂ ਕੀਤਾ ਜਾਂਦਾ। ਹੋਰ ਚੁਣਨ ਲਈ $t(transcriptionModel.title) 'ਤੇ ਟੈਪ ਕਰੋ।", "gatewaySignInRequired": "Kilo Gateway ਬਦਲੀ ਵਰਤਣ ਲਈ ਸਾਈਨ ਇਨ ਕਰੋ।", - "gatewayNoModel": "ਪਹਿਲਾਂ ਤਰਜੀਹਾਂ ਵਿੱਚ ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣ ਵਾਲਾ ਮਾਡਲ ਚੁਣੋ।" + "gatewayNoModel": "ਪਹਿਲਾਂ ਤਰਜੀਹਾਂ ਵਿੱਚ ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣ ਵਾਲਾ ਮਾਡਲ ਚੁਣੋ।", + "testTitle": "ਵੌਇਸ ਇਨਪੁਟ ਦੀ ਜਾਂਚ ਕਰੋ", + "testPlaceholder": "ਮਾਈਕ੍ਰੋਫ਼ੋਨ 'ਤੇ ਟੈਪ ਕਰੋ ਅਤੇ ਬੋਲਣਾ ਸ਼ੁਰੂ ਕਰੋ।", + "testClear": "ਲਿਖਤ ਸਾਫ਼ ਕਰੋ" }, "share": { "title": "Kilo ਵਿੱਚ ਸਾਂਝਾ ਕਰਨਾ", @@ -2970,5 +2973,12 @@ "emptyTitle": "ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣ ਲਈ ਕੋਈ ਮਾਡਲ ਨਹੀਂ", "emptyDescription": "Kilo Gateway ਹੁਣ ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣ ਲਈ ਕੋਈ ਮਾਡਲ ਨਹੀਂ ਦੇ ਰਿਹਾ।", "loadFailed": "ਬੋਲੀ ਨੂੰ ਲਿਖਤ ਵਿੱਚ ਬਦਲਣ ਵਾਲੇ ਮਾਡਲ ਲੋਡ ਨਹੀਂ ਹੋ ਸਕੇ।" + }, + "voiceLanguage": { + "title": "ਵੌਇਸ ਇਨਪੁਟ ਦੀ ਭਾਸ਼ਾ", + "automatic": "ਆਟੋ", + "loadFailed": "ਇਸ ਡਿਵਾਈਸ ਦੁਆਰਾ ਸਮਰਥਿਤ ਭਾਸ਼ਾਵਾਂ ਲੋਡ ਨਹੀਂ ਹੋ ਸਕੀਆਂ।", + "emptyTitle": "ਕੋਈ ਸਮਰਥਿਤ ਭਾਸ਼ਾ ਨਹੀਂ", + "emptyDescription": "ਇਸ ਡਿਵਾਈਸ ਦੀ ਬੋਲੀ ਪਛਾਣ ਕੋਈ ਸਮਰਥਿਤ ਭਾਸ਼ਾ ਨਹੀਂ ਦੱਸਦੀ।" } } diff --git a/apps/mobile/src/i18n/locales/pl.json b/apps/mobile/src/i18n/locales/pl.json index c3ced2bca2..c0ab29191c 100644 --- a/apps/mobile/src/i18n/locales/pl.json +++ b/apps/mobile/src/i18n/locales/pl.json @@ -2175,7 +2175,10 @@ "gatewayModelUnavailable": "Ten model transkrypcji jest niedostępny. Wybierz inny w Preferencjach.", "gatewayModelUnavailableNotice": "Ten model nie jest już oferowany. Dotknij $t(transcriptionModel.title), aby wybrać inny.", "gatewaySignInRequired": "Zaloguj się, aby korzystać z transkrypcji przez Kilo Gateway.", - "gatewayNoModel": "Najpierw wybierz model transkrypcji w Preferencjach." + "gatewayNoModel": "Najpierw wybierz model transkrypcji w Preferencjach.", + "testTitle": "Testuj dyktowanie", + "testPlaceholder": "Dotknij mikrofonu i zacznij mówić.", + "testClear": "Wyczyść tekst" }, "share": { "title": "Udostępnianie w Kilo", @@ -3014,5 +3017,12 @@ "emptyTitle": "Brak modeli transkrypcji", "emptyDescription": "Kilo Gateway nie oferuje teraz żadnych modeli transkrypcji.", "loadFailed": "Nie udało się wczytać modeli transkrypcji." + }, + "voiceLanguage": { + "title": "Język dyktowania", + "automatic": "Automatyczny", + "loadFailed": "Nie udało się załadować języków obsługiwanych przez to urządzenie.", + "emptyTitle": "Brak obsługiwanych języków", + "emptyDescription": "Rozpoznawanie mowy tego urządzenia nie zgłasza żadnych obsługiwanych języków." } } diff --git a/apps/mobile/src/i18n/locales/ps.json b/apps/mobile/src/i18n/locales/ps.json index cdcaa78a27..c657d067ee 100644 --- a/apps/mobile/src/i18n/locales/ps.json +++ b/apps/mobile/src/i18n/locales/ps.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "دا ماډل شتون نلري. په غوره توبونو کې بل وټاکئ.", "gatewayModelUnavailableNotice": "دا ماډل نور وړاندې نه کېږي. د بل غوره کولو لپاره $t(transcriptionModel.title) ته ټک ووهئ.", "gatewaySignInRequired": "د Kilo Gateway بدلولو کارولو لپاره ننوځئ.", - "gatewayNoModel": "لومړی په غوره توبونو کې یو ماډل وټاکئ." + "gatewayNoModel": "لومړی په غوره توبونو کې یو ماډل وټاکئ.", + "testTitle": "د غږیز لیکل ازموینه", + "testPlaceholder": "مایکروفون ته ټېپ وکړئ او خبرې پیل کړئ.", + "testClear": "متن پاک کړئ" }, "share": { "title": "له Kilo سره شریکول", @@ -2970,5 +2973,12 @@ "emptyTitle": "د غږ د متن ته بدلولو هیڅ ماډل نشته", "emptyDescription": "Kilo Gateway اوس مهال د غږ د متن ته بدلولو هیڅ ماډل نه وړاندې کوي.", "loadFailed": "د غږ د متن ته بدلولو ماډلونه بار نه شول." + }, + "voiceLanguage": { + "title": "د غږیز لیکل ژبه", + "automatic": "اتومات", + "loadFailed": "د دې وسیلې ملاتړ شوې ژبې بار نشوې.", + "emptyTitle": "ملاتړ شوې ژبې نشته", + "emptyDescription": "د دې وسیلې د وینا پېژندنه کومه ملاتړ شوې ژبه نه ښیې." } } diff --git a/apps/mobile/src/i18n/locales/pt-BR.json b/apps/mobile/src/i18n/locales/pt-BR.json index 9dfa824ff4..b34fd42dd8 100644 --- a/apps/mobile/src/i18n/locales/pt-BR.json +++ b/apps/mobile/src/i18n/locales/pt-BR.json @@ -2161,7 +2161,10 @@ "gatewayModelUnavailable": "Este modelo de transcrição não está disponível. Escolha outro em Preferências.", "gatewayModelUnavailableNotice": "Este modelo não é mais oferecido. Toque em $t(transcriptionModel.title) para escolher outro.", "gatewaySignInRequired": "Entre para usar a transcrição com Kilo Gateway.", - "gatewayNoModel": "Escolha primeiro um modelo de transcrição em Preferências." + "gatewayNoModel": "Escolha primeiro um modelo de transcrição em Preferências.", + "testTitle": "Testar digitação por voz", + "testPlaceholder": "Toque no microfone e comece a falar.", + "testClear": "Limpar texto" }, "share": { "title": "Compartilhar com o Kilo", @@ -2992,5 +2995,12 @@ "emptyTitle": "Nenhum modelo de transcrição", "emptyDescription": "O Kilo Gateway não oferece modelos de transcrição no momento.", "loadFailed": "Não foi possível carregar os modelos de transcrição." + }, + "voiceLanguage": { + "title": "Idioma da digitação por voz", + "automatic": "Automático", + "loadFailed": "Não foi possível carregar os idiomas compatíveis com este dispositivo.", + "emptyTitle": "Nenhum idioma compatível", + "emptyDescription": "O reconhecimento de fala deste dispositivo não informa nenhum idioma compatível." } } diff --git a/apps/mobile/src/i18n/locales/pt.json b/apps/mobile/src/i18n/locales/pt.json index 8ad1a0be9f..d4e9d4ea20 100644 --- a/apps/mobile/src/i18n/locales/pt.json +++ b/apps/mobile/src/i18n/locales/pt.json @@ -2602,7 +2602,10 @@ "gatewayModelUnavailable": "Este modelo de transcrição não está disponível. Escolha outro em Preferências.", "gatewayModelUnavailableNotice": "Este modelo já não é oferecido. Toque em $t(transcriptionModel.title) para escolher outro.", "gatewaySignInRequired": "Inicie sessão para usar a transcrição com Kilo Gateway.", - "gatewayNoModel": "Escolha primeiro um modelo de transcrição em Preferências." + "gatewayNoModel": "Escolha primeiro um modelo de transcrição em Preferências.", + "testTitle": "Testar o ditado", + "testPlaceholder": "Toque no microfone e comece a falar.", + "testClear": "Limpar texto" }, "share": { "title": "Partilhar com o Kilo", @@ -2992,5 +2995,12 @@ "emptyTitle": "Sem modelos de transcrição", "emptyDescription": "O Kilo Gateway não oferece modelos de transcrição de momento.", "loadFailed": "Não foi possível carregar os modelos de transcrição." + }, + "voiceLanguage": { + "title": "Idioma do ditado", + "automatic": "Automático", + "loadFailed": "Não foi possível carregar os idiomas suportados por este dispositivo.", + "emptyTitle": "Nenhum idioma suportado", + "emptyDescription": "O reconhecimento de fala deste dispositivo não comunica nenhum idioma suportado." } } diff --git a/apps/mobile/src/i18n/locales/ro.json b/apps/mobile/src/i18n/locales/ro.json index 20615b1cbc..a08aaa6a97 100644 --- a/apps/mobile/src/i18n/locales/ro.json +++ b/apps/mobile/src/i18n/locales/ro.json @@ -2602,7 +2602,10 @@ "gatewayModelUnavailable": "Acest model de transcriere nu este disponibil. Alege altul în Preferințe.", "gatewayModelUnavailableNotice": "Acest model nu mai este oferit. Atinge $t(transcriptionModel.title) pentru a alege altul.", "gatewaySignInRequired": "Autentifică-te pentru a folosi transcrierea cu Kilo Gateway.", - "gatewayNoModel": "Alege mai întâi un model de transcriere în Preferințe." + "gatewayNoModel": "Alege mai întâi un model de transcriere în Preferințe.", + "testTitle": "Testează dictarea", + "testPlaceholder": "Atinge microfonul și începe să vorbești.", + "testClear": "Șterge textul" }, "share": { "title": "Distribuire către Kilo", @@ -2992,5 +2995,12 @@ "emptyTitle": "Nu există modele de transcriere", "emptyDescription": "Kilo Gateway nu oferă modele de transcriere în acest moment.", "loadFailed": "Nu s-au putut încărca modelele de transcriere." + }, + "voiceLanguage": { + "title": "Limba dictării", + "automatic": "Automat", + "loadFailed": "Limbile acceptate de acest dispozitiv nu au putut fi încărcate.", + "emptyTitle": "Nicio limbă acceptată", + "emptyDescription": "Recunoașterea vocală a acestui dispozitiv nu raportează nicio limbă acceptată." } } diff --git a/apps/mobile/src/i18n/locales/ru.json b/apps/mobile/src/i18n/locales/ru.json index ae3522ea5b..44673dc37e 100644 --- a/apps/mobile/src/i18n/locales/ru.json +++ b/apps/mobile/src/i18n/locales/ru.json @@ -2175,7 +2175,10 @@ "gatewayModelUnavailable": "Эта модель распознавания речи недоступна. Выберите другую в настройках.", "gatewayModelUnavailableNotice": "Эта модель больше не предлагается. Нажмите $t(transcriptionModel.title), чтобы выбрать другую.", "gatewaySignInRequired": "Войдите, чтобы использовать распознавание речи через Kilo Gateway.", - "gatewayNoModel": "Сначала выберите модель распознавания речи в настройках." + "gatewayNoModel": "Сначала выберите модель распознавания речи в настройках.", + "testTitle": "Проверить голосовой ввод", + "testPlaceholder": "Коснитесь микрофона и начните говорить.", + "testClear": "Очистить текст" }, "share": { "title": "Отправка в Kilo", @@ -3014,5 +3017,12 @@ "emptyTitle": "Нет моделей распознавания речи", "emptyDescription": "Сейчас Kilo Gateway не предлагает моделей распознавания речи.", "loadFailed": "Не удалось загрузить модели распознавания речи." + }, + "voiceLanguage": { + "title": "Язык голосового ввода", + "automatic": "Авто", + "loadFailed": "Не удалось загрузить языки, поддерживаемые этим устройством.", + "emptyTitle": "Нет поддерживаемых языков", + "emptyDescription": "Распознавание речи этого устройства не сообщает о поддерживаемых языках." } } diff --git a/apps/mobile/src/i18n/locales/si.json b/apps/mobile/src/i18n/locales/si.json index 5dcb8fa52f..1ca02d64a5 100644 --- a/apps/mobile/src/i18n/locales/si.json +++ b/apps/mobile/src/i18n/locales/si.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "මෙම හඬ පෙළට හැරවීමේ ආකෘතිය ලබා ගත නොහැක. මනාප වලින් තවත් එකක් තෝරන්න.", "gatewayModelUnavailableNotice": "මෙම ආකෘතිය තවදුරටත් ලබා දෙන්නේ නැත. තවත් එකක් තෝරන්න $t(transcriptionModel.title) තට්ටු කරන්න.", "gatewaySignInRequired": "Kilo Gateway හඬ පෙළට හැරවීම භාවිත කිරීමට පුරනය වන්න.", - "gatewayNoModel": "පළමුව මනාප වලින් හඬ පෙළට හැරවීමේ ආකෘතියක් තෝරන්න." + "gatewayNoModel": "පළමුව මනාප වලින් හඬ පෙළට හැරවීමේ ආකෘතියක් තෝරන්න.", + "testTitle": "හඬ ආදානය පරීක්ෂා කරන්න", + "testPlaceholder": "මයික්‍රෆෝනය තට්ටු කර කථා කිරීම අරඹන්න.", + "testClear": "පෙළ ඉවත් කරන්න" }, "share": { "title": "Kilo වෙත බෙදාගැනීම", @@ -2970,5 +2973,12 @@ "emptyTitle": "හඬ පෙළට හැරවීමේ ආකෘති නොමැත", "emptyDescription": "දැනට Kilo Gateway හඬ පෙළට හැරවීමේ ආකෘති ලබා දෙන්නේ නැත.", "loadFailed": "හඬ පෙළට හැරවීමේ ආකෘති පූරණය කළ නොහැකි විය." + }, + "voiceLanguage": { + "title": "හඬ ආදාන භාෂාව", + "automatic": "ස්වයංක්‍රීය", + "loadFailed": "මෙම උපාංගය සහාය දක්වන භාෂා පූරණය කළ නොහැකි විය.", + "emptyTitle": "සහාය දක්වන භාෂා නොමැත", + "emptyDescription": "මෙම උපාංගයේ කථන හඳුනාගැනීම සහාය දක්වන කිසිදු භාෂාවක් වාර්තා නොකරයි." } } diff --git a/apps/mobile/src/i18n/locales/sk.json b/apps/mobile/src/i18n/locales/sk.json index f50c0b0352..a2e889e62b 100644 --- a/apps/mobile/src/i18n/locales/sk.json +++ b/apps/mobile/src/i18n/locales/sk.json @@ -2623,7 +2623,10 @@ "gatewayModelUnavailable": "Tento model prepisu nie je dostupný. Vyberte iný v Predvoľbách.", "gatewayModelUnavailableNotice": "Tento model sa už neponúka. Klepnutím na $t(transcriptionModel.title) vyberte iný.", "gatewaySignInRequired": "Prihláste sa, aby ste mohli používať prepis cez Kilo Gateway.", - "gatewayNoModel": "Najprv vyberte model prepisu v Predvoľbách." + "gatewayNoModel": "Najprv vyberte model prepisu v Predvoľbách.", + "testTitle": "Otestovať hlasový vstup", + "testPlaceholder": "Klepnite na mikrofón a začnite hovoriť.", + "testClear": "Vymazať text" }, "share": { "title": "Zdieľanie do aplikácie Kilo", @@ -3014,5 +3017,12 @@ "emptyTitle": "Žiadne modely prepisu", "emptyDescription": "Kilo Gateway teraz neponúka žiadne modely prepisu.", "loadFailed": "Nepodarilo sa načítať modely prepisu." + }, + "voiceLanguage": { + "title": "Jazyk hlasového vstupu", + "automatic": "Automaticky", + "loadFailed": "Nepodarilo sa načítať jazyky, ktoré toto zariadenie podporuje.", + "emptyTitle": "Žiadne podporované jazyky", + "emptyDescription": "Rozpoznávanie reči tohto zariadenia nehlási žiadne podporované jazyky." } } diff --git a/apps/mobile/src/i18n/locales/sl.json b/apps/mobile/src/i18n/locales/sl.json index f3ee83bbce..ef4c7510b3 100644 --- a/apps/mobile/src/i18n/locales/sl.json +++ b/apps/mobile/src/i18n/locales/sl.json @@ -2623,7 +2623,10 @@ "gatewayModelUnavailable": "Ta model za prepisovanje ni na voljo. Izberite drugega v Nastavitvah.", "gatewayModelUnavailableNotice": "Ta model se ne ponuja več. Dotaknite se $t(transcriptionModel.title) in izberite drugega.", "gatewaySignInRequired": "Prijavite se za uporabo prepisovanja prek Kilo Gateway.", - "gatewayNoModel": "Najprej izberite model za prepisovanje v Nastavitvah." + "gatewayNoModel": "Najprej izberite model za prepisovanje v Nastavitvah.", + "testTitle": "Preizkusite glasovni vnos", + "testPlaceholder": "Dotaknite se mikrofona in začnite govoriti.", + "testClear": "Počisti besedilo" }, "share": { "title": "Deljenje v Kilo", @@ -3014,5 +3017,12 @@ "emptyTitle": "Ni modelov za prepisovanje", "emptyDescription": "Kilo Gateway trenutno ne ponuja modelov za prepisovanje.", "loadFailed": "Modelov za prepisovanje ni bilo mogoče naložiti." + }, + "voiceLanguage": { + "title": "Jezik glasovnega vnosa", + "automatic": "Samodejno", + "loadFailed": "Jezikov, ki jih podpira ta naprava, ni bilo mogoče naložiti.", + "emptyTitle": "Ni podprtih jezikov", + "emptyDescription": "Prepoznavanje govora te naprave ne poroča o podprtih jezikih." } } diff --git a/apps/mobile/src/i18n/locales/so.json b/apps/mobile/src/i18n/locales/so.json index 7fbee7e922..aa7bb89ef5 100644 --- a/apps/mobile/src/i18n/locales/so.json +++ b/apps/mobile/src/i18n/locales/so.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Moodeelkan qoraal u beddelidda lagama heli karo. Mid kale ka dooro Dookhyada.", "gatewayModelUnavailableNotice": "Moodeelkan lama bixiyo mar dambe. Taabo $t(transcriptionModel.title) si aad u dooratid mid kale.", "gatewaySignInRequired": "Soo gal si aad u isticmaasho qoraal u beddelidda Kilo Gateway.", - "gatewayNoModel": "Marka hore moodeel qoraal u beddelidda ka dooro Dookhyada." + "gatewayNoModel": "Marka hore moodeel qoraal u beddelidda ka dooro Dookhyada.", + "testTitle": "Tijaabi gelinta codka", + "testPlaceholder": "Taabo makarafoonka oo bilow hadalka.", + "testClear": "Nadiifi qoraalka" }, "share": { "title": "U wadaag Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Ma jiraan moodeello qoraal u beddelid", "emptyDescription": "Kilo Gateway hadda ma bixiyo moodeello qoraal u beddelid.", "loadFailed": "Moodeellada qoraal u beddelidda lama soo rari karin." + }, + "voiceLanguage": { + "title": "Luqadda gelinta codka", + "automatic": "Otomaatig", + "loadFailed": "Luqadaha qalabkani taageerto lama soo dejisan karin.", + "emptyTitle": "Ma jiraan luqado la taageeray", + "emptyDescription": "Aqoonsiga hadalka qalabkani ma soo sheego wax luqado la taageeray ah." } } diff --git a/apps/mobile/src/i18n/locales/sq.json b/apps/mobile/src/i18n/locales/sq.json index 38f5fc3173..4e3b21e86f 100644 --- a/apps/mobile/src/i18n/locales/sq.json +++ b/apps/mobile/src/i18n/locales/sq.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Ky model transkriptimi nuk është i disponueshëm. Zgjidh një tjetër te Preferencat.", "gatewayModelUnavailableNotice": "Ky model nuk ofrohet më. Trokit $t(transcriptionModel.title) për të zgjedhur një tjetër.", "gatewaySignInRequired": "Hyr për të përdorur transkriptimin me Kilo Gateway.", - "gatewayNoModel": "Së pari zgjidh një model transkriptimi te Preferencat." + "gatewayNoModel": "Së pari zgjidh një model transkriptimi te Preferencat.", + "testTitle": "Testo diktimin", + "testPlaceholder": "Prek mikrofonin dhe fillo të flasësh.", + "testClear": "Pastro tekstin" }, "share": { "title": "Ndarja në Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Nuk ka modele transkriptimi", "emptyDescription": "Kilo Gateway nuk ofron tani asnjë model transkriptimi.", "loadFailed": "Modelet e transkriptimit nuk u ngarkuan." + }, + "voiceLanguage": { + "title": "Gjuha e diktimit", + "automatic": "Automatik", + "loadFailed": "Nuk u ngarkuan dot gjuhët që mbështet kjo pajisje.", + "emptyTitle": "Asnjë gjuhë e mbështetur", + "emptyDescription": "Njohja e të folurit e kësaj pajisjeje nuk raporton asnjë gjuhë të mbështetur." } } diff --git a/apps/mobile/src/i18n/locales/sr.json b/apps/mobile/src/i18n/locales/sr.json index 5c6cbb80c4..dd90d27d3c 100644 --- a/apps/mobile/src/i18n/locales/sr.json +++ b/apps/mobile/src/i18n/locales/sr.json @@ -2602,7 +2602,10 @@ "gatewayModelUnavailable": "Ovaj model za transkripciju nije dostupan. Izaberi drugi u Podešavanjima.", "gatewayModelUnavailableNotice": "Ovaj model se više ne nudi. Dodirni $t(transcriptionModel.title) da izabereš drugi.", "gatewaySignInRequired": "Prijavi se da koristiš transkripciju preko Kilo Gateway-a.", - "gatewayNoModel": "Prvo izaberi model za transkripciju u Podešavanjima." + "gatewayNoModel": "Prvo izaberi model za transkripciju u Podešavanjima.", + "testTitle": "Testiraj glasovni unos", + "testPlaceholder": "Dodirnite mikrofon i počnite da govorite.", + "testClear": "Obriši tekst" }, "share": { "title": "Deljenje u aplikaciji Kilo", @@ -2992,5 +2995,12 @@ "emptyTitle": "Nema modela za transkripciju", "emptyDescription": "Kilo Gateway trenutno ne nudi modele za transkripciju.", "loadFailed": "Nije moguće učitati modele za transkripciju." + }, + "voiceLanguage": { + "title": "Jezik glasovnog unosa", + "automatic": "Automatski", + "loadFailed": "Nije bilo moguće učitati jezike koje ovaj uređaj podržava.", + "emptyTitle": "Nema podržanih jezika", + "emptyDescription": "Prepoznavanje govora na ovom uređaju ne prijavljuje nijedan podržani jezik." } } diff --git a/apps/mobile/src/i18n/locales/sv.json b/apps/mobile/src/i18n/locales/sv.json index 40f30db602..6bd7fe4de7 100644 --- a/apps/mobile/src/i18n/locales/sv.json +++ b/apps/mobile/src/i18n/locales/sv.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Den här transkriberingsmodellen är inte tillgänglig. Välj en annan i Inställningar.", "gatewayModelUnavailableNotice": "Den här modellen erbjuds inte längre. Tryck på $t(transcriptionModel.title) för att välja en annan.", "gatewaySignInRequired": "Logga in för att använda transkribering via Kilo Gateway.", - "gatewayNoModel": "Välj först en transkriberingsmodell i Inställningar." + "gatewayNoModel": "Välj först en transkriberingsmodell i Inställningar.", + "testTitle": "Testa röstinmatning", + "testPlaceholder": "Tryck på mikrofonen och börja prata.", + "testClear": "Rensa text" }, "share": { "title": "Dela till Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Inga transkriberingsmodeller", "emptyDescription": "Kilo Gateway erbjuder inga transkriberingsmodeller just nu.", "loadFailed": "Det gick inte att läsa in transkriberingsmodellerna." + }, + "voiceLanguage": { + "title": "Röstinmatningsspråk", + "automatic": "Automatisk", + "loadFailed": "Det gick inte att läsa in språken som den här enheten stöder.", + "emptyTitle": "Inga språk som stöds", + "emptyDescription": "Den här enhetens taligenkänning rapporterar inga språk som stöds." } } diff --git a/apps/mobile/src/i18n/locales/sw.json b/apps/mobile/src/i18n/locales/sw.json index e5d4165035..c8cbde1d28 100644 --- a/apps/mobile/src/i18n/locales/sw.json +++ b/apps/mobile/src/i18n/locales/sw.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Modeli hii ya unukuzi haipatikani. Chagua nyingine katika Mapendeleo.", "gatewayModelUnavailableNotice": "Modeli hii haitolewi tena. Gusa $t(transcriptionModel.title) kuchagua nyingine.", "gatewaySignInRequired": "Ingia ili utumie unukuzi wa Kilo Gateway.", - "gatewayNoModel": "Kwanza chagua modeli ya unukuzi katika Mapendeleo." + "gatewayNoModel": "Kwanza chagua modeli ya unukuzi katika Mapendeleo.", + "testTitle": "Jaribu kuingiza kwa sauti", + "testPlaceholder": "Gusa maikrofoni na anza kuzungumza.", + "testClear": "Futa maandishi" }, "share": { "title": "Kushiriki kwenye Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Hakuna modeli za unukuzi", "emptyDescription": "Kilo Gateway haina modeli za unukuzi kwa sasa.", "loadFailed": "Imeshindikana kupakia modeli za unukuzi." + }, + "voiceLanguage": { + "title": "Lugha ya kuingiza kwa sauti", + "automatic": "Kiotomatiki", + "loadFailed": "Imeshindwa kupakia lugha zinazoauniwa na kifaa hiki.", + "emptyTitle": "Hakuna lugha zinazoauniwa", + "emptyDescription": "Utambuzi wa usemi wa kifaa hiki hauripoti lugha yoyote inayoauniwa." } } diff --git a/apps/mobile/src/i18n/locales/ta.json b/apps/mobile/src/i18n/locales/ta.json index ca8616b9af..14628dfda4 100644 --- a/apps/mobile/src/i18n/locales/ta.json +++ b/apps/mobile/src/i18n/locales/ta.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "இந்த உரையாக்க மாதிரி கிடைக்கவில்லை. விருப்பத்தேர்வுகளில் வேறொன்றைத் தேர்ந்தெடுக்கவும்.", "gatewayModelUnavailableNotice": "இந்த மாதிரி இனி வழங்கப்படாது. வேறொன்றைத் தேர்ந்தெடுக்க $t(transcriptionModel.title) தட்டவும்.", "gatewaySignInRequired": "Kilo Gateway உரையாக்கத்தைப் பயன்படுத்த உள்நுழையவும்.", - "gatewayNoModel": "முதலில் விருப்பத்தேர்வுகளில் உரையாக்க மாதிரியைத் தேர்ந்தெடுக்கவும்." + "gatewayNoModel": "முதலில் விருப்பத்தேர்வுகளில் உரையாக்க மாதிரியைத் தேர்ந்தெடுக்கவும்.", + "testTitle": "குரல் உள்ளீட்டைச் சோதிக்கவும்", + "testPlaceholder": "மைக்ரோஃபோனைத் தட்டி பேசத் தொடங்கவும்.", + "testClear": "உரையை அழிக்கவும்" }, "share": { "title": "Kilo-வுக்குப் பகிர்தல்", @@ -2970,5 +2973,12 @@ "emptyTitle": "உரையாக்க மாதிரிகள் இல்லை", "emptyDescription": "Kilo Gateway தற்போது உரையாக்க மாதிரிகளை வழங்கவில்லை.", "loadFailed": "உரையாக்க மாதிரிகளை ஏற்ற முடியவில்லை." + }, + "voiceLanguage": { + "title": "குரல் உள்ளீட்டு மொழி", + "automatic": "தானியங்கி", + "loadFailed": "இந்தச் சாதனம் ஆதரிக்கும் மொழிகளை ஏற்ற முடியவில்லை.", + "emptyTitle": "ஆதரிக்கப்படும் மொழிகள் இல்லை", + "emptyDescription": "இந்தச் சாதனத்தின் பேச்சு அறிதல் ஆதரிக்கப்படும் மொழிகள் எதையும் தெரிவிக்கவில்லை." } } diff --git a/apps/mobile/src/i18n/locales/te.json b/apps/mobile/src/i18n/locales/te.json index add7304d58..eb9a1a7415 100644 --- a/apps/mobile/src/i18n/locales/te.json +++ b/apps/mobile/src/i18n/locales/te.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "ఈ వచన మార్పిడి మోడల్ అందుబాటులో లేదు. ప్రాధాన్యతలలో మరొకదాన్ని ఎంచుకోండి.", "gatewayModelUnavailableNotice": "ఈ మోడల్ ఇకపై అందించబడదు. మరొకదాన్ని ఎంచుకోవడానికి $t(transcriptionModel.title) ని నొక్కండి.", "gatewaySignInRequired": "Kilo Gateway మార్పిడిని ఉపయోగించడానికి సైన్ ఇన్ చేయండి.", - "gatewayNoModel": "ముందుగా ప్రాధాన్యతలలో వచనంగా మార్చే మోడల్‌ను ఎంచుకోండి." + "gatewayNoModel": "ముందుగా ప్రాధాన్యతలలో వచనంగా మార్చే మోడల్‌ను ఎంచుకోండి.", + "testTitle": "వాయిస్ ఇన్‌పుట్‌ను పరీక్షించండి", + "testPlaceholder": "మైక్రోఫోన్‌ను నొక్కి మాట్లాడటం ప్రారంభించండి.", + "testClear": "వచనాన్ని తీసివేయండి" }, "share": { "title": "Kiloలో పంచుకోవడం", @@ -2970,5 +2973,12 @@ "emptyTitle": "వచనంగా మార్చే మోడల్లు లేవు", "emptyDescription": "Kilo Gateway ప్రస్తుతం వచనంగా మార్చే మోడల్లను అందించడం లేదు.", "loadFailed": "వచనంగా మార్చే మోడల్లను లోడ్ చేయలేకపోయాం." + }, + "voiceLanguage": { + "title": "వాయిస్ ఇన్‌పుట్ భాష", + "automatic": "ఆటో", + "loadFailed": "ఈ పరికరం మద్దతిచ్చే భాషలను లోడ్ చేయలేకపోయాము.", + "emptyTitle": "మద్దతిచ్చే భాషలు లేవు", + "emptyDescription": "ఈ పరికరం యొక్క ప్రసంగ గుర్తింపు మద్దతిచ్చే భాషలను నివేదించదు." } } diff --git a/apps/mobile/src/i18n/locales/th.json b/apps/mobile/src/i18n/locales/th.json index 3347c65df4..2e4c104531 100644 --- a/apps/mobile/src/i18n/locales/th.json +++ b/apps/mobile/src/i18n/locales/th.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "โมเดลถอดเสียงนี้ไม่พร้อมใช้งาน เลือกโมเดลอื่นในการตั้งค่า", "gatewayModelUnavailableNotice": "โมเดลนี้ไม่ให้บริการแล้ว แตะ $t(transcriptionModel.title) เพื่อเลือกโมเดลอื่น", "gatewaySignInRequired": "เข้าสู่ระบบเพื่อใช้การถอดเสียงผ่าน Kilo Gateway", - "gatewayNoModel": "เลือกโมเดลถอดเสียงในการตั้งค่าก่อน" + "gatewayNoModel": "เลือกโมเดลถอดเสียงในการตั้งค่าก่อน", + "testTitle": "ทดสอบการพิมพ์ด้วยเสียง", + "testPlaceholder": "แตะไมโครโฟนแล้วเริ่มพูด", + "testClear": "ล้างข้อความ" }, "share": { "title": "แชร์ไปยัง Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "ไม่มีโมเดลถอดเสียง", "emptyDescription": "ขณะนี้ Kilo Gateway ไม่มีโมเดลถอดเสียงให้บริการ", "loadFailed": "ไม่สามารถโหลดโมเดลถอดเสียงได้" + }, + "voiceLanguage": { + "title": "ภาษาของการพิมพ์ด้วยเสียง", + "automatic": "อัตโนมัติ", + "loadFailed": "ไม่สามารถโหลดภาษาที่อุปกรณ์นี้รองรับได้", + "emptyTitle": "ไม่มีภาษาที่รองรับ", + "emptyDescription": "การรู้จำเสียงพูดของอุปกรณ์นี้ไม่รายงานภาษาที่รองรับ" } } diff --git a/apps/mobile/src/i18n/locales/tr.json b/apps/mobile/src/i18n/locales/tr.json index 078d5313b2..e1d2413898 100644 --- a/apps/mobile/src/i18n/locales/tr.json +++ b/apps/mobile/src/i18n/locales/tr.json @@ -2147,7 +2147,10 @@ "gatewayModelUnavailable": "Bu metne dönüştürme modeli kullanılamıyor. Tercihler'den başkasını seç.", "gatewayModelUnavailableNotice": "Bu model artık sunulmuyor. Başka bir tane seçmek için $t(transcriptionModel.title) öğesine dokun.", "gatewaySignInRequired": "Kilo Gateway ile dönüştürmeyi kullanmak için oturum açın.", - "gatewayNoModel": "Önce Tercihler'den bir metne dönüştürme modeli seçin." + "gatewayNoModel": "Önce Tercihler'den bir metne dönüştürme modeli seçin.", + "testTitle": "Sesli girişi test et", + "testPlaceholder": "Mikrofona dokunun ve konuşmaya başlayın.", + "testClear": "Metni temizle" }, "share": { "title": "Kilo ile paylaş", @@ -2970,5 +2973,12 @@ "emptyTitle": "Metne dönüştürme modeli yok", "emptyDescription": "Kilo Gateway şu anda metne dönüştürme modeli sunmuyor.", "loadFailed": "Metne dönüştürme modelleri yüklenemedi." + }, + "voiceLanguage": { + "title": "Sesli giriş dili", + "automatic": "Otomatik", + "loadFailed": "Bu cihazın desteklediği diller yüklenemedi.", + "emptyTitle": "Desteklenen dil yok", + "emptyDescription": "Bu cihazın konuşma tanıması desteklenen bir dil bildirmiyor." } } diff --git a/apps/mobile/src/i18n/locales/uk.json b/apps/mobile/src/i18n/locales/uk.json index 981d7ac246..adf58d51b6 100644 --- a/apps/mobile/src/i18n/locales/uk.json +++ b/apps/mobile/src/i18n/locales/uk.json @@ -2175,7 +2175,10 @@ "gatewayModelUnavailable": "Ця модель розпізнавання мовлення недоступна. Виберіть іншу в Налаштуваннях.", "gatewayModelUnavailableNotice": "Ця модель більше не пропонується. Натисніть $t(transcriptionModel.title), щоб вибрати іншу.", "gatewaySignInRequired": "Увійдіть, щоб використовувати розпізнавання через Kilo Gateway.", - "gatewayNoModel": "Спершу виберіть модель розпізнавання мовлення в Налаштуваннях." + "gatewayNoModel": "Спершу виберіть модель розпізнавання мовлення в Налаштуваннях.", + "testTitle": "Перевірити голосове введення", + "testPlaceholder": "Торкніться мікрофона й почніть говорити.", + "testClear": "Очистити текст" }, "share": { "title": "Надсилання в Kilo", @@ -3014,5 +3017,12 @@ "emptyTitle": "Немає моделей розпізнавання мовлення", "emptyDescription": "Зараз Kilo Gateway не пропонує моделей розпізнавання мовлення.", "loadFailed": "Не вдалося завантажити моделі розпізнавання мовлення." + }, + "voiceLanguage": { + "title": "Мова голосового введення", + "automatic": "Авто", + "loadFailed": "Не вдалося завантажити мови, які підтримує цей пристрій.", + "emptyTitle": "Немає підтримуваних мов", + "emptyDescription": "Розпізнавання мовлення цього пристрою не повідомляє про підтримувані мови." } } diff --git a/apps/mobile/src/i18n/locales/ur.json b/apps/mobile/src/i18n/locales/ur.json index bd68847183..a625c18782 100644 --- a/apps/mobile/src/i18n/locales/ur.json +++ b/apps/mobile/src/i18n/locales/ur.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "یہ ماڈل دستیاب نہیں ہے۔ ترجیحات میں دوسرا منتخب کریں۔", "gatewayModelUnavailableNotice": "یہ ماڈل اب پیش نہیں کیا جاتا۔ کوئی دوسرا منتخب کرنے کے لیے $t(transcriptionModel.title) پر ٹیپ کریں۔", "gatewaySignInRequired": "Kilo Gateway کی تبدیلی استعمال کرنے کے لیے سائن ان کریں۔", - "gatewayNoModel": "پہلے ترجیحات میں متن میں بدلنے کا ماڈل منتخب کریں۔" + "gatewayNoModel": "پہلے ترجیحات میں متن میں بدلنے کا ماڈل منتخب کریں۔", + "testTitle": "آواز سے لکھنے کی جانچ کریں", + "testPlaceholder": "مائیکروفون پر ٹیپ کریں اور بولنا شروع کریں۔", + "testClear": "متن صاف کریں" }, "share": { "title": "Kilo پر شیئر کریں", @@ -2970,5 +2973,12 @@ "emptyTitle": "متن میں بدلنے کا کوئی ماڈل موجود نہیں", "emptyDescription": "Kilo Gateway فی الحال متن میں بدلنے کا کوئی ماڈل نہیں دیتا۔", "loadFailed": "متن میں بدلنے والے ماڈلز لوڈ نہیں ہو سکے۔" + }, + "voiceLanguage": { + "title": "آواز سے لکھنے کی زبان", + "automatic": "خودکار", + "loadFailed": "اس ڈیوائس کے معاون زبانوں کو لوڈ نہیں کیا جا سکا۔", + "emptyTitle": "کوئی معاون زبان نہیں", + "emptyDescription": "اس ڈیوائس کی تقریر کی شناخت کسی معاون زبان کی اطلاع نہیں دیتی۔" } } diff --git a/apps/mobile/src/i18n/locales/uz.json b/apps/mobile/src/i18n/locales/uz.json index dd9634939b..1cc0ce7e88 100644 --- a/apps/mobile/src/i18n/locales/uz.json +++ b/apps/mobile/src/i18n/locales/uz.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Ushbu matnga aylantirish modeli mavjud emas. Shaxsiy sozlamalardan boshqasini tanlang.", "gatewayModelUnavailableNotice": "Bu model endi taklif etilmaydi. Boshqasini tanlash uchun $t(transcriptionModel.title) ga teging.", "gatewaySignInRequired": "Kilo Gateway orqali aylantirishdan foydalanish uchun tizimga kiring.", - "gatewayNoModel": "Avval Shaxsiy sozlamalardan matnga aylantirish modelini tanlang." + "gatewayNoModel": "Avval Shaxsiy sozlamalardan matnga aylantirish modelini tanlang.", + "testTitle": "Ovozli kiritishni sinash", + "testPlaceholder": "Mikrofonga teging va gapirishni boshlang.", + "testClear": "Matnni tozalash" }, "share": { "title": "Kiloga yuborish", @@ -2970,5 +2973,12 @@ "emptyTitle": "Matnga aylantirish modellari yo'q", "emptyDescription": "Kilo Gateway hozircha matnga aylantirish modellarini taklif qilmayapti.", "loadFailed": "Matnga aylantirish modellarini yuklab bo'lmadi." + }, + "voiceLanguage": { + "title": "Ovozli kiritish tili", + "automatic": "Avtomatik", + "loadFailed": "Bu qurilma qo'llab-quvvatlaydigan tillar yuklanmadi.", + "emptyTitle": "Qo'llab-quvvatlanadigan tillar yo'q", + "emptyDescription": "Bu qurilmaning nutqni aniqlashi qo'llab-quvvatlanadigan tillar haqida xabar bermaydi." } } diff --git a/apps/mobile/src/i18n/locales/vi.json b/apps/mobile/src/i18n/locales/vi.json index 9e5aa9967e..73779f2688 100644 --- a/apps/mobile/src/i18n/locales/vi.json +++ b/apps/mobile/src/i18n/locales/vi.json @@ -2147,7 +2147,10 @@ "gatewayModelUnavailable": "Mô hình này hiện không khả dụng. Hãy chọn mô hình khác trong Tùy chọn.", "gatewayModelUnavailableNotice": "Mô hình này không còn được cung cấp nữa. Nhấn $t(transcriptionModel.title) để chọn mô hình khác.", "gatewaySignInRequired": "Đăng nhập để dùng tính năng chuyển giọng nói qua Kilo Gateway.", - "gatewayNoModel": "Trước tiên, hãy chọn một mô hình chuyển giọng nói trong Tùy chọn." + "gatewayNoModel": "Trước tiên, hãy chọn một mô hình chuyển giọng nói trong Tùy chọn.", + "testTitle": "Kiểm tra nhập bằng giọng nói", + "testPlaceholder": "Chạm vào micrô và bắt đầu nói.", + "testClear": "Xóa văn bản" }, "share": { "title": "Chia sẻ đến Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Không có mô hình chuyển giọng nói thành văn bản", "emptyDescription": "Hiện Kilo Gateway không cung cấp mô hình chuyển giọng nói thành văn bản.", "loadFailed": "Không thể tải các mô hình chuyển giọng nói thành văn bản." + }, + "voiceLanguage": { + "title": "Ngôn ngữ nhập bằng giọng nói", + "automatic": "Tự động", + "loadFailed": "Không thể tải các ngôn ngữ mà thiết bị này hỗ trợ.", + "emptyTitle": "Không có ngôn ngữ được hỗ trợ", + "emptyDescription": "Tính năng nhận dạng giọng nói của thiết bị này không báo cáo ngôn ngữ được hỗ trợ nào." } } diff --git a/apps/mobile/src/i18n/locales/yo.json b/apps/mobile/src/i18n/locales/yo.json index d35b952a10..4f48a45d43 100644 --- a/apps/mobile/src/i18n/locales/yo.json +++ b/apps/mobile/src/i18n/locales/yo.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Àwòṣe ìyípadà yìí kò sí. Yan òmíràn nínú Àwọn ààyò.", "gatewayModelUnavailableNotice": "A kìí ṣe àwòṣe yìí mọ́. Tẹ $t(transcriptionModel.title) láti yan òmíràn.", "gatewaySignInRequired": "Wọlé láti lo ìyípadà ohùn sí ọ̀rọ̀ pẹ̀lú Kilo Gateway.", - "gatewayNoModel": "Ní àkọ́kọ́, yan àwòṣe ìyípadà ohùn sí ọ̀rọ̀ nínú Àwọn ààyò." + "gatewayNoModel": "Ní àkọ́kọ́, yan àwòṣe ìyípadà ohùn sí ọ̀rọ̀ nínú Àwọn ààyò.", + "testTitle": "Dán títẹ ọ̀rọ̀ pẹ̀lú ohùn wò", + "testPlaceholder": "Tẹ míkírọ́fóònù kí o sì bẹ̀rẹ̀ sí sọ̀rọ̀.", + "testClear": "Pa ọ̀rọ̀ rẹ́" }, "share": { "title": "Pín sí Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Kò sí àwòṣe ìyípadà ohùn sí ọ̀rọ̀", "emptyDescription": "Kilo Gateway kò pèsè àwòṣe ìyípadà ohùn sí ọ̀rọ̀ ní báyìí.", "loadFailed": "A kò lè gbé àwòṣe ìyípadà ohùn sí ọ̀rọ̀ wọlé." + }, + "voiceLanguage": { + "title": "Èdè títẹ ọ̀rọ̀ pẹ̀lú ohùn", + "automatic": "Aládàáṣiṣẹ́", + "loadFailed": "A kò lè gbé àwọn èdè tí ẹ̀rọ yìí ń ṣe àtìlẹ́yìn fún.", + "emptyTitle": "Kò sí èdè tí a ṣe àtìlẹ́yìn fún", + "emptyDescription": "Ìdánimọ̀ ọ̀rọ̀-ẹnu ti ẹ̀rọ yìí kò sọ èdè tí a ṣe àtìlẹ́yìn fún kankan." } } diff --git a/apps/mobile/src/i18n/locales/zh-Hans.json b/apps/mobile/src/i18n/locales/zh-Hans.json index 5de03ef03d..7e9ff3a64a 100644 --- a/apps/mobile/src/i18n/locales/zh-Hans.json +++ b/apps/mobile/src/i18n/locales/zh-Hans.json @@ -2147,7 +2147,10 @@ "gatewayModelUnavailable": "此转写模型不可用。请在偏好设置中选择其他模型。", "gatewayModelUnavailableNotice": "此模型已不再提供。点按$t(transcriptionModel.title)选择其他模型。", "gatewaySignInRequired": "登录后可使用 Kilo Gateway 转写。", - "gatewayNoModel": "请先在偏好设置中选择转写模型。" + "gatewayNoModel": "请先在偏好设置中选择转写模型。", + "testTitle": "测试语音输入", + "testPlaceholder": "点按麦克风并开始说话。", + "testClear": "清除文本" }, "share": { "title": "分享到 Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "没有转写模型", "emptyDescription": "Kilo Gateway 当前没有提供转写模型。", "loadFailed": "无法加载转写模型。" + }, + "voiceLanguage": { + "title": "语音输入语言", + "automatic": "自动", + "loadFailed": "无法加载此设备支持的语言。", + "emptyTitle": "没有支持的语言", + "emptyDescription": "此设备的语音识别未报告任何支持的语言。" } } diff --git a/apps/mobile/src/i18n/locales/zh-Hant.json b/apps/mobile/src/i18n/locales/zh-Hant.json index 0618078679..a58ee7d70b 100644 --- a/apps/mobile/src/i18n/locales/zh-Hant.json +++ b/apps/mobile/src/i18n/locales/zh-Hant.json @@ -2147,7 +2147,10 @@ "gatewayModelUnavailable": "這個轉錄模型無法使用。請在偏好設定中選擇其他模型。", "gatewayModelUnavailableNotice": "這個模型已不再提供。點一下$t(transcriptionModel.title)選擇另一個。", "gatewaySignInRequired": "登入後即可使用 Kilo Gateway 轉錄。", - "gatewayNoModel": "請先在偏好設定中選擇轉錄模型。" + "gatewayNoModel": "請先在偏好設定中選擇轉錄模型。", + "testTitle": "測試語音輸入", + "testPlaceholder": "點按麥克風並開始說話。", + "testClear": "清除文字" }, "share": { "title": "分享到 Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "沒有轉錄模型", "emptyDescription": "Kilo Gateway 目前沒有提供轉錄模型。", "loadFailed": "無法載入轉錄模型。" + }, + "voiceLanguage": { + "title": "語音輸入語言", + "automatic": "自動", + "loadFailed": "無法載入此裝置支援的語言。", + "emptyTitle": "沒有支援的語言", + "emptyDescription": "此裝置的語音辨識未回報任何支援的語言。" } } diff --git a/apps/mobile/src/i18n/locales/zu.json b/apps/mobile/src/i18n/locales/zu.json index f9c3d3a210..9f9c88574b 100644 --- a/apps/mobile/src/i18n/locales/zu.json +++ b/apps/mobile/src/i18n/locales/zu.json @@ -2581,7 +2581,10 @@ "gatewayModelUnavailable": "Le modeli yokuguqula inkulumo ayitholakali. Khetha enye Ku-Okuncamelayo.", "gatewayModelUnavailableNotice": "Le modeli ayisanikezwa. Thepha $t(transcriptionModel.title) ukukhetha enye.", "gatewaySignInRequired": "Ngena ngemvume ukuze usebenzise ukuguqula inkulumo nge-Kilo Gateway.", - "gatewayNoModel": "Okokuqala, khetha imodeli yokuguqula inkulumo Ku-Okuncamelayo." + "gatewayNoModel": "Okokuqala, khetha imodeli yokuguqula inkulumo Ku-Okuncamelayo.", + "testTitle": "Hlola ukufaka ngezwi", + "testPlaceholder": "Thepha imakrofoni bese uqala ukukhuluma.", + "testClear": "Sula umbhalo" }, "share": { "title": "Yabelana ku-Kilo", @@ -2970,5 +2973,12 @@ "emptyTitle": "Azikho izimodeli zokuguqula inkulumo", "emptyDescription": "I-Kilo Gateway ayinikezi zimodeli zokuguqula inkulumo okwamanje.", "loadFailed": "Azikwazanga ukulayisha izimodeli zokuguqula inkulumo." + }, + "voiceLanguage": { + "title": "Ulimi lokufaka ngezwi", + "automatic": "Okuzenzakalelayo", + "loadFailed": "Izilimi ezisekelwa yile divayisi azikwazanga ukulayishwa.", + "emptyTitle": "Azikho izilimi ezisekelwayo", + "emptyDescription": "Ukubona inkulumo kwale divayisi akubiki zilimi ezisekelwayo." } } diff --git a/apps/mobile/src/i18n/resolve-language.test.ts b/apps/mobile/src/i18n/resolve-language.test.ts index c242152945..5db47fe912 100644 --- a/apps/mobile/src/i18n/resolve-language.test.ts +++ b/apps/mobile/src/i18n/resolve-language.test.ts @@ -71,6 +71,20 @@ describe('resolveLanguageTag', () => { it('resolves zh to zh-Hans', () => { expect(resolveLanguageTag([{ languageTag: 'zh' }])).toBe('zh-Hans'); }); + + it('resolves the Android speech tag cmn-Hans-CN to zh-Hans', () => { + // Android's speech service names Mandarin with the ISO 639-3 code `cmn`; + // the app ships it as the zh scripts (p16, p4). + expect(resolveSupportedLanguageTag([{ languageTag: 'cmn-Hans-CN' }])).toBe('zh-Hans'); + }); + + it('resolves the Android speech tag cmn-Hant-TW to zh-Hant', () => { + expect(resolveSupportedLanguageTag([{ languageTag: 'cmn-Hant-TW' }])).toBe('zh-Hant'); + }); + + it('resolves a bare cmn tag to zh-Hans', () => { + expect(resolveSupportedLanguageTag([{ languageTag: 'cmn' }])).toBe('zh-Hans'); + }); }); describe('resolveSupportedLanguageTag', () => { diff --git a/apps/mobile/src/i18n/resolve-language.ts b/apps/mobile/src/i18n/resolve-language.ts index c3d56e01cc..a412e2a15b 100644 --- a/apps/mobile/src/i18n/resolve-language.ts +++ b/apps/mobile/src/i18n/resolve-language.ts @@ -3,7 +3,14 @@ import { getLocales } from 'expo-localization'; import { SUPPORTED_LANGUAGES, type SupportedLanguage } from './languages'; function normalizeLocale(tag: string): string { - return tag.toLowerCase().replaceAll('_', '-'); + // Android's speech service names Mandarin with the ISO 639-3 code `cmn` + // (`cmn-Hans-CN`), the app ships it as `zh-Hans`/`zh-Hant`. Canonicalizing + // the primary subtag here makes every comparison below treat the two + // spellings as one language. + return tag + .toLowerCase() + .replaceAll('_', '-') + .replace(/^cmn(?=-|$)/, 'zh'); } /** diff --git a/apps/mobile/src/i18n/voice-copy.test.ts b/apps/mobile/src/i18n/voice-copy.test.ts new file mode 100644 index 0000000000..8a6402a88a --- /dev/null +++ b/apps/mobile/src/i18n/voice-copy.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import en from './locales/en.json'; + +/** + * Copy for the voice language picker and the voice-input test field. English + * is the source of truth: the translation slice fills the other catalogs, and + * this test pins the eight new messages so a rename cannot ship silently. + */ +const VOICE_LANGUAGE_COPY: [keyof typeof en.voiceLanguage, string][] = [ + ['title', 'Voice language'], + ['automatic', 'Automatic'], + ['loadFailed', "Couldn't load the languages this device supports."], + ['emptyTitle', 'No supported languages'], + ['emptyDescription', "This device's speech recognition reports no supported languages."], +]; + +const VOICE_INPUT_TEST_COPY: [keyof typeof en.voiceInput, string][] = [ + ['testTitle', 'Test voice input'], + ['testPlaceholder', 'Tap the microphone and start speaking.'], + ['testClear', 'Clear text'], +]; + +describe('voice copy', () => { + it.each(VOICE_LANGUAGE_COPY)('defines voiceLanguage.%s', (key, value) => { + expect(en.voiceLanguage[key]).toBe(value); + }); + + it.each(VOICE_INPUT_TEST_COPY)('defines voiceInput.%s', (key, value) => { + expect(en.voiceInput[key]).toBe(value); + }); + + it('keeps the reused picker and state copy', () => { + expect(en.common.cancel).toBeTruthy(); + expect(en.common.done).toBeTruthy(); + expect(en.common.retry).toBeTruthy(); + expect(en.common.language).toBeTruthy(); + expect(en.common.loading).toBeTruthy(); + expect(en.language.search).toBeTruthy(); + expect(en.language.noMatches).toBeTruthy(); + }); +}); diff --git a/apps/mobile/src/lib/storage-keys.ts b/apps/mobile/src/lib/storage-keys.ts index 7b3ade71e4..62ab34cb40 100644 --- a/apps/mobile/src/lib/storage-keys.ts +++ b/apps/mobile/src/lib/storage-keys.ts @@ -42,6 +42,8 @@ export const RETURN_SENDS_MESSAGE_KEY = 'return-sends-message'; export const GATEWAY_TRANSCRIPTION_ENABLED_KEY = 'gateway-transcription-enabled'; /** Persisted `{ id, name }` of the chosen gateway transcription model (null = none chosen). */ export const GATEWAY_TRANSCRIPTION_MODEL_KEY = 'gateway-transcription-model'; +/** Persisted BCP-47 tag of the chosen voice-input language (empty = auto from the app/device language). */ +export const VOICE_INPUT_LANGUAGE_KEY = 'voice-input-language'; /** Revocable per-host list of markdown link hosts that open without an Alert. */ export const TRUSTED_HOSTS_KEY = 'trusted-hosts'; export const PR_REVIEW_FOOTER_KEY = 'pr-review-footer-enabled'; diff --git a/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.test.ts b/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.test.ts index 5e070c20a2..ce267c8da5 100644 --- a/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.test.ts +++ b/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.test.ts @@ -55,12 +55,13 @@ type FakeRecorder = GatewayRecorder & { release: Mock<() => void>; }; -function makeRecorder(): FakeRecorder { +function makeRecorder(uri: string | null = null): FakeRecorder { const recorder = { - uri: null as string | null, + uri, prepareToRecordAsync: vi.fn(async (): Promise => undefined), record: vi.fn((): void => { - recorder.uri = 'file:///recordings/recording.m4a'; + // A real recorder exposes its file URI only once it is recording. + recorder.uri ??= 'file:///recordings/recording.m4a'; }), stop: vi.fn(async (): Promise => undefined), release: vi.fn((): void => undefined), @@ -109,6 +110,14 @@ function buildEngine(overrides: Partial = {}): { return { engine, events, recorder, upload, deleteRecording }; } +/** Drain the engine's queued microtasks without advancing the fake clock. */ +async function settle(): Promise { + for (let i = 0; i < 6; i += 1) { + // eslint-disable-next-line no-await-in-loop -- each pass lets one chained continuation land + await vi.advanceTimersByTimeAsync(0); + } +} + async function startAndStop( engine: ReturnType ): Promise { @@ -229,6 +238,23 @@ describe('createGatewayVoiceInputEngine', () => { expect(upload).not.toHaveBeenCalled(); }); + it('terminalizes with a client error when the model read rejects', async () => { + const { engine, events, upload, deleteRecording } = buildEngine({ + readModelId: async () => { + throw new Error('secure store unavailable'); + }, + }); + + await startAndStop(engine); + await flush(); + + expect(events.map(entry => entry.event)).toEqual(['start', 'transcribing', 'error', 'end']); + const errorPayload = events[2]?.payload as VoiceInputNativeEvent['error']; + expect(errorPayload.error).toBe('client'); + expect(upload).not.toHaveBeenCalled(); + expect(deleteRecording).toHaveBeenCalledWith('file:///recordings/recording.m4a'); + }); + it('emits client error and end when recording prep fails', async () => { const { engine, events } = buildEngine({ setAudioMode: async () => { @@ -450,3 +476,256 @@ describe('recording file cleanup', () => { expect(deleteRecording).toHaveBeenCalledWith('file:///recordings/recording.m4a'); }); }); + +describe('progressive segment rotation', () => { + it('emits a final result while listening when a segment elapses, then stop ends the session', async () => { + vi.useFakeTimers(); + try { + const { engine, events } = buildEngine({ segmentDurationMs: 40 }); + + engine.start(START_OPTIONS); + await settle(); + expect(events.map(entry => entry.event)).toEqual(['start']); + + await vi.advanceTimersByTimeAsync(40); + await settle(); + + // The segment transcribed before the user stopped; the status is still + // `listening`, so `transcribing` has not fired yet. + expect(events.map(entry => entry.event)).toEqual(['start', 'result']); + const result = events[1]?.payload as VoiceInputNativeEvent['result']; + expect(result.isFinal).toBe(true); + expect(result.results[0]?.transcript).toBe('hello world'); + + engine.stop(); + await settle(); + + expect(events.map(entry => entry.event)).toEqual([ + 'start', + 'result', + 'transcribing', + 'result', + 'end', + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('stop finalizes the in-flight segment and drains queued uploads in order', async () => { + vi.useFakeTimers(); + try { + let sequence = 0; + const pending: { uri: string; resolve: (result: TranscribeRecordingResult) => void }[] = []; + const { engine, events } = buildEngine({ + createRecorder: () => { + sequence += 1; + return makeRecorder(`file:///recordings/segment-${sequence}.m4a`); + }, + segmentDurationMs: 30, + upload: async input => + new Promise(resolve => { + pending.push({ uri: input.recordingUri, resolve }); + }), + }); + + engine.start(START_OPTIONS); + await settle(); + await vi.advanceTimersByTimeAsync(30); + await settle(); + await vi.advanceTimersByTimeAsync(30); + await settle(); + // Two rotations queued segment 1 and 2; stop captures the in-flight 3. + engine.stop(); + await settle(); + + // Uploads are serialized: only the first starts until it resolves. + expect(pending.map(entry => entry.uri)).toEqual(['file:///recordings/segment-1.m4a']); + + pending[0]?.resolve({ ok: true, text: 'one' }); + await settle(); + expect(pending.map(entry => entry.uri)).toEqual([ + 'file:///recordings/segment-1.m4a', + 'file:///recordings/segment-2.m4a', + ]); + + pending[1]?.resolve({ ok: true, text: 'two' }); + await settle(); + // The segment captured by stop() drains last. + expect(pending.map(entry => entry.uri)).toEqual([ + 'file:///recordings/segment-1.m4a', + 'file:///recordings/segment-2.m4a', + 'file:///recordings/segment-3.m4a', + ]); + + pending[2]?.resolve({ ok: true, text: 'three' }); + await settle(); + + const transcripts = events + .filter(entry => entry.event === 'result') + .map(entry => (entry.payload as VoiceInputNativeEvent['result']).results[0]?.transcript); + expect(transcripts).toEqual(['one', 'two', 'three']); + expect(events.map(entry => entry.event)).toEqual([ + 'start', + 'transcribing', + 'result', + 'result', + 'result', + 'end', + ]); + } finally { + vi.useRealTimers(); + } + }); + + it.each(['', ' '])('emits no-speech for empty segments (%j) and allows retry', async text => { + vi.useFakeTimers(); + try { + const { engine, events, upload } = buildEngine({ + segmentDurationMs: 30, + }); + upload.mockResolvedValue({ ok: true, text }); + + engine.start(START_OPTIONS); + await settle(); + await vi.advanceTimersByTimeAsync(30); + await settle(); + await vi.advanceTimersByTimeAsync(30); + await settle(); + // Empty segments are skipped silently while listening. + expect(events.map(entry => entry.event)).toEqual(['start']); + + engine.stop(); + await settle(); + + expect(events.map(entry => entry.event)).toEqual(['start', 'transcribing', 'error', 'end']); + const errorPayload = events[2]?.payload as VoiceInputNativeEvent['error']; + expect(errorPayload.error).toBe('no-speech'); + + // A silent session never emits a draft write. A new microphone tap can + // still transcribe normally using the same engine after the error ends. + upload.mockResolvedValue({ ok: true, text: 'retry words' }); + engine.start(START_OPTIONS); + await settle(); + engine.stop(); + await settle(); + expect(events.slice(4).map(entry => entry.event)).toEqual([ + 'start', + 'transcribing', + 'result', + 'end', + ]); + const resultPayload = events[6]?.payload as VoiceInputNativeEvent['result']; + expect(resultPayload.results[0]?.transcript).toBe('retry words'); + } finally { + vi.useRealTimers(); + } + }); + + it('a mid-session segment failure emits the gateway error and keeps the earlier result', async () => { + vi.useFakeTimers(); + try { + let calls = 0; + const { engine, events } = buildEngine({ + segmentDurationMs: 30, + upload: async (): Promise => { + calls += 1; + return calls === 1 + ? { ok: true, text: 'first words' } + : { ok: false, isTimeout: false, isNetworkError: true }; + }, + }); + + engine.start(START_OPTIONS); + await settle(); + await vi.advanceTimersByTimeAsync(30); + await settle(); + expect(events.map(entry => entry.event)).toEqual(['start', 'result']); + + await vi.advanceTimersByTimeAsync(30); + await settle(); + + expect(events.map(entry => entry.event)).toEqual(['start', 'result', 'error', 'end']); + const errorPayload = events[2]?.payload as VoiceInputNativeEvent['error']; + expect(errorPayload.error).toBe('gateway-unreachable'); + const resultPayload = events[1]?.payload as VoiceInputNativeEvent['result']; + expect(resultPayload.results[0]?.transcript).toBe('first words'); + } finally { + vi.useRealTimers(); + } + }); + + it('prepares the successor while the predecessor records, then hands off capture', async () => { + vi.useFakeTimers(); + try { + const trace: string[] = []; + const recorders: FakeRecorder[] = []; + const makeTracedRecorder = (label: string): FakeRecorder => ({ + uri: `file:///recordings/${label}.m4a`, + prepareToRecordAsync: vi.fn(async (): Promise => { + trace.push(`${label}:prepare`); + }), + record: vi.fn((): void => { + trace.push(`${label}:record`); + }), + stop: vi.fn(async (): Promise => { + trace.push(`${label}:stop`); + }), + release: vi.fn((): void => { + trace.push(`${label}:release`); + }), + }); + const { engine } = buildEngine({ + createRecorder: () => { + const label = `seg-${recorders.length + 1}`; + const recorder = makeTracedRecorder(label); + recorders.push(recorder); + return recorder; + }, + segmentDurationMs: 30, + }); + + engine.start(START_OPTIONS); + await settle(); + expect(trace).toEqual(['seg-1:prepare', 'seg-1:record']); + + await vi.advanceTimersByTimeAsync(30); + await settle(); + + // The microphone is never idle across the rotation: the successor is + // created and prepared while the predecessor still captures, and capture + // only moves in the stop -> record handoff. + expect(trace).toEqual([ + 'seg-1:prepare', + 'seg-1:record', + 'seg-2:prepare', + 'seg-1:stop', + 'seg-2:record', + 'seg-1:release', + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('abort clears the rotation timer so no further recorder is created', async () => { + vi.useFakeTimers(); + try { + const createRecorder = vi.fn(() => makeRecorder()); + const { engine, events } = buildEngine({ createRecorder, segmentDurationMs: 40 }); + + engine.start(START_OPTIONS); + await settle(); + expect(createRecorder).toHaveBeenCalledTimes(1); + + engine.abort(); + await vi.advanceTimersByTimeAsync(80); + await settle(); + + expect(events.map(entry => entry.event)).toEqual(['start', 'end']); + expect(createRecorder).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.ts b/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.ts index 13c759fd3d..8e7cc831ea 100644 --- a/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.ts +++ b/apps/mobile/src/lib/voice-input/gateway/gateway-voice-input-engine.ts @@ -1,4 +1,4 @@ -/* eslint-disable max-lines -- one session state machine: start, stop, upload, abort, and recording cleanup share the session lifecycle. */ +/* eslint-disable max-lines -- one session state machine: start, rotation, upload, stop, abort, and recording cleanup share the session lifecycle. */ import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; import { @@ -14,6 +14,14 @@ import { type TranscribeRecordingResult, } from './gateway-transcription-client'; +/** + * How long one recording segment lasts before the engine stops the current + * recorder and starts a fresh one. The gateway has no streaming endpoint, so + * "real time" is a sequence of short batch uploads whose final results the + * controller appends to the live draft while `status` stays `listening`. + */ +const GATEWAY_SEGMENT_DURATION_MS = 3000; + /** * The slice of an expo-audio recorder the engine drives. `AudioModule.AudioRecorder` * satisfies it structurally; tests fake it. `release()` detaches the native @@ -50,6 +58,12 @@ export type GatewayVoiceInputEngineDeps = { * property (not a method) so the engine can hold a detached reference. */ upload?: ((input: TranscribeRecordingInput) => Promise) | undefined; + /** + * Length of one recording segment before the engine rotates to a fresh + * recorder. Defaults to `GATEWAY_SEGMENT_DURATION_MS`; tests shorten it to + * drive rotations without waiting. + */ + segmentDurationMs?: number; }; type RecorderHandle = { @@ -57,19 +71,45 @@ type RecorderHandle = { recorder: GatewayRecorder; }; +/** Session-scoped reads, resolved once before the first segment upload. */ +type GatewayCredentials = { + model: { id: string; name: string }; + authToken: string; + organizationId: string | null; +}; + type GatewaySession = { /** Monotonic id; every async continuation checks it still owns the session. */ id: number; - /** BCP-47 hint carried from `start()` into the transcription upload. */ + /** BCP-47 hint carried from `start()` into every segment upload. */ languageTag: string; - /** The recorder we currently own, or null once `stop()` has taken it. */ + /** The recorder we currently own, or null once it has been detached. */ handle: RecorderHandle | null; /** Set when `stop()` arrives while the recorder is still preparing. */ stopRequested: boolean; /** Owns the in-flight upload; `abort()` cancels it. */ uploadController: AbortController | null; + /** Set by `stop()`/`abort()`; a stopped session never rotates again. */ + stopped: boolean; + /** True once the first recorder has started, i.e. `start` was emitted. */ + recordingStarted: boolean; + /** Pending rotation timer, or null when none is scheduled. */ + rotationTimer: ReturnType | null; + /** True while a rotation stops the old segment and starts the next. */ + rotationInFlight: boolean; + /** Guards `finalizeStop` so stop/rotation races emit `end` exactly once. */ + finalizing: boolean; + /** True once any segment produced non-empty text. */ + producedText: boolean; + /** Serializes segment uploads so their results apply in recording order. */ + uploadChain: Promise; + /** Session-scoped credentials, cached before the first upload. */ + credentials: GatewayCredentials | null; }; +/** Outcome of detaching a completed segment: its file URI, or a failed stop. */ +type CapturedSegment = { ok: true; uri: string | null } | { ok: false }; + type AnyListener = (event: VoiceInputNativeEvent[keyof VoiceInputNativeEvent]) => void; /** @@ -142,21 +182,40 @@ async function discardRecording( await releaseAndDeleteRecording(handle, deleteRecording); } +/** Cancel a session's pending segment rotation, if one is armed. */ +function clearRotationTimer(current: GatewaySession): void { + if (current.rotationTimer !== null) { + clearTimeout(current.rotationTimer); + current.rotationTimer = null; + } +} + +/** + * Read the live `stopped` flag. A concurrent `stop()` flips it across an + * await, which control-flow narrowing would otherwise hide. + */ +function isStopped(current: GatewaySession): boolean { + return current.stopped; +} + /** - * Gateway transcription engine: record with expo-audio, upload the file to - * the Kilo gateway, and emit the transcript as a single final result. It - * implements the same `VoiceInputNative` protocol as the OS binding so the - * controller cannot tell them apart. + * Gateway transcription engine: record with expo-audio in short segments, + * upload each finished segment to the Kilo gateway, and emit one final result + * per segment while the session is still listening. It implements the same + * `VoiceInputNative` protocol as the OS binding so the controller cannot tell + * them apart. * - * Event protocol per session: `start` → (`stop()`) `transcribing` → + * Event protocol per session: `start` → zero or more `result` (one per + * transcribed segment, still `listening`) → (`stop()`) `transcribing` → * `result`|`error` → `end`. `abort()` ends the session without a result. * Every failure path terminalizes with `error` + `end` so the controller's - * session never hangs; the recorder's native object is released on every - * path that stops owning it. + * session never hangs; the recorder's native object is released on every path + * that stops owning it, and each segment file is deleted after its upload. */ export function createGatewayVoiceInputEngine(deps: GatewayVoiceInputEngineDeps): VoiceInputNative { const upload = deps.upload ?? transcribeRecording; const { deleteRecording } = deps; + const segmentDurationMs = deps.segmentDurationMs ?? GATEWAY_SEGMENT_DURATION_MS; const listeners = new Map>(); let session: GatewaySession | null = null; let sessionSeq = 0; @@ -184,107 +243,147 @@ export function createGatewayVoiceInputEngine(deps: GatewayVoiceInputEngineDeps) emit('end', null); }; + /** + * Terminalize the session on a failure. The rotation timer is cleared and + * any recorder the session still owns is discarded: a failure can land + * between rotations, when a fresh segment is already recording. + */ const fail = (current: GatewaySession, code: string): void => { emit('error', { error: code, message: `gateway-voice-input: ${code}` }); + clearRotationTimer(current); + const handle = current.handle; + current.handle = null; + if (handle) { + void discardRecording(handle, deleteRecording); + } endSession(current); }; - const startPrep = async (current: GatewaySession): Promise => { + /** + * Stop a completed segment's recorder and hand back its file URI, freeing + * the native object but not the file: the upload still needs it. + */ + const detachSegment = async (handle: RecorderHandle): Promise => { try { - await deps.setAudioMode({ allowsRecording: true }); - if (stale(current)) { - return; - } - const recorder = deps.createRecorder(); - const handle: RecorderHandle = { recorder, released: false }; - try { - await recorder.prepareToRecordAsync(); - } catch { - await releaseAndDeleteRecording(handle, deleteRecording); - if (!stale(current)) { - fail(current, 'client'); - } - return; - } - if (stale(current)) { - await releaseAndDeleteRecording(handle, deleteRecording); - return; - } - // Only hand the recorder to `stop()`/`abort()` once it can actually - // record; while preparing, `stop()` sets `stopRequested` instead. - current.handle = handle; - recorder.record(); - emit('start', null); - if (current.stopRequested) { - // `stop()` arrived while we were still preparing: run the upload - // path now so the session still terminalizes. - emit('transcribing', null); - current.handle = null; - void finishSession(current, handle); - } + await handle.recorder.stop(); } catch { - if (stale(current)) { - return; - } - if (current.handle) { - const handle = current.handle; - current.handle = null; - await releaseAndDeleteRecording(handle, deleteRecording); - } - fail(current, 'client'); + await releaseAndDeleteRecording(handle, deleteRecording); + return { ok: false }; + } + let uri: string | null = null; + try { + uri = handle.recorder.uri; + } catch { + // A recorder that refuses a URI read has no file to delete. + } + releaseRecorder(handle); + return { ok: true, uri }; + }; + + /** + * Finish the outgoing segment and move capture to the successor in one + * handoff. The successor was already prepared while the outgoing recorder + * was still capturing, so only the native stop separates the two captures: + * the recorder is exclusive, so the successor must not start before the + * outgoing recorder stops, but it starts immediately after it does. The + * outgoing file URI is read, and its recorder released, once the successor + * is live. + */ + const stopAndHandOff = async ( + previous: RecorderHandle, + next: RecorderHandle + ): Promise => { + try { + await previous.recorder.stop(); + } catch { + await releaseAndDeleteRecording(previous, deleteRecording); + return { ok: false }; + } + try { + next.recorder.record(); + } catch { + await releaseAndDeleteRecording(previous, deleteRecording); + return { ok: false }; + } + let uri: string | null = null; + try { + uri = previous.recorder.uri; + } catch { + // A recorder that refuses a URI read has no file to delete. } + releaseRecorder(previous); + return { ok: true, uri }; }; - const finishSession = async (current: GatewaySession, handle: RecorderHandle): Promise => { - const { recorder } = handle; + /** + * Begin capture on a prepared recorder. A native refusal terminalizes the + * session; the caller still owns the handle and must release it. + */ + const startRecorder = (current: GatewaySession, handle: RecorderHandle): boolean => { try { - await recorder.stop(); + handle.recorder.record(); + return true; } catch { - await releaseAndDeleteRecording(handle, deleteRecording); if (!stale(current)) { fail(current, 'client'); } - return; - } - const uri = recorder.uri; - releaseRecorder(handle); - if (stale(current)) { - // A newer session owns the controller; delete the file without emitting. - await deleteRecordingFile(deleteRecording, uri); - return; - } - if (uri === null || uri === '') { - fail(current, 'client'); - return; + return false; } + }; + + /** + * Transcribe one finished segment and emit its final result. Empty or + * no-speech segments are skipped silently; a real failure terminalizes the + * session with the classified gateway code. The file is deleted on every + * path, including an aborted upload. + */ + const uploadSegment = async (current: GatewaySession, uri: string): Promise => { try { - const model = await deps.readModelId(); if (stale(current)) { return; } - if (model === null) { - fail(current, 'gateway-no-model'); - return; - } - let authToken: string | null = null; - let organizationId: string | null = null; - try { - authToken = await deps.readAuthToken(); - organizationId = await deps.readOrganizationId(); - } catch { - if (!stale(current)) { - fail(current, 'client'); + let credentials = current.credentials; + if (credentials === null) { + let model: { id: string; name: string } | null = null; + try { + model = await deps.readModelId(); + } catch { + // A rejected model read must not poison the upload chain: the + // session terminalizes like any other client failure. + if (!stale(current)) { + fail(current, 'client'); + } + return; } - return; - } - if (stale(current)) { - return; - } - if (authToken === null || authToken === '') { - // Without a token the gateway will answer 401; tell the user to sign in - // instead of burning an upload round-trip. - fail(current, 'gateway-auth'); - return; + if (stale(current)) { + return; + } + if (model === null) { + fail(current, 'gateway-no-model'); + return; + } + let authToken: string | null = null; + let organizationId: string | null = null; + try { + authToken = await deps.readAuthToken(); + organizationId = await deps.readOrganizationId(); + } catch { + if (!stale(current)) { + fail(current, 'client'); + } + return; + } + if (stale(current)) { + return; + } + if (authToken === null || authToken === '') { + // Without a token the gateway will answer 401; tell the user to sign + // in instead of burning an upload round-trip. + fail(current, 'gateway-auth'); + return; + } + credentials = { authToken, model, organizationId }; + current.credentials = credentials; } const controller = new AbortController(); current.uploadController = controller; @@ -292,10 +391,10 @@ export function createGatewayVoiceInputEngine(deps: GatewayVoiceInputEngineDeps) try { result = await upload({ recordingUri: uri, - model, + model: credentials.model, language: current.languageTag, - organizationId, - authToken, + organizationId: credentials.organizationId, + authToken: credentials.authToken, signal: controller.signal, }); } catch { @@ -305,25 +404,21 @@ export function createGatewayVoiceInputEngine(deps: GatewayVoiceInputEngineDeps) fail(current, 'client'); return; } - if (controller.signal.aborted) { - // `abort()` cancelled the upload and already emitted `end`. - return; - } - if (stale(current)) { + if (controller.signal.aborted || stale(current)) { return; } const classification = classifyTranscriptionFailure(result); - if (classification === 'success' && result.ok) { + if (classification === 'success' && result.ok && result.text.trim() !== '') { + current.producedText = true; emit('result', { isFinal: true, results: [{ transcript: result.text, confidence: 1, segments: [] }], }); - endSession(current); return; } if (classification === 'no-speech') { - // Reuse the OS recognizer's empty-recording copy: same user-facing state. - fail(current, 'no-speech'); + // An empty segment is skipped silently; `stop()` reports no-speech + // only when the whole session produced nothing. return; } // 'unreachable' | 'timeout' | 'model-unavailable' | 'auth' | 'server' | @@ -332,11 +427,214 @@ export function createGatewayVoiceInputEngine(deps: GatewayVoiceInputEngineDeps) // 'gateway-invalid-response' — the codes voice-input-state classifies. fail(current, `gateway-${classification}`); } finally { - // The upload no longer needs the file; delete it on every terminal path. + // The upload no longer needs the file; delete it on every path. await deleteRecordingFile(deleteRecording, uri); } }; + /** + * Queue one segment upload behind the previous ones so their results land + * in recording order even when a later request finishes first. + */ + const enqueueSegmentUpload = (current: GatewaySession, uri: string): void => { + const previous = current.uploadChain; + current.uploadChain = (async () => { + await previous; + await uploadSegment(current, uri); + })(); + }; + + /** + * Create and prepare a fresh recorder, leaving it ready but not yet + * recording. Returns null when the session was taken over while preparing or + * when preparation failed (which already terminalized the session); the + * caller owns the returned handle and starts capture with `startRecorder` + * when it becomes the session's recorder. + */ + const prepareRecorder = async (current: GatewaySession): Promise => { + let handle: RecorderHandle | null = null; + try { + await deps.setAudioMode({ allowsRecording: true }); + if (stale(current)) { + return null; + } + const recorder = deps.createRecorder(); + handle = { recorder, released: false }; + try { + await recorder.prepareToRecordAsync(); + } catch { + await releaseAndDeleteRecording(handle, deleteRecording); + handle = null; + if (!stale(current)) { + fail(current, 'client'); + } + return null; + } + if (stale(current)) { + await releaseAndDeleteRecording(handle, deleteRecording); + handle = null; + return null; + } + return handle; + } catch { + if (handle) { + await releaseAndDeleteRecording(handle, deleteRecording); + } + if (!stale(current)) { + fail(current, 'client'); + } + return null; + } + }; + + /** + * End a stopped session once every queued segment upload has landed. Drains + * the whole chain before deciding no-speech, so the last segment's result is + * counted. Idempotent: only the first caller emits `end`. + */ + const finalizeStop = async (current: GatewaySession): Promise => { + if (current.finalizing) { + return; + } + current.finalizing = true; + clearRotationTimer(current); + try { + await current.uploadChain; + if (stale(current)) { + return; + } + if (!current.producedText) { + // Reuse the OS recognizer's empty-recording copy: same user-facing state. + fail(current, 'no-speech'); + return; + } + endSession(current); + } finally { + current.finalizing = false; + } + }; + + /** + * Stop the segment captured by `stop()`/`startPrep`, upload it, then + * finalize. Used on the paths that already emitted `transcribing`. + */ + const completeSegmentAndFinalize = async ( + current: GatewaySession, + handle: RecorderHandle + ): Promise => { + const captured = await detachSegment(handle); + if (!captured.ok || captured.uri === null || captured.uri === '') { + if (!stale(current)) { + fail(current, 'client'); + } + return; + } + enqueueSegmentUpload(current, captured.uri); + await finalizeStop(current); + }; + + /** + * One segment elapsed: prepare a successor while the finished segment is + * still recording, then stop the finished segment and start the successor in + * one handoff — the microphone is never idle for the successor's creation, + * preparation or audio-mode setup. A `stop()` that raced this rotation owns + * the terminal signals, so the continuation emits `transcribing` and + * finalizes on its behalf. + */ + const rotateSegment = async (current: GatewaySession): Promise => { + clearRotationTimer(current); + if (stale(current) || current.stopped || current.finalizing) { + return; + } + current.rotationInFlight = true; + try { + const next = await prepareRecorder(current); + if (!next) { + // `prepareRecorder` terminalized the session, or it was taken over. + return; + } + if (stale(current)) { + await releaseAndDeleteRecording(next, deleteRecording); + return; + } + const previous = current.handle; + current.handle = null; + if (previous) { + const captured = await stopAndHandOff(previous, next); + if (!captured.ok || captured.uri === null || captured.uri === '') { + await releaseAndDeleteRecording(next, deleteRecording); + if (!stale(current)) { + fail(current, 'client'); + } + return; + } + enqueueSegmentUpload(current, captured.uri); + } else if (!startRecorder(current, next)) { + await releaseAndDeleteRecording(next, deleteRecording); + return; + } + if (stale(current)) { + // The session was aborted or replaced mid-rotation; its recorder is + // the successor, which abort could not see while it was being handed + // off, so free it here. + current.handle = null; + await releaseAndDeleteRecording(next, deleteRecording); + return; + } + current.handle = next; + if (isStopped(current)) { + // `stop()` arrived while rotating; it left the terminal signals here. + emit('transcribing', null); + current.handle = null; + void completeSegmentAndFinalize(current, next); + return; + } + scheduleRotation(current); + } finally { + current.rotationInFlight = false; + } + }; + + /** Arm the next segment rotation unless the session is stopping or gone. */ + const scheduleRotation = (current: GatewaySession): void => { + if (stale(current) || current.stopped || current.finalizing) { + return; + } + current.rotationTimer = setTimeout(() => { + current.rotationTimer = null; + void rotateSegment(current); + }, segmentDurationMs); + }; + + const startPrep = async (current: GatewaySession): Promise => { + const handle = await prepareRecorder(current); + if (!handle) { + return; + } + if (stale(current)) { + await releaseAndDeleteRecording(handle, deleteRecording); + return; + } + if (!startRecorder(current, handle)) { + await releaseAndDeleteRecording(handle, deleteRecording); + return; + } + // Only hand the recorder to `stop()`/`abort()` once it can actually + // record; while preparing, `stop()` sets `stopRequested` instead. + current.handle = handle; + emit('start', null); + current.recordingStarted = true; + if (current.stopped) { + // `stop()` arrived while we were still preparing: emit transcribing + // after start, then run the upload path so the session terminalizes. + emit('transcribing', null); + current.handle = null; + void completeSegmentAndFinalize(current, handle); + return; + } + scheduleRotation(current); + }; + return { addListener(event, listener) { const boxed = listener as AnyListener; @@ -364,18 +662,43 @@ export function createGatewayVoiceInputEngine(deps: GatewayVoiceInputEngineDeps) // of the setting, and it is what makes the mic button appear on devices // whose OS recognizer is missing. isRecognitionAvailable: () => true, - // One recording, one upload: no continuous mode, nothing on-device. + // The gateway `start()` ignores the continuous flag; real time comes from + // the engine's segment rotation, not from the OS recognizer. supportsContinuousRecognition: () => false, supportsOnDevice: () => false, start: (options: VoiceInputNativeStartOptions): void => { + // The controller serializes ownership (it aborts the previous session + // before starting the next), but a direct caller may not. Drop any + // still-live session so its rotation timer and recorder cannot outlive + // the new session; the replacement is what makes the old one `stale`. + const previous = session; + if (previous) { + previous.stopped = true; + clearRotationTimer(previous); + previous.uploadController?.abort(); + const previousHandle = previous.handle; + previous.handle = null; + if (previousHandle) { + void discardRecording(previousHandle, deleteRecording); + } + } // Any previous session's upload (if still in flight) owns its own abort // controller and cannot emit into this one. sessionSeq += 1; const current: GatewaySession = { + credentials: null, + finalizing: false, handle: null, id: sessionSeq, languageTag: options.lang, + producedText: false, + recordingStarted: false, + rotationInFlight: false, + rotationTimer: null, + stopped: false, stopRequested: false, + // eslint-disable-next-line prefer-await-to-then -- Promise.resolve() is the empty-chain sentinel; there is no async context to await in + uploadChain: Promise.resolve(), uploadController: null, }; session = current; @@ -386,7 +709,15 @@ export function createGatewayVoiceInputEngine(deps: GatewayVoiceInputEngineDeps) if (!current) { return; } - if (!current.handle) { + current.stopped = true; + clearRotationTimer(current); + if (current.rotationInFlight) { + // A rotation is mid-flight; its continuation emits `transcribing` and + // finalizes once it has captured the in-flight segment. + return; + } + const handle = current.handle; + if (!handle) { // Still preparing; `startPrep` runs the upload path when it lands. current.stopRequested = true; return; @@ -394,9 +725,8 @@ export function createGatewayVoiceInputEngine(deps: GatewayVoiceInputEngineDeps) // Synchronous first signal: the UI flips to "Transcribing…" before the // recorder stop / upload awaits begin. emit('transcribing', null); - const handle = current.handle; current.handle = null; - void finishSession(current, handle); + void completeSegmentAndFinalize(current, handle); }, abort: (): void => { const current = session; @@ -404,13 +734,15 @@ export function createGatewayVoiceInputEngine(deps: GatewayVoiceInputEngineDeps) if (!current) { return; } - current.stopRequested = true; + current.stopped = true; + clearRotationTimer(current); current.uploadController?.abort(); const handle = current.handle; current.handle = null; if (handle) { // Discard the recording: end capture, free the native object, delete - // the file. + // the file. Late segment uploads check `stale`, delete their file, + // and emit nothing. void discardRecording(handle, deleteRecording); } emit('end', null); diff --git a/apps/mobile/src/lib/voice-input/gateway/native-gateway-voice-input.test.ts b/apps/mobile/src/lib/voice-input/gateway/native-gateway-voice-input.test.ts index 4b0af1597c..153d3ae574 100644 --- a/apps/mobile/src/lib/voice-input/gateway/native-gateway-voice-input.test.ts +++ b/apps/mobile/src/lib/voice-input/gateway/native-gateway-voice-input.test.ts @@ -2,7 +2,7 @@ /* eslint-disable require-await, @typescript-eslint/require-await -- the binding's fakes resolve immediately, so they settle without await */ import { setAudioModeAsync } from 'expo-audio'; import * as SecureStore from 'expo-secure-store'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { gatewayVoiceInputNative, @@ -155,6 +155,13 @@ describe('gatewayVoiceInputNative recorder construction', () => { platformMock.OS = 'ios'; }); + afterEach(() => { + // These cases only start a session. The engine keeps a segment rotation + // armed until the session ends, so end it here; otherwise a rotation timer + // outlives the test. + gatewayVoiceInputNative.abort(); + }); + it('pairs allowsRecording with playsInSilentMode so expo-audio iOS accepts the recording mode', async () => { // expo-audio's native iOS validation throws InvalidAudioModeException // when allowsRecording is set while the stored playsInSilentMode is diff --git a/apps/mobile/src/lib/voice-input/use-voice-input-actions.ts b/apps/mobile/src/lib/voice-input/use-voice-input-actions.ts index 3ce7b36992..435e2f712e 100644 --- a/apps/mobile/src/lib/voice-input/use-voice-input-actions.ts +++ b/apps/mobile/src/lib/voice-input/use-voice-input-actions.ts @@ -17,7 +17,7 @@ import { import { invalidateVoiceRecognitionLocalesCache, isVoiceInputLanguageInstalledOnDevice, - resolveVoiceInputStartLanguageTag, + resolveVoiceInputSessionLanguageTag, voiceInputLanguageDisplayName, } from './voice-input-language'; import { @@ -50,6 +50,7 @@ type VoiceInputActionsConfig = { controller: VoiceInputControllerLike; getDisabled: () => boolean; getDraft: () => string; + getLanguageTag?: () => string | null; getOnDraftChange: () => (draft: string) => void; getOwner: () => string; getUserId: () => string | undefined; @@ -87,7 +88,39 @@ export function runVoiceInputListeningFeedback( */ const VOICE_INPUT_TOAST_ID = 'voice-input-feedback'; +/** + * Last voice-input feedback, mirrored for surfaces that render it inline. + * sonner-native toasts are drawn outside the accessibility hierarchy, so a + * toast-only error is invisible to assistive tech (and to the on-device + * hierarchy digest the e2 scenario reads). `showFeedback` publishes here as + * well as showing the toast, and a voice surface renders the message inline + * via `AccessibleStatus` — a real Text node with a live region. + */ +let currentFeedback: VoiceInputFeedback | null = null; +const feedbackListeners = new Set<(feedback: VoiceInputFeedback | null) => void>(); + +export function publishVoiceInputFeedback(feedback: VoiceInputFeedback | null): void { + currentFeedback = feedback; + for (const listener of feedbackListeners) { + listener(feedback); + } +} + +export function readVoiceInputFeedback(): VoiceInputFeedback | null { + return currentFeedback; +} + +export function subscribeVoiceInputFeedback( + listener: (feedback: VoiceInputFeedback | null) => void +): () => void { + feedbackListeners.add(listener); + return () => { + feedbackListeners.delete(listener); + }; +} + export function showFeedback(feedback: VoiceInputFeedback): void { + publishVoiceInputFeedback(feedback); const presentation = resolveVoiceInputFeedbackPresentation(feedback); if (presentation.kind === 'alert') { // The alert is the message now; clear the toast channel with it. @@ -125,7 +158,15 @@ export function shouldAbortVoiceInputForOwner( } export function createVoiceInputActions(config: VoiceInputActionsConfig): VoiceInputActions { - const { controller, getDisabled, getDraft, getOnDraftChange, getOwner, getUserId } = config; + const { + controller, + getDisabled, + getDraft, + getLanguageTag, + getOnDraftChange, + getOwner, + getUserId, + } = config; const abort = async (): Promise => { const result = await controller.abort(getOwner()); @@ -162,7 +203,16 @@ export function createVoiceInputActions(config: VoiceInputActionsConfig): VoiceI return; } - const languageTag = await resolveVoiceInputStartLanguageTag(i18n.language); + const gatewayMode = isGatewayTranscriptionEnabled(); + // A persisted tag is reconciled against the active mode's list before it + // starts: a tag saved in the other mode (gateway `zh-Hans` in device mode) + // matches no option and would fail with `language-not-supported`. + const storedTag = getLanguageTag?.() ?? null; + const languageTag = await resolveVoiceInputSessionLanguageTag( + storedTag, + gatewayMode ? 'gateway' : 'device', + i18n.language + ); const startWith = async (requiresOnDeviceRecognition: boolean): Promise => { const startOptions: VoiceInputStartOptions = { @@ -176,7 +226,7 @@ export function createVoiceInputActions(config: VoiceInputActionsConfig): VoiceI await controller.start(startOptions); }; - if (isGatewayTranscriptionEnabled()) { + if (gatewayMode) { // Gateway mode: the switch itself is the consent to send the recording // to the Kilo gateway, so no OS network-recognition disclosure applies. // The chosen model is resolved by the engine (the stored choice, else diff --git a/apps/mobile/src/lib/voice-input/use-voice-input.test.ts b/apps/mobile/src/lib/voice-input/use-voice-input.test.ts index fb855f0dd6..88e8e46dc7 100644 --- a/apps/mobile/src/lib/voice-input/use-voice-input.test.ts +++ b/apps/mobile/src/lib/voice-input/use-voice-input.test.ts @@ -156,18 +156,26 @@ type ActionHarness = { }; function buildActions( - overrides: { disabled?: boolean; draft?: string; owner?: string; userId?: string } = {} + overrides: { + disabled?: boolean; + draft?: string; + languageTag?: string | null; + owner?: string; + userId?: string; + } = {} ): ActionHarness { const owner = overrides.owner ?? 'owner-A'; const draft = vi.fn<() => string>(() => overrides.draft ?? 'draft text'); const onDraftChange = vi.fn<(nextDraft: string) => void>(); const disabled = vi.fn<() => boolean>(() => overrides.disabled ?? false); const userId = vi.fn<() => string | undefined>(() => overrides.userId); + const languageTag = overrides.languageTag; const actions = createVoiceInputActions({ controller: mockController, getDisabled: disabled, getDraft: draft, + getLanguageTag: languageTag === undefined ? undefined : () => languageTag, getOnDraftChange: () => onDraftChange, getOwner: () => owner, getUserId: userId, @@ -254,6 +262,65 @@ describe('useVoiceInput integration', () => { expect(startOptions.onFeedback).toBe(showFeedback); }); + it('uses the persisted language choice instead of resolving the app language', async () => { + const { actions } = buildActions({ languageTag: 'nl-NL', userId: 'user-1' }); + mockController.setSnapshot(idleSnapshot()); + mockController.supportsOnDevice.mockReturnValue(true); + voiceNetworkConsentMock.readVoiceNetworkConsent.mockResolvedValue('granted'); + + await actions.toggle(); + + expect(mockController.start).toHaveBeenCalledTimes(1); + const startOptions = mockController.start.mock.calls[0]?.[0]; + if (!startOptions) { + throw new Error('controller.start was not called'); + } + expect(startOptions.languageTag).toBe('nl-NL'); + }); + + it('reconciles a gateway language tag onto the device locale before starting', async () => { + const { actions } = buildActions({ languageTag: 'zh-Hans', userId: 'user-1' }); + mockController.setSnapshot(idleSnapshot()); + mockController.supportsOnDevice.mockReturnValue(true); + voiceNetworkConsentMock.readVoiceNetworkConsent.mockResolvedValue('granted'); + getSupportedLocalesMock.mockResolvedValue({ + locales: ['zh-CN', 'en-US'], + installedLocales: ['zh-CN'], + }); + + await actions.toggle(); + + expect(mockController.start).toHaveBeenCalledTimes(1); + expect(mockController.start.mock.calls[0]?.[0]?.languageTag).toBe('zh-CN'); + }); + + it('reconciles a device locale onto the app language in gateway mode without a device fetch', async () => { + const { actions } = buildActions({ languageTag: 'de-DE', userId: 'user-1' }); + mockController.setSnapshot(idleSnapshot()); + gatewayPreferenceMock.isGatewayTranscriptionEnabled.mockReturnValue(true); + + await actions.toggle(); + + expect(mockController.start).toHaveBeenCalledTimes(1); + expect(mockController.start.mock.calls[0]?.[0]?.languageTag).toBe('de'); + expect(getSupportedLocalesMock).not.toHaveBeenCalled(); + }); + + it('resolves the app/device language when no language choice is persisted', async () => { + const { actions } = buildActions({ languageTag: null }); + mockController.setSnapshot(idleSnapshot()); + localizationMock.getLocales.mockReturnValue([{ languageTag: 'en-US' }]); + + await actions.toggle(); + + expect(mockController.start).toHaveBeenCalledTimes(1); + const startOptions = mockController.start.mock.calls[0]?.[0]; + if (!startOptions) { + throw new Error('controller.start was not called'); + } + expect(startOptions.languageTag).toBe('en-US'); + }); + it('resolves an en-DE device locale to en-US when the supported list contains en-AU and en-US', async () => { const { actions } = buildActions(); mockController.setSnapshot(idleSnapshot()); diff --git a/apps/mobile/src/lib/voice-input/use-voice-input.ts b/apps/mobile/src/lib/voice-input/use-voice-input.ts index 79428ef216..63dae0066d 100644 --- a/apps/mobile/src/lib/voice-input/use-voice-input.ts +++ b/apps/mobile/src/lib/voice-input/use-voice-input.ts @@ -3,13 +3,17 @@ import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { type VoiceInputControllerSnapshot } from './voice-input-controller'; +import { useVoiceInputLanguage } from './voice-input-language-preference'; import { voiceInputController } from './native-voice-input'; -import { type VoiceInputStatus } from './voice-input-state'; +import { type VoiceInputFeedback, type VoiceInputStatus } from './voice-input-state'; import { resolveOwnerVoiceInputView } from './voice-input-view-state'; import { createVoiceInputActions, + publishVoiceInputFeedback, + readVoiceInputFeedback, runVoiceInputListeningFeedback, shouldAbortVoiceInputForOwner, + subscribeVoiceInputFeedback, type VoiceInputActions, } from './use-voice-input-actions'; @@ -22,6 +26,8 @@ type UseVoiceInputOptions = { type UseVoiceInputResult = { abort: () => Promise; available: boolean; + /** Last failure feedback, mirrored from `showFeedback`; null between sessions. */ + feedback: VoiceInputFeedback | null; isActive: boolean; settleBeforeSubmit: () => Promise; status: VoiceInputStatus; @@ -74,13 +80,26 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul const getUserIdRef = useRef(userId); getUserIdRef.current = userId; + // Read through a ref so an action created once in `actionsRef` always sees + // the latest persisted language choice. + const voiceLanguage = useVoiceInputLanguage(); + const voiceLanguageRef = useRef(voiceLanguage); + voiceLanguageRef.current = voiceLanguage; + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const feedback = useSyncExternalStore( + subscribeVoiceInputFeedback, + readVoiceInputFeedback, + readVoiceInputFeedback + ); + const actionsRef = useRef(null); actionsRef.current ??= createVoiceInputActions({ controller: voiceInputController, getDisabled: () => getDisabledRef.current, getDraft: () => getDraftRef.current(), + getLanguageTag: () => voiceLanguageRef.current, getOnDraftChange: () => getOnDraftChangeRef.current, getOwner: () => owner, getUserId: () => getUserIdRef.current, @@ -139,12 +158,16 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul }, [actions]); const toggle = useCallback(async () => { + // A new tap clears the previous failure so the surface never shows a stale + // message while the next session is starting. + publishVoiceInputFeedback(null); await actions.toggle(); }, [actions]); return { abort, available: view.available, + feedback, isActive: view.isActive, settleBeforeSubmit, status: view.status, diff --git a/apps/mobile/src/lib/voice-input/use-voice-recognition-languages.test.ts b/apps/mobile/src/lib/voice-input/use-voice-recognition-languages.test.ts new file mode 100644 index 0000000000..3cd023690b --- /dev/null +++ b/apps/mobile/src/lib/voice-input/use-voice-recognition-languages.test.ts @@ -0,0 +1,160 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React trees under vitest (same pattern as src/lib/agent-attachments/use-agent-attachment-upload.test.ts) */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { __resetVoiceInputLanguageTagCacheForTests } from './voice-input-language'; +import { useVoiceRecognitionLanguages } from './use-voice-recognition-languages'; + +// The hook delegates to `getVoiceRecognitionLocales`, which calls +// `ExpoSpeechRecognitionModule.getSupportedLocales`. Mocking the native module +// controls the fetch; mocking expo-localization keeps the import graph free of +// the native localization module this hook never uses. +const getSupportedLocalesMock = vi.hoisted(() => + vi.fn<() => Promise<{ locales: string[]; installedLocales: string[] }>>() +); + +vi.mock('expo-localization', () => ({ + getLocales: () => [{ languageTag: 'en-US' }], +})); + +vi.mock('expo-speech-recognition', () => ({ + ExpoSpeechRecognitionModule: { + getSupportedLocales: getSupportedLocalesMock, + }, +})); + +type HookApi = ReturnType; + +const hookRef: { current: HookApi | undefined } = { current: undefined }; + +function Harness() { + hookRef.current = useVoiceRecognitionLanguages(); + return null; +} + +function hookApi(): HookApi { + const current = hookRef.current; + if (!current) { + throw new Error('hook was not mounted'); + } + return current; +} + +async function mountHook(): Promise { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + await act(async () => { + ref.current = TestRenderer.create(createElement(Harness)); + await Promise.resolve(); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +async function settle(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('useVoiceRecognitionLanguages', () => { + beforeEach(() => { + __resetVoiceInputLanguageTagCacheForTests(); + getSupportedLocalesMock.mockReset(); + }); + + it('maps the supported locales and settles the loading state', async () => { + let resolveLocales: + | ((value: { locales: string[]; installedLocales: string[] }) => void) + | undefined = undefined; + const pending = new Promise<{ locales: string[]; installedLocales: string[] }>(resolve => { + resolveLocales = resolve; + }); + getSupportedLocalesMock.mockReturnValue(pending); + + const renderer = await mountHook(); + // The service call is still in flight, so the picker keeps its skeletons. + expect(hookApi().isLoading).toBe(true); + + await act(async () => { + resolveLocales?.({ locales: ['de-DE', 'en-US'], installedLocales: ['en-US'] }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(hookApi().languages).toEqual(['de-DE', 'en-US']); + expect(hookApi().isLoading).toBe(false); + expect(hookApi().isError).toBe(false); + + renderer.unmount(); + }); + + it('reports a null fetch result (service rejected) as the error state', async () => { + getSupportedLocalesMock.mockRejectedValue(new Error('network failure')); + + const renderer = await mountHook(); + await settle(); + + expect(hookApi().languages).toEqual([]); + expect(hookApi().isError).toBe(true); + expect(hookApi().isLoading).toBe(false); + + renderer.unmount(); + }); + + it('reports a synchronous throw as the error state', async () => { + getSupportedLocalesMock.mockImplementation(() => { + throw new Error('package not found'); + }); + + const renderer = await mountHook(); + await settle(); + + expect(hookApi().isError).toBe(true); + expect(hookApi().isLoading).toBe(false); + + renderer.unmount(); + }); + + it('keeps an empty supported list as a successful, non-error answer', async () => { + getSupportedLocalesMock.mockResolvedValue({ locales: [], installedLocales: [] }); + + const renderer = await mountHook(); + await settle(); + + expect(hookApi().languages).toEqual([]); + expect(hookApi().isError).toBe(false); + expect(hookApi().isLoading).toBe(false); + + renderer.unmount(); + }); + + it('refetch invalidates the cache and re-queries the service', async () => { + getSupportedLocalesMock.mockResolvedValue({ locales: ['de-DE'], installedLocales: [] }); + + const renderer = await mountHook(); + await settle(); + expect(hookApi().languages).toEqual(['de-DE']); + expect(getSupportedLocalesMock).toHaveBeenCalledTimes(1); + + // A plain re-query would replay the memoized first answer; refetch must + // drop the cache so the new list is the one observed. + getSupportedLocalesMock.mockResolvedValue({ locales: ['fr-FR'], installedLocales: [] }); + await act(async () => { + hookApi().refetch(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(hookApi().languages).toEqual(['fr-FR']); + expect(getSupportedLocalesMock).toHaveBeenCalledTimes(2); + + renderer.unmount(); + }); +}); diff --git a/apps/mobile/src/lib/voice-input/use-voice-recognition-languages.ts b/apps/mobile/src/lib/voice-input/use-voice-recognition-languages.ts new file mode 100644 index 0000000000..897facaf48 --- /dev/null +++ b/apps/mobile/src/lib/voice-input/use-voice-recognition-languages.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { + getVoiceRecognitionLocales, + invalidateVoiceRecognitionLocalesCache, +} from './voice-input-language'; + +export type VoiceRecognitionLanguages = { + /** Locale tags the device's speech recognition reports as supported. */ + languages: string[]; + isLoading: boolean; + /** The service call failed. Distinct from "the service reported no languages". */ + isError: boolean; + /** Drop the session cache and query the service again. */ + refetch: () => void; +}; + +/** + * The recognition service's supported locale list for the voice-language + * picker. `getVoiceRecognitionLocales` memoizes the fetch for the session, so + * reopening the sheet reuses it. A `null` result (the service call failed) is + * the retryable error state; an empty list is a successful answer the service + * gave, which no retry can change. `refetch` invalidates the cache first so a + * retry actually re-queries instead of replaying the memoized failure. + */ +export function useVoiceRecognitionLanguages(): VoiceRecognitionLanguages { + const [languages, setLanguages] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + const [reloadToken, setReloadToken] = useState(0); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + setIsError(false); + + const load = async () => { + try { + const result = await getVoiceRecognitionLocales(); + if (cancelled) { + return; + } + if (result === null) { + setIsError(true); + setLanguages([]); + return; + } + setLanguages([...result.locales]); + } catch { + if (!cancelled) { + setIsError(true); + setLanguages([]); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + void load(); + + return () => { + cancelled = true; + }; + }, [reloadToken]); + + const refetch = useCallback(() => { + invalidateVoiceRecognitionLocalesCache(); + setReloadToken(token => token + 1); + }, []); + + return { languages, isLoading, isError, refetch }; +} diff --git a/apps/mobile/src/lib/voice-input/voice-input-language-preference.test.ts b/apps/mobile/src/lib/voice-input/voice-input-language-preference.test.ts new file mode 100644 index 0000000000..2be97ab39e --- /dev/null +++ b/apps/mobile/src/lib/voice-input/voice-input-language-preference.test.ts @@ -0,0 +1,99 @@ +import type * as voiceInputLanguagePreference from './voice-input-language-preference'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const store = vi.hoisted(() => new Map()); +const { captureException, toastError } = vi.hoisted(() => ({ + captureException: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock('expo-secure-store', () => ({ + getItemAsync: vi.fn(async (key: string) => { + await Promise.resolve(); + return store.get(key) ?? null; + }), + setItemAsync: vi.fn(async (key: string, value: string) => { + await Promise.resolve(); + store.set(key, value); + }), + deleteItemAsync: vi.fn(async (key: string) => { + await Promise.resolve(); + store.delete(key); + }), +})); +vi.mock('@sentry/react-native', () => ({ captureException })); +vi.mock('sonner-native', () => ({ toast: { error: toastError } })); + +const LANGUAGE_KEY = 'voice-input-language'; + +// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule +function flushPreferences(): Promise { + // The module-scope preload reads SecureStore in the background; two + // macrotask rounds let the read, parse and emit settle before assertions. + return new Promise(resolve => { + setImmediate(() => { + setImmediate(resolve); + }); + }); +} + +/** + * Fresh module instance per import: the store is module-scoped and preload() + * runs at import time, so the disk contents must be arranged before the + * import for a test to observe them. + */ +// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule +function importPreferenceModule(): Promise { + return import('./voice-input-language-preference'); +} + +beforeEach(() => { + store.clear(); + vi.resetModules(); + captureException.mockReset(); + toastError.mockReset(); +}); + +describe('voice input language preference', () => { + it('reads null by default when nothing is persisted', async () => { + const mod = await importPreferenceModule(); + await flushPreferences(); + + expect(mod.readVoiceInputLanguage()).toBeNull(); + }); + + it('round-trips a chosen language through SecureStore across a module reload', async () => { + const mod = await importPreferenceModule(); + await flushPreferences(); + + mod.writeVoiceInputLanguage('nl-NL'); + expect(mod.readVoiceInputLanguage()).toBe('nl-NL'); + await flushPreferences(); + expect(store.get(LANGUAGE_KEY)).toBe('nl-NL'); + + // A fresh module (next launch) reads the persisted choice. + vi.resetModules(); + const reloaded = await importPreferenceModule(); + await flushPreferences(); + expect(reloaded.readVoiceInputLanguage()).toBe('nl-NL'); + }); + + it('persists null as an empty string and parses that empty string back to null', async () => { + const mod = await importPreferenceModule(); + await flushPreferences(); + + mod.writeVoiceInputLanguage('nl-NL'); + await flushPreferences(); + mod.writeVoiceInputLanguage(null); + await flushPreferences(); + + expect(mod.readVoiceInputLanguage()).toBeNull(); + expect(store.get(LANGUAGE_KEY)).toBe(''); + + // A stored empty string is the serialized form of "auto", not a language. + vi.resetModules(); + const reloaded = await importPreferenceModule(); + await flushPreferences(); + expect(reloaded.readVoiceInputLanguage()).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/voice-input/voice-input-language-preference.ts b/apps/mobile/src/lib/voice-input/voice-input-language-preference.ts new file mode 100644 index 0000000000..53ab28cd5d --- /dev/null +++ b/apps/mobile/src/lib/voice-input/voice-input-language-preference.ts @@ -0,0 +1,44 @@ +import { useSyncExternalStore } from 'react'; + +import { createSecureStorePreference } from '@/lib/hooks/secure-store-preference'; +import { VOICE_INPUT_LANGUAGE_KEY } from '@/lib/storage-keys'; + +/** + * The persisted voice-input language choice, a BCP-47 tag. `null` is the + * "auto" default: resolve the tag from the active app language and the + * device's locales at start. An empty persisted string is the serialized + * form of `null`, so it parses back to the default. + */ +const languageStore = createSecureStorePreference({ + key: VOICE_INPUT_LANGUAGE_KEY, + defaultValue: null, + parse: raw => (raw === null || raw.trim() === '' ? null : raw), + serialize: value => value ?? '', +}); + +// Warm the disk read at module scope so the settings row and the first toggle +// see the persisted choice without waiting for a React mount. +languageStore.preload(); + +export function readVoiceInputLanguage(): string | null { + return languageStore.get(); +} + +export function writeVoiceInputLanguage(tag: string | null): void { + languageStore.set(tag); +} + +/** Settings UI binding for the chosen voice-input language. */ +export function useVoiceInputLanguage(): string | null { + return useSyncExternalStore(languageStore.subscribe, languageStore.get); +} + +/** Whether the stored language choice has finished its SecureStore read. */ +export function useVoiceInputLanguageLoaded(): boolean { + return useSyncExternalStore(languageStore.subscribe, languageStore.getHasLoaded); +} + +/** Await the persisted language read. For callers with no React tree. */ +export async function whenVoiceInputLanguageLoaded(): Promise { + await languageStore.whenLoaded(); +} diff --git a/apps/mobile/src/lib/voice-input/voice-input-language-session.test.ts b/apps/mobile/src/lib/voice-input/voice-input-language-session.test.ts new file mode 100644 index 0000000000..07e00a5421 --- /dev/null +++ b/apps/mobile/src/lib/voice-input/voice-input-language-session.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SUPPORTED_LANGUAGES } from '@/i18n/languages'; + +import { + __resetVoiceInputLanguageTagCacheForTests, + reconcileVoiceInputLanguageTag, + resolveVoiceInputSessionLanguageTag, +} from './voice-input-language'; + +const localizationMock = vi.hoisted(() => ({ + getLocales: vi.fn<() => { languageTag: string }[]>(() => [{ languageTag: 'en-US' }]), +})); + +const getSupportedLocalesMock = vi.hoisted(() => + vi.fn<() => Promise<{ locales: string[]; installedLocales: string[] }>>().mockResolvedValue({ + locales: [], + installedLocales: [], + }) +); + +vi.mock('expo-localization', () => ({ + getLocales: localizationMock.getLocales, +})); + +vi.mock('expo-speech-recognition', () => ({ + ExpoSpeechRecognitionModule: { + getSupportedLocales: getSupportedLocalesMock, + }, +})); + +describe('reconcileVoiceInputLanguageTag', () => { + it('returns null for the Automatic choice', () => { + expect(reconcileVoiceInputLanguageTag(null, ['de-DE'])).toBeNull(); + }); + + it('keeps an exact match in the option list spelling', () => { + expect(reconcileVoiceInputLanguageTag('en_US', ['en-US', 'de-DE'])).toBe('en-US'); + }); + + it('maps a gateway app tag onto the device locale sharing its language (zh-Hans → zh-CN)', () => { + expect(reconcileVoiceInputLanguageTag('zh-Hans', ['zh-CN', 'de-DE'])).toBe('zh-CN'); + }); + + it('maps a device locale onto the gateway app language (de-DE → de)', () => { + expect(reconcileVoiceInputLanguageTag('de-DE', SUPPORTED_LANGUAGES)).toBe('de'); + }); + + it('returns null when no option shares the stored language', () => { + expect(reconcileVoiceInputLanguageTag('fil-PH', ['en-US', 'de-DE'])).toBeNull(); + }); +}); + +describe('resolveVoiceInputSessionLanguageTag', () => { + beforeEach(() => { + __resetVoiceInputLanguageTagCacheForTests(); + vi.clearAllMocks(); + }); + + it('starts in the stored gateway tag when it maps to an app language (de-DE → de)', async () => { + expect(await resolveVoiceInputSessionLanguageTag('de-DE', 'gateway', 'en')).toBe('de'); + }); + + it('does not fetch device locales for a gateway-mode resolve', async () => { + await resolveVoiceInputSessionLanguageTag('de-DE', 'gateway', 'en'); + + expect(getSupportedLocalesMock).not.toHaveBeenCalled(); + }); + + it('reconciles a gateway tag onto the device locale in device mode (zh-Hans → zh-CN)', async () => { + getSupportedLocalesMock.mockResolvedValue({ + locales: ['zh-CN', 'de-DE'], + installedLocales: [], + }); + + expect(await resolveVoiceInputSessionLanguageTag('zh-Hans', 'device', 'en')).toBe('zh-CN'); + }); + + it('falls back to the app/device resolution when the stored tag has no option in the mode', async () => { + localizationMock.getLocales.mockReturnValue([{ languageTag: 'de-DE' }]); + getSupportedLocalesMock.mockResolvedValue({ + locales: ['de-DE', 'en-US'], + installedLocales: [], + }); + + expect(await resolveVoiceInputSessionLanguageTag('fil-PH', 'device', 'de')).toBe('de-DE'); + }); + + it('resolves fresh for the Automatic choice', async () => { + localizationMock.getLocales.mockReturnValue([{ languageTag: 'nl-NL' }]); + getSupportedLocalesMock.mockResolvedValue({ + locales: ['en-US', 'nl-NL'], + installedLocales: [], + }); + + expect(await resolveVoiceInputSessionLanguageTag(null, 'device', 'nl')).toBe('nl-NL'); + }); + + it('keeps the stored tag when the device locale probe fails', async () => { + getSupportedLocalesMock.mockRejectedValueOnce(new Error('service unavailable')); + + expect(await resolveVoiceInputSessionLanguageTag('nl-NL', 'device', 'en')).toBe('nl-NL'); + }); + + it('keeps the stored tag when the service reports no locales', async () => { + getSupportedLocalesMock.mockResolvedValue({ locales: [], installedLocales: [] }); + + expect(await resolveVoiceInputSessionLanguageTag('nl-NL', 'device', 'en')).toBe('nl-NL'); + }); +}); diff --git a/apps/mobile/src/lib/voice-input/voice-input-language.test.ts b/apps/mobile/src/lib/voice-input/voice-input-language.test.ts index 22356744ae..1bdea7fa8a 100644 --- a/apps/mobile/src/lib/voice-input/voice-input-language.test.ts +++ b/apps/mobile/src/lib/voice-input/voice-input-language.test.ts @@ -2,10 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { __resetVoiceInputLanguageTagCacheForTests, + getVoiceRecognitionLocales, isVoiceInputLanguageInstalledOnDevice, pickSupportedVoiceInputLanguageTag, resolveVoiceInputStartLanguageTag, voiceInputLanguageDisplayName, + voiceInputLanguageEnglishName, } from './voice-input-language'; const localizationMock = vi.hoisted(() => ({ @@ -109,6 +111,23 @@ describe('pickSupportedVoiceInputLanguageTag', () => { it('keeps the old behavior for a non-Chinese tag', () => { expect(pickSupportedVoiceInputLanguageTag(['de-AT'], ['de-DE', 'de-CH'])).toBe('de-DE'); }); + + it.each([ + ['cmn-Hans-CN', 'zh-Hans'], + ['cmn-Hant-TW', 'zh-Hant'], + ])('maps the Android Mandarin tag %s onto the app script (p16)', (tag, expected) => { + // Android's speech service stores the explicit choice with the ISO 639-3 + // code `cmn`; the gateway list offers the app's `zh` scripts. + expect(pickSupportedVoiceInputLanguageTag([tag], ['zh-Hans', 'zh-Hant'])).toBe(expected); + }); + + it('keeps the service spelling when the device list names Mandarin cmn', () => { + // A gateway choice (`zh-Hans`) used in device mode must land on the + // service's own spelling so the recogniser accepts it. + expect(pickSupportedVoiceInputLanguageTag(['zh-Hans'], ['cmn-Hans-CN', 'cmn-Hant-TW'])).toBe( + 'cmn-Hans-CN' + ); + }); }); describe('resolveVoiceInputStartLanguageTag', () => { @@ -257,6 +276,59 @@ describe('isVoiceInputLanguageInstalledOnDevice', () => { }); }); +describe('getVoiceRecognitionLocales', () => { + beforeEach(() => { + __resetVoiceInputLanguageTagCacheForTests(); + vi.clearAllMocks(); + }); + + it('returns the fetched locale lists', async () => { + getSupportedLocalesMock.mockResolvedValue({ + locales: ['de-DE', 'en-US'], + installedLocales: ['en-US'], + }); + + expect(await getVoiceRecognitionLocales()).toEqual({ + locales: ['de-DE', 'en-US'], + installedLocales: ['en-US'], + }); + }); + + it('memoizes a success, so the second call does not re-query the service', async () => { + getSupportedLocalesMock.mockResolvedValue({ locales: ['de-DE'], installedLocales: [] }); + + const first = await getVoiceRecognitionLocales(); + const second = await getVoiceRecognitionLocales(); + + expect(second).toBe(first); + expect(getSupportedLocalesMock).toHaveBeenCalledTimes(1); + }); + + it('returns null when the service rejects, and retries on the next call', async () => { + getSupportedLocalesMock.mockRejectedValueOnce(new Error('network failure')); + + expect(await getVoiceRecognitionLocales()).toBeNull(); + + getSupportedLocalesMock.mockResolvedValueOnce({ + locales: ['de-DE'], + installedLocales: ['de-DE'], + }); + expect(await getVoiceRecognitionLocales()).toEqual({ + locales: ['de-DE'], + installedLocales: ['de-DE'], + }); + expect(getSupportedLocalesMock).toHaveBeenCalledTimes(2); + }); + + it('returns null when the service throws synchronously', async () => { + getSupportedLocalesMock.mockImplementationOnce(() => { + throw new Error('package not found'); + }); + + expect(await getVoiceRecognitionLocales()).toBeNull(); + }); +}); + describe('voiceInputLanguageDisplayName', () => { it('names a supported recognition language by its endonym', () => { expect(voiceInputLanguageDisplayName('de-DE')).toBe('Deutsch'); @@ -274,6 +346,11 @@ describe('voiceInputLanguageDisplayName', () => { expect(voiceInputLanguageDisplayName('zh-TW')).toBe('繁體中文'); }); + it('maps the Android Mandarin tag onto the shipped script endonym', () => { + expect(voiceInputLanguageDisplayName('cmn-Hans-CN')).toBe('简体中文'); + expect(voiceInputLanguageDisplayName('cmn-Hant-TW')).toBe('繁體中文'); + }); + it('falls back to the primary-subtag endonym for an unlisted region', () => { expect(voiceInputLanguageDisplayName('pt-PT')).toBe('Português (Portugal)'); }); @@ -282,3 +359,23 @@ describe('voiceInputLanguageDisplayName', () => { expect(voiceInputLanguageDisplayName('xx-LOL')).toBe('xx-LOL'); }); }); + +describe('voiceInputLanguageEnglishName', () => { + it('names the locale in English so a search for "german" finds de-DE', () => { + expect(voiceInputLanguageEnglishName('de-DE')).toBe('German'); + }); + + it('matches the full tag, so pt-BR names the Brazilian variant', () => { + expect(voiceInputLanguageEnglishName('pt-BR')).toBe('Portuguese (Brazil)'); + }); + + it('maps Chinese service tags onto the English script names', () => { + expect(voiceInputLanguageEnglishName('zh-CN')).toBe('Chinese (Simplified)'); + expect(voiceInputLanguageEnglishName('zh-TW')).toBe('Chinese (Traditional)'); + expect(voiceInputLanguageEnglishName('cmn-Hans-CN')).toBe('Chinese (Simplified)'); + }); + + it('returns undefined for a language the app does not ship', () => { + expect(voiceInputLanguageEnglishName('xx-LOL')).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/lib/voice-input/voice-input-language.ts b/apps/mobile/src/lib/voice-input/voice-input-language.ts index 97491a5413..e2cf0201ef 100644 --- a/apps/mobile/src/lib/voice-input/voice-input-language.ts +++ b/apps/mobile/src/lib/voice-input/voice-input-language.ts @@ -1,11 +1,18 @@ import { getLocales } from 'expo-localization'; import { ExpoSpeechRecognitionModule } from 'expo-speech-recognition'; -import { LANGUAGE_ENDONYMS } from '@/i18n/languages'; +import { LANGUAGE_ENDONYMS, LANGUAGE_ENGLISH_NAMES, SUPPORTED_LANGUAGES } from '@/i18n/languages'; import { resolveSupportedLanguageTag } from '@/i18n/resolve-language'; function normalizeLocale(tag: string): string { - return tag.toLowerCase().replaceAll('_', '-'); + // Android's speech service names Mandarin with the ISO 639-3 code `cmn` + // (`cmn-Hans-CN`), the app ships it as `zh-Hans`/`zh-Hant`. Canonicalizing + // the primary subtag here makes the picker's reconciliation treat a stored + // `cmn` tag and an app `zh` tag as the same language (p16). + return tag + .toLowerCase() + .replaceAll('_', '-') + .replace(/^cmn(?=-|$)/, 'zh'); } function scriptSubtag(tag: string): string | undefined { @@ -120,6 +127,26 @@ export function pickSupportedVoiceInputLanguageTag( return null; } +/** + * Map a stored voice-input language tag onto the option list of the mode that + * is in effect now. A choice is stored as one tag, but gateway mode offers the + * app's languages (`de`) while device mode offers the OS locales (`de-DE`), so + * a tag saved in the other mode matches no row: the picker shows nothing + * checked and the tag cannot reach the recogniser unchanged. An exact match + * wins (case/`_`-insensitive), then the same-language fallback inside + * `pickSupportedVoiceInputLanguageTag`; `null` — the Automatic row — means no + * option shares the stored tag's language. + */ +export function reconcileVoiceInputLanguageTag( + storedTag: string | null, + optionTags: readonly string[] +): string | null { + if (storedTag === null) { + return null; + } + return pickSupportedVoiceInputLanguageTag([storedTag], optionTags); +} + let cachedVoiceRecognitionLocales: { locales: readonly string[]; installedLocales: readonly string[]; @@ -144,6 +171,20 @@ async function fetchVoiceRecognitionLocales(): Promise<{ } } +/** + * The recognition service's supported and installed locale lists, memoized for + * the session by `fetchVoiceRecognitionLocales`. `null` means the service call + * failed (never cached, so a later call retries); a successful call that + * reports zero locales is a real "no languages" answer, not an error. + */ +export async function getVoiceRecognitionLocales(): Promise<{ + locales: readonly string[]; + installedLocales: readonly string[]; +} | null> { + const result = await fetchVoiceRecognitionLocales(); + return result; +} + /** * Resolve the best language tag for voice recognition from the active app * language. On first call, fetches and @@ -175,6 +216,41 @@ export async function resolveVoiceInputStartLanguageTag(appLanguage: string): Pr return pickSupportedVoiceInputLanguageTag(preferredTags, supported.locales) ?? appLanguage; } +/** + * Resolve the tag a session actually starts with. A persisted tag wins only + * when it maps onto the active mode's list (`reconcileVoiceInputLanguageTag`); + * otherwise the app/device language is resolved fresh. This is what keeps a + * tag chosen in the other mode — a gateway `zh-Hans` used in device mode — + * from reaching the recogniser and failing with `language-not-supported`. + * Gateway mode reconciles against the static app list, so it never fetches the + * device's locales. A failed or empty device answer is no evidence the stored + * tag is wrong, so device mode keeps it rather than discarding an explicit + * choice on a probe that returned nothing. + */ +export async function resolveVoiceInputSessionLanguageTag( + storedTag: string | null, + mode: 'device' | 'gateway', + appLanguage: string +): Promise { + if (storedTag === null) { + return resolveVoiceInputStartLanguageTag(appLanguage); + } + + // Gateway mode never fetches the device's locales: it reconciles against the + // static app list only. + const deviceLocales = mode === 'device' ? await getVoiceRecognitionLocales() : null; + // A failed or empty device answer cannot prove the tag wrong, so it wins: + // discarding an explicit choice on a probe that returned nothing would + // silently drop the user's selection. + if (mode === 'device' && (deviceLocales === null || deviceLocales.locales.length === 0)) { + return storedTag; + } + + const optionTags = deviceLocales === null ? SUPPORTED_LANGUAGES : deviceLocales.locales; + const reconciled = reconcileVoiceInputLanguageTag(storedTag, optionTags); + return reconciled ?? resolveVoiceInputStartLanguageTag(appLanguage); +} + /** * Whether the recognition service reports the language as installed for * offline (on-device) recognition. Starting with @@ -207,6 +283,18 @@ export function voiceInputLanguageDisplayName(languageTag: string): string { return language ? LANGUAGE_ENDONYMS[language] : languageTag; } +/** + * The English name of a recognition language, so the picker's search matches + * the name a user is likelier to know than the endonym or the raw tag + * (`de-DE` → "German"). Mirrors `voiceInputLanguageDisplayName`: resolves the + * whole tag first, and returns `undefined` for a language the app does not + * ship so callers add no match term rather than the raw tag twice. + */ +export function voiceInputLanguageEnglishName(languageTag: string): string | undefined { + const language = resolveSupportedLanguageTag([{ languageTag }]); + return language ? LANGUAGE_ENGLISH_NAMES[language] : undefined; +} + /** * Drop the memoized supported/installed locale lists so the next gate * re-queries the service. The download flow calls this after triggering a