- Microphone audio is sent to this environment to transcribe, then discarded. Choose Off
- to turn voice input off on this device. This choice is saved on this device only.
+ Audio is sent here to transcribe, then discarded. Choose Off to disable voice input.
+ Saved on this device only.
>
}
control={
@@ -1182,6 +1216,11 @@ export function VoiceSettingsPanel() {
}
>
+ {!destination.environmentId ? (
+
+ Choose an environment to enable voice input, then select Set up voice input below.
+
+ ) : null}
{destination.environmentId && chosenUnsupported ? (
{chosenEnvironment.label} can’t transcribe audio. Pick an environment that can to use
diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx
index a41f359f68ef4..8f705fca4e425 100644
--- a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx
+++ b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx
@@ -39,10 +39,16 @@ const mocks = vi.hoisted(() => ({
recordingOptions: [] as SpeechTranscriptionOptions[],
projectWords: {} as Record,
transcripts: [] as Array,
+ navigate: vi.fn(),
committed: vi.fn(),
draft: "",
}));
+vi.mock("@tanstack/react-router", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useNavigate: () => mocks.navigate,
+}));
+
vi.mock("../state/session", () => ({
useEnvironmentScope: (id: string) => mocks.canDictate.has(id),
usePreparedConnection: (environmentId: string | null) => {
@@ -166,6 +172,7 @@ const THREAD_ENV = "thread-env";
function setup(options: { readonly destination?: string | null } = {}) {
mocks.settings = {};
+ mocks.navigate.mockReset();
setDestination(options.destination ?? null);
chooseTranscriptCleanup(false);
mocks.primaryEnvironmentId = "primary-env";
@@ -238,7 +245,11 @@ it.each([
await act(() => mocks.voice.start());
expectNoAudioSent();
expect(mocks.committed).not.toHaveBeenCalled();
- expect(mocks.voice.available).toBe(false);
+ expect(mocks.voice.available).toBe(true);
+ expect(mocks.navigate).toHaveBeenCalledWith({
+ to: "/settings/voice",
+ hash: "voice-transcription-destination",
+ });
expect(mocks.voice.transcriptionEnvironmentId).toBeNull();
});
@@ -309,6 +320,25 @@ it("retries a failed transcription on the same chosen environment", async () =>
expect(mocks.committed).toHaveBeenCalledExactlyOnceWith("second try", expect.anything());
});
+it.each(["missing-model", "ready"] as const)(
+ "opens setup explicitly with a %s model without recording",
+ async (modelState) => {
+ setup({ destination: "env-a" });
+ mocks.modelState = modelState;
+ await using _screen = await render();
+ await act(() => mocks.voice.setup.show());
+ expect(mocks.voice.setup).toMatchObject({
+ open: true,
+ step: modelState === "missing-model" ? 0 : 1,
+ });
+ expect(mocks.microphoneRequests).toEqual([]);
+ expect(mocks.transcribeCalls).toEqual([]);
+ await act(() => mocks.voice.setup.startRecording());
+ expect(mocks.voice.setup.open).toBe(modelState === "missing-model");
+ expect(mocks.microphoneRequests).toEqual(modelState === "ready" ? ["env-a"] : []);
+ },
+);
+
it("reopens setup after reconnecting during a download and ignores its stale completion", async () => {
setup({ destination: "env-a" });
mocks.modelState = "missing-model";
@@ -370,7 +400,7 @@ it("revokes across windows even before the storage event and survives stale pref
storage.removeItem(key);
expect(canSend()).toBe(false);
writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, wordWrap: false });
- await vi.waitFor(() => expect(mocks.voice.available).toBe(false));
+ await vi.waitFor(() => expect(mocks.voice.transcriptionEnvironmentId).toBeNull());
expect(mocks.voice.state.phase).toBe("idle");
await act(() => mocks.voice.stop());
expect(mocks.transcribeCalls).toEqual([]);
diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.ts b/apps/web/src/speech/useEnvironmentSpeechInput.ts
index 644edb303aef2..c79994d94af03 100644
--- a/apps/web/src/speech/useEnvironmentSpeechInput.ts
+++ b/apps/web/src/speech/useEnvironmentSpeechInput.ts
@@ -25,6 +25,7 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { resolveSpeechVocabulary } from "@t3tools/shared/speech";
import * as Option from "effect/Option";
import { useEffect, useRef, useState } from "react";
+import { useNavigate } from "@tanstack/react-router";
import { useStableCallback } from "~/hooks/useStableCallback";
import { toastManager } from "../components/ui/toast";
@@ -119,6 +120,7 @@ export function useVoiceTranscriptionDestination() {
}
export function useEnvironmentSpeechInput(input: HookInput) {
+ const navigate = useNavigate();
const destination = useVoiceTranscriptionDestination();
const prepared = destination.prepared;
const grantId = destination.grantId;
@@ -423,7 +425,12 @@ export function useEnvironmentSpeechInput(input: HookInput) {
if (controller.currentState.phase === "recording") void controller.stop();
});
- const start = useStableCallback(async () => {
+ const start = useStableCallback(async (setupOnly = false) => {
+ if (latestRef.current.input.disabled) return;
+ if (!grantId) {
+ await navigate({ to: "/settings/voice", hash: "voice-transcription-destination" });
+ return;
+ }
const request = ++startRequestRef.current;
const controller = controllerRef.current;
const owner = latestRef.current.input.ownerKey;
@@ -458,8 +465,12 @@ export function useEnvironmentSpeechInput(input: HookInput) {
});
return;
}
- if (latestStatus.state === "missing-model") {
- setSetup({ ...INITIAL_SETUP, open: true });
+ if (setupOnly || latestStatus.state === "missing-model") {
+ setSetup({
+ ...INITIAL_SETUP,
+ open: true,
+ step: latestStatus.state === "missing-model" ? 0 : 1,
+ });
return;
}
setLevel(0);
@@ -513,9 +524,10 @@ export function useEnvironmentSpeechInput(input: HookInput) {
return {
available:
- prepared !== null &&
- status?.prepared === prepared &&
- status.value?.supported !== false &&
+ (!grantId ||
+ (prepared !== null &&
+ status?.prepared === prepared &&
+ status.value?.supported !== false)) &&
typeof navigator !== "undefined" &&
Boolean(navigator.mediaDevices?.getUserMedia) &&
typeof AudioWorkletNode !== "undefined",
@@ -524,6 +536,7 @@ export function useEnvironmentSpeechInput(input: HookInput) {
transcriptionEnvironmentLabel: destination.label,
setup: {
...setup,
+ show: () => start(true),
setOpen: (open: boolean) => setSetup((current) => ({ ...current, open })),
download: downloadSetupModel,
cancelDownload: cancelSetupDownload,
diff --git a/docs/user/voice-input.md b/docs/user/voice-input.md
index e204b28660626..212308ed911da 100644
--- a/docs/user/voice-input.md
+++ b/docs/user/voice-input.md
@@ -15,6 +15,8 @@ to a named environment on that phone, open **Settings → Voice → Transcribe o
On web and desktop, transcription runs on one of your T3 Code environments.
Nothing is sent until you choose one: open **Settings → Voice** and set
**Send microphone audio to** the environment that should transcribe for you.
+The microphone and dictation shortcut open these settings when no destination
+has been chosen.
Microphone audio recorded on this device is sent to that environment over your
authenticated connection, transcribed there, and discarded. The choice is saved
on this device only, so every computer and browser you use asks for it once.
@@ -25,8 +27,10 @@ substitutes another environment.
## Set up a model
-The first time you dictate, T3 Code offers to download the recommended English
-model to the chosen environment. In **Settings → Voice → Transcription**, select
+Select **Set up voice input** in Voice settings to open
+the setup wizard. It offers a model download if needed, then lets you choose a
+microphone and start dictating. The first time you dictate with a missing model,
+the same wizard opens. In **Settings → Voice → Transcription**, select
the languages you dictate in to compare models that support all of them.
Accuracy shows how often the model got words or characters wrong in each
language; speed is the time to transcribe 30 seconds on a reference GPU. Choose