-
Notifications
You must be signed in to change notification settings - Fork 5.4k
feat(mobile): environment-backed voice transcription #9028
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c687116
ace14b6
9293986
ccb485a
bc83013
53ec43f
ebc8e4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,25 @@ | ||
| import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| import { useAtomSet, useAtomValue } from "@effect/atom-react"; | ||
| import { AsyncResult } from "effect/unstable/reactivity"; | ||
| import { useNavigation } from "@react-navigation/native"; | ||
| import { SymbolView } from "../../components/AppSymbol"; | ||
| import type { EnvironmentId } from "@t3tools/contracts"; | ||
| import { useCallback, useState } from "react"; | ||
| import { Platform, ScrollView, View } from "react-native"; | ||
| import { Platform, Pressable, ScrollView, View } from "react-native"; | ||
| import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
|
|
||
| import { AppText as Text } from "../../components/AppText"; | ||
| import { ControlPillMenu } from "../../components/ControlPill"; | ||
| import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ||
| import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; | ||
| import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; | ||
| import { splitEnvironmentSections } from "../connection/environmentSections"; | ||
| import { cn } from "../../lib/cn"; | ||
| import { useUniwindTheme } from "../../lib/useUniwindTheme"; | ||
| import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; | ||
| import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | ||
| import { serverEnvironment } from "../../state/server"; | ||
| import { | ||
| applyShowcaseLocalEnvironmentDisplayUrls, | ||
| resolveShowcaseEnvironmentUpdateDisplayUrl, | ||
|
|
@@ -171,7 +177,89 @@ export function SettingsEnvironmentsRouteScreen() { | |
| } | ||
| : {})} | ||
| /> | ||
| {[...localEnvironments, ...connectedCloudEnvironments].map((environment) => ( | ||
| <EnvironmentTranscriptionRow | ||
| key={`transcription-${environment.environmentId}`} | ||
| environmentId={environment.environmentId} | ||
| environmentLabel={environment.environmentLabel} | ||
| /> | ||
| ))} | ||
| </ScrollView> | ||
| </View> | ||
| ); | ||
| } | ||
|
|
||
| function EnvironmentTranscriptionRow(props: { | ||
| readonly environmentId: EnvironmentId; | ||
| readonly environmentLabel: string; | ||
| }) { | ||
| const services = useAtomValue( | ||
| serverEnvironment.transcriptionServicesValueAtom(props.environmentId), | ||
| ); | ||
| const preferences = useAtomValue(mobilePreferencesAtom); | ||
| const savePreferences = useAtomSet(updateMobilePreferencesAtom); | ||
| if (services.length === 0) return null; | ||
|
|
||
| const localAvailable = getLocalVoiceTranscriber() !== null; | ||
| const savedSource = AsyncResult.isSuccess(preferences) | ||
| ? preferences.value.voiceTranscriptionSources?.[props.environmentId] | ||
| : undefined; | ||
| const selectedSource = savedSource ?? (localAvailable ? "local" : services[0]?.id); | ||
| const selectedLabel = | ||
| selectedSource === "local" | ||
| ? "On this device" | ||
| : (services.find((service) => service.id === selectedSource)?.label ?? "Unavailable"); | ||
| const actions = [ | ||
| ...(localAvailable | ||
| ? [ | ||
| { | ||
| id: "local", | ||
| title: "On this device", | ||
| state: selectedSource === "local" ? ("on" as const) : ("off" as const), | ||
| }, | ||
| ] | ||
| : []), | ||
| ...services.map((service) => ({ | ||
| id: service.id, | ||
| title: service.label, | ||
| state: selectedSource === service.id ? ("on" as const) : ("off" as const), | ||
| })), | ||
| ]; | ||
|
|
||
| return ( | ||
| <ControlPillMenu | ||
| actions={actions} | ||
| onPressAction={({ nativeEvent }) => { | ||
| const current = AsyncResult.isSuccess(preferences) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π‘ Medium Selecting a source before π€ Copy this AI Prompt to have your agent fix this: |
||
| ? (preferences.value.voiceTranscriptionSources ?? {}) | ||
| : {}; | ||
| savePreferences({ | ||
| voiceTranscriptionSources: { | ||
| ...current, | ||
| [props.environmentId]: nativeEvent.event, | ||
| }, | ||
| }); | ||
| }} | ||
| > | ||
| <Pressable | ||
| accessibilityLabel={`${props.environmentLabel} voice transcription: ${selectedLabel}`} | ||
| accessibilityRole="button" | ||
| className="mt-3 flex-row items-center justify-between rounded-[24px] bg-card px-4 py-3" | ||
| > | ||
| <View className="flex-row items-center gap-3"> | ||
| <SymbolView | ||
| name="waveform" | ||
| size={18} | ||
| tintColorClassName="accent-icon-muted" | ||
| type="monochrome" | ||
| /> | ||
| <View> | ||
| <Text className="text-sm text-foreground">Voice transcription</Text> | ||
| <Text className="text-xs text-foreground-muted">{props.environmentLabel}</Text> | ||
| </View> | ||
| </View> | ||
| <Text className="text-sm text-foreground-muted">{selectedLabel}</Text> | ||
| </Pressable> | ||
| </ControlPillMenu> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { File, UploadType } from "expo-file-system"; | ||
| import type { EnvironmentVoiceTranscriptionTransport } from "@t3tools/client-runtime/voice-input"; | ||
|
|
||
| export const environmentVoiceTransport: EnvironmentVoiceTranscriptionTransport = { | ||
| sizeBytes: async (uri) => new File(uri).size, | ||
| upload: async ({ uri, url, mimeType, signal }) => { | ||
| const result = await new File(uri).upload(url, { | ||
| httpMethod: "POST", | ||
| uploadType: UploadType.BINARY_CONTENT, | ||
| headers: { "Content-Type": mimeType }, | ||
| signal, | ||
| }); | ||
| return { status: result.status, bodyText: result.body }; | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,21 +8,33 @@ import { | |||||||||||||||||
| } from "expo-audio"; | ||||||||||||||||||
| import { File } from "expo-file-system"; | ||||||||||||||||||
| import { useFocusEffect } from "@react-navigation/native"; | ||||||||||||||||||
| import { useAtomValue } from "@effect/atom-react"; | ||||||||||||||||||
| import type { EnvironmentId } from "@t3tools/contracts"; | ||||||||||||||||||
| import * as Option from "effect/Option"; | ||||||||||||||||||
| import { AsyncResult } from "effect/unstable/reactivity"; | ||||||||||||||||||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||||||||||||||||||
| import { AppState } from "react-native"; | ||||||||||||||||||
| import { useSharedValue } from "react-native-reanimated"; | ||||||||||||||||||
|
|
||||||||||||||||||
| import type { ComposerEditorSelection } from "../../components/ComposerEditor"; | ||||||||||||||||||
| import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; | ||||||||||||||||||
| import { | ||||||||||||||||||
| createEnvironmentVoiceTranscriber, | ||||||||||||||||||
| VoiceInputController, | ||||||||||||||||||
| VOICE_RECORDING_LIMIT_SECONDS, | ||||||||||||||||||
| voiceInputBlocksSubmission, | ||||||||||||||||||
| voiceInputFreezesEditor, | ||||||||||||||||||
| type VoiceDraftSnapshot, | ||||||||||||||||||
| type VoiceInputState, | ||||||||||||||||||
| } from "@t3tools/client-runtime/voice-input"; | ||||||||||||||||||
| import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | ||||||||||||||||||
| import { environmentVoiceTransport } from "./environmentVoiceTransport"; | ||||||||||||||||||
| import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering"; | ||||||||||||||||||
| import { appAtomRegistry } from "../../state/atom-registry"; | ||||||||||||||||||
| import { mobilePreferencesAtom } from "../../state/preferences"; | ||||||||||||||||||
| import { serverEnvironment } from "../../state/server"; | ||||||||||||||||||
| import { environmentSession } from "../../state/session"; | ||||||||||||||||||
| import { transcriptionEnvironment } from "../../state/transcription"; | ||||||||||||||||||
|
|
||||||||||||||||||
| const INITIAL_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; | ||||||||||||||||||
| const VOICE_METERING_INTERVAL_MS = 80; | ||||||||||||||||||
|
|
@@ -61,6 +73,7 @@ async function configureVoiceRecordingAudio(): Promise<void> { | |||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| export function useVoiceInputController(input: { | ||||||||||||||||||
| readonly environmentId: EnvironmentId | null; | ||||||||||||||||||
| readonly ownerKey: string | null; | ||||||||||||||||||
| readonly draftMessage: string; | ||||||||||||||||||
| readonly selection: ComposerEditorSelection; | ||||||||||||||||||
|
|
@@ -70,6 +83,27 @@ export function useVoiceInputController(input: { | |||||||||||||||||
| }) { | ||||||||||||||||||
| const [state, setState] = useState<VoiceInputState>(INITIAL_STATE); | ||||||||||||||||||
| const [elapsedSeconds, setElapsedSeconds] = useState(0); | ||||||||||||||||||
| const preferences = useAtomValue(mobilePreferencesAtom); | ||||||||||||||||||
| const services = useAtomValue( | ||||||||||||||||||
| serverEnvironment.transcriptionServicesValueAtom(input.environmentId), | ||||||||||||||||||
| ); | ||||||||||||||||||
| const localTranscriber = getLocalVoiceTranscriber(); | ||||||||||||||||||
| const selectedSource = | ||||||||||||||||||
| input.environmentId !== null && AsyncResult.isSuccess(preferences) | ||||||||||||||||||
| ? preferences.value.voiceTranscriptionSources?.[input.environmentId] | ||||||||||||||||||
| : undefined; | ||||||||||||||||||
| const voiceSelectionRef = useRef({ | ||||||||||||||||||
| environmentId: input.environmentId, | ||||||||||||||||||
| services, | ||||||||||||||||||
| localTranscriber, | ||||||||||||||||||
| selectedSource, | ||||||||||||||||||
| }); | ||||||||||||||||||
| voiceSelectionRef.current = { | ||||||||||||||||||
| environmentId: input.environmentId, | ||||||||||||||||||
| services, | ||||||||||||||||||
| localTranscriber, | ||||||||||||||||||
| selectedSource, | ||||||||||||||||||
| }; | ||||||||||||||||||
| const elapsedSecondsRef = useRef(0); | ||||||||||||||||||
| const audioLevelsRef = useRef(Array<number>(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0)); | ||||||||||||||||||
| const audioLevels = useSharedValue(audioLevelsRef.current); | ||||||||||||||||||
|
|
@@ -99,7 +133,38 @@ export function useVoiceInputController(input: { | |||||||||||||||||
| if (!controllerRef.current) { | ||||||||||||||||||
| controllerRef.current = new VoiceInputController({ | ||||||||||||||||||
| recorder, | ||||||||||||||||||
| getTranscriber: getLocalVoiceTranscriber, | ||||||||||||||||||
| getTranscriber: () => { | ||||||||||||||||||
| const selection = voiceSelectionRef.current; | ||||||||||||||||||
| const source = | ||||||||||||||||||
| selection.selectedSource ?? | ||||||||||||||||||
| (selection.localTranscriber !== null ? "local" : selection.services[0]?.id); | ||||||||||||||||||
|
Comment on lines
+139
to
+140
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π‘ Medium When a previously selected environment service is removed from
Suggested change
π€ Copy this AI Prompt to have your agent fix this: |
||||||||||||||||||
| if (source === "local" || source === undefined) return selection.localTranscriber; | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale source blocks local transcriptionMedium Severity A persisted environment source is used even when that service is no longer advertised. Additional Locations (2)Reviewed by Cursor Bugbot for commit bc83013. Configure here. |
||||||||||||||||||
| if (selection.environmentId === null) return null; | ||||||||||||||||||
| const environmentId = selection.environmentId; | ||||||||||||||||||
| return createEnvironmentVoiceTranscriber({ | ||||||||||||||||||
| environmentId, | ||||||||||||||||||
| serviceId: source, | ||||||||||||||||||
| locale: Intl.DateTimeFormat().resolvedOptions().locale, | ||||||||||||||||||
| mimeType: "audio/mp4", | ||||||||||||||||||
| transport: environmentVoiceTransport, | ||||||||||||||||||
| registry: appAtomRegistry, | ||||||||||||||||||
| environment: transcriptionEnvironment, | ||||||||||||||||||
| getServices: () => | ||||||||||||||||||
| appAtomRegistry.get(serverEnvironment.transcriptionServicesValueAtom(environmentId)), | ||||||||||||||||||
| isConnected: () => | ||||||||||||||||||
| Option.isSome( | ||||||||||||||||||
| appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)), | ||||||||||||||||||
| ), | ||||||||||||||||||
| resolveUrl: (relativeUrl) => { | ||||||||||||||||||
| const connection = appAtomRegistry.get( | ||||||||||||||||||
| environmentSession.preparedConnectionValueAtom(environmentId), | ||||||||||||||||||
| ); | ||||||||||||||||||
| return Option.isSome(connection) | ||||||||||||||||||
| ? resolveAssetUrl(connection.value.httpBaseUrl, relativeUrl) | ||||||||||||||||||
| : null; | ||||||||||||||||||
| }, | ||||||||||||||||||
| }); | ||||||||||||||||||
| }, | ||||||||||||||||||
| requestPermission: async () => { | ||||||||||||||||||
| const permission = await requestRecordingPermissionsAsync(); | ||||||||||||||||||
| return { granted: permission.granted, canAskAgain: permission.canAskAgain }; | ||||||||||||||||||
|
|
@@ -203,7 +268,7 @@ export function useVoiceInputController(input: { | |||||||||||||||||
| const cancel = useCallback(() => controller.cancel(), [controller]); | ||||||||||||||||||
|
|
||||||||||||||||||
| return { | ||||||||||||||||||
| isAvailable: getLocalVoiceTranscriber() !== null, | ||||||||||||||||||
| isAvailable: localTranscriber !== null || services.length > 0, | ||||||||||||||||||
| state, | ||||||||||||||||||
| audioLevels, | ||||||||||||||||||
| elapsedSeconds, | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { createTranscriptionEnvironmentAtoms } from "@t3tools/client-runtime/state/transcription"; | ||
|
|
||
| import { connectionAtomRuntime } from "../connection/runtime"; | ||
|
|
||
| export const transcriptionEnvironment = createTranscriptionEnvironmentAtoms(connectionAtomRuntime); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π‘ Medium
settings/SettingsEnvironmentsRouteScreen.tsx:201When a remote transcription service is removed, this early return hides the picker even if the on-device transcriber is available, so the persisted remote id remains selected and voice input stays unavailable with no way to switch back to local. Keep the picker mounted whenever
getLocalVoiceTranscriber()is available, even whenservicesis empty.π€ Copy this AI Prompt to have your agent fix this: