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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string }
: current,
);
const voiceInput = useVoiceInputController({
environmentId: editor?.environmentId ?? null,
ownerKey,
label: editor?.draft.title.trim() || title,
subscribeToDraftChanges: (onChange) =>
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import * as Dialog from "../../components/ui/Dialog";
import { useEnvironments, type EnvironmentPresentation } from "../../state/environments";
import {
chooseVoiceDestination,
chooseTranscriptCleanup,
voiceCleanupGrantAtom,
voiceConnectionAtom,
voiceGrantAtom,
voiceSpeechStatusAtom,
Expand All @@ -37,6 +39,7 @@ import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActi
import { SettingsDragHandle } from "./components/SettingsDragHandle";
import { SettingsScreen } from "./components/SettingsScreen";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import {
deviceMicrophoneOrder,
localVoiceTranscriberAtom,
Expand Down Expand Up @@ -169,6 +172,7 @@ function EnvironmentChoice({
export function SettingsVoiceRouteScreen() {
const transcriber = useAtomValue(localVoiceTranscriberAtom);
const grant = useAtomValue(voiceGrantAtom);
const cleanupGrant = useAtomValue(voiceCleanupGrantAtom);
const { environments, isReady } = useEnvironments();
const grantEnvironment = grant
? environments.find(
Expand Down Expand Up @@ -259,6 +263,34 @@ export function SettingsVoiceRouteScreen() {
/>
) : null}
</SettingsSection>
<SettingsSection title="Cleanup">
<SettingsSwitchRow
label="Clean up transcripts"
subtitle="Sends the transcript and up to 1,024 characters around the cursor to the cleanup model on the draft’s environment. Text only, off by default. Does not apply to This device or cloud dictation."
value={cleanupGrant !== null}
onValueChange={(enabled) => {
const save = () => {
try {
chooseTranscriptCleanup(enabled);
} catch {
Dialog.alert(
"Could not save transcript cleanup choice",
"Cleanup is off for this session. Save again before closing the app, or your previous choice may return when it restarts.",
);
}
};
if (enabled)
Dialog.confirm({
title: "Clean up transcripts?",
message:
"Your transcript and nearby draft text will be sent to the cleanup model on the draft’s environment. Depending on the model, this may use an external service. Audio is not sent for cleanup. You can turn this off at any time.",
confirmText: "Enable cleanup",
onConfirm: save,
});
else save();
}}
/>
</SettingsSection>
{hasEnvironmentVoiceCapture &&
environments
.filter(
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
offersSideChat: props.offersSideChat === true,
});
const voiceInput = useVoiceInputController({
environmentId: props.environmentId,
ownerKey: composerDraftKey,
label: props.selectedThread.title || "Untitled thread",
readDraftMessage: () => getComposerDraftSnapshot(composerDraftKey).text,
Expand Down
75 changes: 74 additions & 1 deletion apps/mobile/src/features/voice-input/VoiceInputProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,24 @@ import { createVoiceRecorder } from "../../native/voiceRecorder";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene";
import {
postProcessEnvironmentTranscript,
voiceInputBlocksSubmission,
type VoiceDraftSnapshot,
type VoiceTranscriber,
} from "@t3tools/client-runtime/voice-input";
import { createLazyVoiceRecorder } from "./lazyVoiceRecorder";
import { VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering";
import { VoiceInputSession } from "./voiceInputSession";
import { createVoiceRecordingPermission } from "./voiceRecordingPermission";
import { voiceRouteAtom } from "./voiceDestination";
import {
runSpeech,
voiceCleanupGrantAtom,
voiceConnectionAtom,
voiceGrantAtom,
voiceRouteAtom,
} from "./voiceDestination";
import * as Dialog from "../../components/ui/Dialog";
import type { VoiceGrant } from "./voiceGrantStorage";
import { makePermissionStatusAtom } from "../../lib/permissionStatusAtom";
import {
acquireAudioSession,
Expand Down Expand Up @@ -55,6 +65,18 @@ function keepScreenAwake(tag: string): () => void {
export const deviceMicrophoneOrder = (saved?: ReadonlyArray<MicrophoneEntry>) =>
microphoneOrder(saved, Platform.OS === "android" ? "android" : "ios");

function cleanupContext({ text, selection }: VoiceDraftSnapshot) {
const limit = 1_024;
const start = Math.max(0, Math.min(selection.start, text.length));
const end = Math.max(start, Math.min(selection.end, text.length));
const before = text.slice(Math.max(0, start - limit), start);
const selected = text.slice(start, Math.min(end, start + limit));
return {
text: before + selected + text.slice(end, end + limit),
selection: { start: before.length, end: before.length + selected.length },
};
}

/** "checking" until the first availability probe settles; re-probed on foreground. */
export const localVoiceTranscriberAtom = makePermissionStatusAtom<
VoiceTranscriber | null | "checking"
Expand All @@ -80,11 +102,17 @@ const voiceInputAtom = Atom.make((get) => {
// Mounted so the recorder can read and remember microphones synchronously.
get.mount(mobilePreferencesAtom);
get.mount(updateMobilePreferencesAtom);
get.mount(voiceCleanupGrantAtom);
const audioLevels = makeMutable<ReadonlyArray<number>>(
Array<number>(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0),
);
let keepAwakeSequence = 0;
let audioToken: ReturnType<typeof acquireAudioSession> | null = null;
let recordingCleanup: {
readonly grant: VoiceGrant;
readonly cleanupGrant: string;
readonly ownerKey: string;
} | null = null;
const permission = createVoiceRecordingPermission(() => {
void session.controller.appMovedToBackground();
});
Expand Down Expand Up @@ -121,11 +149,56 @@ const voiceInputAtom = Atom.make((get) => {
const session: VoiceInputSession = new VoiceInputSession({
recorder,
getTranscriber: () => {
const grant = registry.get(voiceGrantAtom);
const cleanupGrant = registry.get(voiceCleanupGrantAtom);
const ownerKey = session.ownerKey;
recordingCleanup =
grant?.destination.kind === "environment" &&
grant.destination.environmentId === session.environmentId &&
cleanupGrant &&
ownerKey
? { grant, cleanupGrant, ownerKey }
: null;
const route = registry.get(voiceRouteAtom);
if (route) return route.transcriber;
const transcriber = registry.get(localVoiceTranscriberAtom);
return transcriber === "checking" ? null : transcriber;
},
postProcess: (transcript, { draft, signal }) => {
const preferences = recordingCleanup;
const canCleanUp = () =>
preferences !== null &&
registry.get(voiceGrantAtom) === preferences.grant &&
registry.get(voiceCleanupGrantAtom) === preferences.cleanupGrant &&
preferences.grant.destination.kind === "environment" &&
preferences.grant.destination.environmentId === session.environmentId &&
session.ownerKey === preferences.ownerKey &&
draft.ownerKey === preferences.ownerKey;
if (!preferences || !canCleanUp()) return null;
const connectionAtom = voiceConnectionAtom(preferences.grant.destination.environmentId);
const prepared = registry.get(connectionAtom);
if (!prepared)
return Promise.reject(
new Error("The draft's environment is unavailable for transcript cleanup."),
);
const canSend = () => canCleanUp() && registry.get(connectionAtom) === prepared;
return runSpeech(
postProcessEnvironmentTranscript(
prepared,
transcript,
cleanupContext(draft),
{
speechCustomWords: [],
speechPostProcessingPrompt: { mode: "default", customInstructions: "" },
},
canSend,
),
signal,
canSend,
).then((result) => (canSend() ? result.text : transcript));
},
onPostProcessingError: () =>
Dialog.alert("Transcript cleanup failed", "The original transcript was added."),
requestPermission: permission.requestPermission,
configureRecording: async () => {
const token = acquireAudioSession("recording");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import {
} from "@t3tools/client-runtime/voice-input";

import type { ComposerEditorSelection } from "../../components/ComposerEditor";
import type { EnvironmentId } from "@t3tools/contracts";
import { useGlobalVoiceInput } from "./VoiceInputProvider";
import { createVoiceInputTarget } from "./voiceInputSession";

const IDLE_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null };

export function useVoiceInputController(input: {
readonly environmentId: EnvironmentId | null;
readonly ownerKey: string | null;
readonly label: string;
readonly readDraftMessage: () => string | null;
Expand Down Expand Up @@ -48,6 +50,7 @@ export function useVoiceInputController(input: {
const captured = latestInput.current;
if (!captured.ownerKey || captured.disabled) return;
void session.start({
environmentId: captured.environmentId,
...createVoiceInputTarget(
captured.ownerKey,
captured.readDraftMessage,
Expand Down
37 changes: 37 additions & 0 deletions apps/mobile/src/features/voice-input/voiceCleanupStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as Schema from "effect/Schema";
import { File, Paths } from "expo-file-system";
import * as SecureStore from "expo-secure-store";
import { whenProtectedDataAvailable } from "../../lib/protectedData";

const key = "t3code.voice.cleanup-grant.v1";
const deviceOnly = { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
const markerFile = () => new File(Paths.document, "voice-cleanup-grant-id");
const decodeGrant = Schema.decodeUnknownSync(
Schema.fromJsonString(Schema.NullOr(Schema.NonEmptyString)),
);

export async function readVoiceCleanupGrant() {
await whenProtectedDataAvailable();
try {
const raw = await SecureStore.getItemAsync(key, deviceOnly);
const grant = raw ? decodeGrant(raw) : null;
return grant && markerFile().textSync() === grant ? grant : null;
} catch {
return null;
}
}

export function writeVoiceCleanupGrant(grant: string | null) {
try {
markerFile().writeSync(grant ?? "");
} catch (cause) {
SecureStore.setItem(key, "null", deviceOnly);
if (grant) throw cause;
return;
}
try {
SecureStore.setItem(key, JSON.stringify(grant), deviceOnly);
} catch (cause) {
if (grant) throw cause;
}
}
Loading
Loading