Skip to content
Open
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
17 changes: 11 additions & 6 deletions apps/mobile/src/features/settings/CloudVoiceSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ScrollView, TextInput, View } from "react-native";
import { Sheet } from "../../components/ui/Sheet";
import * as Dialog from "../../components/ui/Dialog";
import { ProviderIcon } from "../../components/ProviderIcon";
import { SymbolView } from "../../components/ui/AppSymbol";
import { AppText as Text } from "../../components/ui/AppText";
import { ListActionRow } from "../../components/ui/ListActionRow";
import { ListChoiceRow } from "../../components/ui/ListChoiceRow";
Expand Down Expand Up @@ -285,12 +286,16 @@ function CloudChoices({ environment, active, onClose }: Props & { onClose: () =>
key={provider}
label={label}
leading={
<ProviderIcon
provider={
provider === "openai" ? "codex" : provider === "xai" ? "grok" : provider
}
size={20}
/>
provider === "deepgram" ? (
<SymbolView name="mic" size={20} tintColorClassName="accent-icon" />
) : (
<ProviderIcon
provider={
provider === "openai" ? "codex" : provider === "xai" ? "grok" : provider
}
size={20}
/>
)
}
selected={isActive}
separated={index > 0}
Expand Down
16 changes: 14 additions & 2 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1765,7 +1765,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
const hub = UsageLimitSourceId.make("hub");
const router = ProviderInstanceId.make("openrouter");
const withUsage = yield* serverSettings.updateSettings({
cloudSpeechCredentials: { openai: usageReference, xai: "literal-fixture" },
cloudSpeechCredentials: {
openai: usageReference,
xai: "literal-fixture",
deepgram: usageReference,
},
textGenerators: {
[router]: {
kind: "openaiCompatible",
Expand Down Expand Up @@ -1823,6 +1827,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
openai: SECRET_REDACTED,
xai: SECRET_REDACTED,
openrouter: "",
deepgram: SECRET_REDACTED,
});
assert.equal(legacy.sourceControlHosts.bitbucket?.accessToken, SECRET_REDACTED);
assert.equal(legacy.usageLimitSources[hub]?.managementKey, SECRET_REDACTED);
Expand Down Expand Up @@ -1853,6 +1858,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
openai: usageReference,
xai: "literal-fixture",
openrouter: "",
deepgram: usageReference,
});
assert.deepEqual(echoed.easDeviceHosts[0]?.accessToken, easReference);
assert.deepEqual(echoed.simlockDeviceHosts[0]?.agentToken, simlockReference);
Expand Down Expand Up @@ -1938,7 +1944,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
"ghe.acme.test": work,
});
assert.equal(removed.usageLimitSources[hub]?.managementKey, "");
assert.deepEqual(removed.cloudSpeechCredentials, { openai: "", xai: work, openrouter: "" });
assert.deepEqual(removed.cloudSpeechCredentials, {
openai: "",
xai: work,
openrouter: "",
deepgram: "",
});
assert.equal(removed.easDeviceHosts[0]?.accessToken, "");
assert.deepEqual(Object.keys(removed.secretProviders), ["work"]);
}).pipe(Effect.provide(layerServerSettingsWithSecrets())),
Expand All @@ -1951,6 +1962,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
"cloudSpeechCredentials.openai",
"cloudSpeechCredentials.xai",
"cloudSpeechCredentials.openrouter",
"cloudSpeechCredentials.deepgram",
"easDeviceHosts.[].accessToken",
"simlockDeviceHosts.[].agentToken",
"usageLimitSources.*.managementKey",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,12 @@ it.effect("reads a collected reference again once it is invalidated", () =>
resolver,
Effect.succeed({
sourceControlHosts: hosts,
cloudSpeechCredentials: { openai: source("voice#key"), xai: "", openrouter: "" },
cloudSpeechCredentials: {
openai: source("voice#key"),
xai: "",
openrouter: "",
deepgram: "",
},
usageLimitSources,
textGenerators: {},
easDeviceHosts,
Expand Down
88 changes: 87 additions & 1 deletion apps/server/src/speech/CloudSpeech.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ it.effect.each(["openai", "xai"] as const)("sends only to %s using its own saved
yield* service.setKey(provider === "openai" ? "xai" : "openai", Redacted.make("other"));
expect((yield* Effect.flip(transcribe()))._tag).toBe("CloudSpeechError");
yield* service.setKey(provider, Redacted.make("chosen"));
expect(yield* service.configured).toEqual({ openai: true, xai: true, openrouter: false });
expect(yield* service.configured).toEqual({
openai: true,
xai: true,
openrouter: false,
deepgram: false,
});
yield* Effect.flip(transcribe(new Uint8Array(3)));
expect(upstream).not.toHaveBeenCalled();
expect(yield* transcribe()).toBe("hello");
Expand Down Expand Up @@ -159,6 +164,87 @@ it.effect("uploads to OpenRouter only for a listed exact model, as zero-retentio
}),
);

it.effect("sends Deepgram raw WAV with its token and reads the first transcript", () =>
Effect.gen(function* () {
const upstream = vi.fn<typeof fetch>(async () =>
Response.json({ results: { channels: [{ alternatives: [{ transcript: "hello" }] }] } }),
);
const service = yield* CloudSpeech.CloudSpeech.pipe(
Effect.provide(
CloudSpeech.layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(ServerSettings.ServerSettingsService.layerTest()),
Layer.provide(Layer.mock(SecretResolver.SecretResolver)({})),
),
),
);
const transcribe = (speechLanguage: string) =>
service
.transcribe("deepgram", new Uint8Array(new Float32Array([0, 0.5]).buffer), {
...DEFAULT_SPEECH_TRANSCRIPTION_OPTIONS,
speechLanguage,
speechCustomWords: [{ term: "T3 Code", aliases: [] }],
})
.pipe(Effect.provideService(FetchHttpClient.Fetch, upstream));
yield* service.setKey("deepgram", Redacted.make("dg-key"));
expect((yield* service.configured).deepgram).toBe(true);
expect(yield* transcribe("ru")).toBe("hello");
const [url, init] = upstream.mock.calls[0]!;
const sent = new URL(String(url));
expect(sent.origin + sent.pathname).toBe("https://api.deepgram.com/v1/listen");
expect(sent.searchParams.get("model")).toBe("nova-3");
expect(sent.searchParams.get("redact_usage")).toBe("true");
expect(sent.searchParams.get("language")).toBe("ru");
expect(sent.searchParams.getAll("keyterm")).toEqual(["T3 Code"]);
expect(init?.redirect).toBe("error");
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Token dg-key");
expect(headers.get("content-type")).toBe("audio/wav");
const wav = Buffer.from(yield* Effect.promise(() => new Response(init?.body).arrayBuffer()));
expect(wav.toString("ascii", 0, 4)).toBe("RIFF");
expect(wav.readInt16LE(46)).toBe(16384);
yield* transcribe("auto");
expect(new URL(String(upstream.mock.calls[1]![0])).searchParams.get("language")).toBe("multi");
const keytermLimit = () =>
Response.json(
{
err_code: "Bad Request",
err_msg:
"Bad Request: Keyterm limit exceeded. The maximum number of tokens across all keyterms is 500.",
},
{ status: 400 },
);
upstream.mockResolvedValueOnce(keytermLimit());
expect(yield* transcribe("en")).toBe("hello");
const [limited, retried] = upstream.mock.calls.slice(-2).map(([url]) => new URL(String(url)));
expect(limited!.searchParams.getAll("keyterm")).toEqual(["T3 Code"]);
expect(retried!.searchParams.has("keyterm")).toBe(false);
upstream.mockResolvedValueOnce(new Response("Bad Request", { status: 400 }));
expect(yield* Effect.flip(transcribe("en"))).toMatchObject({ reason: "unavailable" });
upstream.mockResolvedValueOnce(new Response(null, { status: 402 }));
expect(yield* Effect.flip(transcribe("en"))).toMatchObject({
provider: "deepgram",
reason: "rejected-key",
});
upstream.mockResolvedValueOnce(Response.json({ results: { channels: [] } }));
expect(yield* Effect.flip(transcribe("en"))).toMatchObject({ reason: "unavailable" });
// Removing the key during the first upload must stop the keyterm-free retry.
const uploadStarted = Promise.withResolvers<void>();
const rejection = Promise.withResolvers<Response>();
upstream.mockImplementationOnce(() => {
uploadStarted.resolve();
return rejection.promise;
});
const uploads = upstream.mock.calls.length;
const job = yield* transcribe("en").pipe(Effect.forkChild);
yield* Effect.promise(() => uploadStarted.promise);
yield* service.setKey("deepgram", null);
rejection.resolve(keytermLimit());
expect(yield* Effect.flip(Fiber.join(job))).toMatchObject({ reason: "missing-key" });
expect(upstream).toHaveBeenCalledTimes(uploads + 1);
}),
);

it.effect("removing a key while a catalog read is pending prevents the upload", () =>
Effect.gen(function* () {
const service = yield* CloudSpeech.CloudSpeech.pipe(
Expand Down
87 changes: 69 additions & 18 deletions apps/server/src/speech/CloudSpeech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,22 @@ const endpoints = {
openai: { url: "https://api.openai.com/v1/audio/transcriptions", model: "gpt-transcribe" },
xai: { url: "https://api.x.ai/v1/stt", model: "grok-voice-transcribe-2.0" },
openrouter: { url: "https://openrouter.ai/api/v1/audio/transcriptions" },
deepgram: { url: "https://api.deepgram.com/v1/listen", model: "nova-3" },
};
const OPENROUTER_CATALOG_URL =
"https://openrouter.ai/api/v1/models?output_modalities=transcription&zdr=true";
const isApiKey = Schema.is(CloudSpeechApiKey);
const isModelId = Schema.is(CloudSpeechModelId);
const sameCredential = Schema.toEquivalence(SettingsSecretValue);
const DeepgramResult = Schema.Struct({
results: Schema.Struct({
channels: Schema.NonEmptyArray(
Schema.Struct({
alternatives: Schema.NonEmptyArray(Schema.Struct({ transcript: Schema.String })),
}),
),
}),
});
const OpenRouterCatalog = Schema.Struct({
data: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.optionalKey(Schema.String) })),
});
Expand Down Expand Up @@ -101,6 +111,20 @@ function openRouterBody(model: string, audio: ArrayBuffer, language: string) {
});
}

function deepgramUrl(language: string, options: SpeechTranscriptionOptions, keyterms: boolean) {
const url = new URL(endpoints.deepgram.url);
url.searchParams.set("model", endpoints.deepgram.model);
url.searchParams.set("smart_format", "true");
url.searchParams.set("redact_usage", "true");
// `multi` transcribes speech that switches languages, such as English terms in other speech.
url.searchParams.set("language", language === "auto" ? "multi" : language);
if (keyterms)
for (const { term } of options.speechCustomWords) url.searchParams.append("keyterm", term);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (!options.speechRemoveFillerWords || options.speechCorrectionWord)
url.searchParams.set("filler_words", "true");
return url;
}

export class CloudSpeech extends Context.Service<
CloudSpeech,
{
Expand Down Expand Up @@ -130,10 +154,11 @@ export const layer = Layer.effect(
Effect.map((value) => value.cloudSpeechCredentials),
);
const configured = credentials.pipe(
Effect.map(({ openai, xai, openrouter }) => ({
Effect.map(({ openai, xai, openrouter, deepgram }) => ({
openai: Boolean(openai),
xai: Boolean(xai),
openrouter: Boolean(openrouter),
deepgram: Boolean(deepgram),
})),
);
const execute = (request: HttpClientRequest.HttpClientRequest) =>
Expand Down Expand Up @@ -208,21 +233,42 @@ export const layer = Layer.effect(
);
if (!isApiKey(key)) return yield* unreadable();
const audio = wave(samples);
const request = HttpClientRequest.post(endpoints[provider].url).pipe(
HttpClientRequest.bearerToken(Redacted.make(key)),
provider === "openrouter"
? openRouterBody(model!, audio, language)
: audioBody(provider, audio, language, options),
);
const deepgramRequest = (keyterms: boolean) =>
HttpClientRequest.post(deepgramUrl(language, options, keyterms)).pipe(
HttpClientRequest.setHeader("authorization", `Token ${key}`),
HttpClientRequest.bodyUint8Array(new Uint8Array(audio), "audio/wav"),
);
const request =
provider === "deepgram"
? deepgramRequest(true)
: HttpClientRequest.post(endpoints[provider].url).pipe(
HttpClientRequest.bearerToken(Redacted.make(key)),
provider === "openrouter"
? openRouterBody(model!, audio, language)
: audioBody(provider, audio, language, options),
);
const unavailable = () => new CloudSpeechError({ provider, reason: "unavailable" });
// A catalog fetch or vault lookup may have outlived the credential it started with.
const current = (yield* credentials)[provider];
if (!current || !sameCredential(source, current))
return yield* new CloudSpeechError({
provider,
reason: current ? "key-changed" : "missing-key",
});
const response = yield* execute(request).pipe(Effect.mapError(unavailable));
// A catalog fetch, vault lookup, or earlier upload may have outlived the credential.
const ensureCurrentKey = Effect.gen(function* () {
const current = (yield* credentials)[provider];
if (!current || !sameCredential(source, current))
return yield* new CloudSpeechError({
provider,
reason: current ? "key-changed" : "missing-key",
});
});
yield* ensureCurrentKey;
let response = yield* execute(request).pipe(Effect.mapError(unavailable));
// Deepgram caps keyterms in tokens clients cannot count; the dictionary still fixes the text below.
if (
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
provider === "deepgram" &&
response.status === 400 &&
options.speechCustomWords.length > 0 &&
(yield* response.text.pipe(Effect.orElseSucceed(() => ""))).includes("Keyterm limit")
) {
yield* ensureCurrentKey;
response = yield* execute(deepgramRequest(false)).pipe(Effect.mapError(unavailable));
}
if (response.status < 200 || response.status >= 300)
return yield* new CloudSpeechError({
provider,
Expand All @@ -235,9 +281,14 @@ export const layer = Layer.effect(
? "model-unavailable"
: "unavailable",
});
const { text } = yield* HttpClientResponse.schemaBodyJson(
EnvironmentSpeechTranscriptionResult,
)(response).pipe(Effect.mapError(unavailable));
const text =
provider === "deepgram"
? (yield* HttpClientResponse.schemaBodyJson(DeepgramResult)(response).pipe(
Effect.mapError(unavailable),
)).results.channels[0].alternatives[0].transcript
: (yield* HttpClientResponse.schemaBodyJson(EnvironmentSpeechTranscriptionResult)(
response,
).pipe(Effect.mapError(unavailable))).text;
const corrected = applySpeechAliases(text, options.speechCustomWords);
return (
options.speechRemoveFillerWords
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,14 @@ it("cancelling a provider's setup keeps the current destination and writes nothi
const destination = screen.getByRole("combobox", { name: "Transcribe with" });
await expect.element(destination).toHaveTextContent("Laptop");
await destination.click();
await page.getByRole("option", { name: "OpenAI via Laptop" }).click();
await page.getByRole("option", { name: "Deepgram via Laptop" }).click();
const dialog = page.getByRole("dialog");
await expect
.element(dialog.getByText("Update Laptop to use Deepgram dictation."))
.toBeVisible();
await dialog.getByRole("button", { name: "Cancel" }).click();
await destination.click();
await page.getByRole("option", { name: "OpenAI via Laptop" }).click();
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect.element(dialog).not.toBeInTheDocument();
await expect.element(destination).toHaveTextContent("Laptop");
Expand Down
11 changes: 5 additions & 6 deletions apps/web/src/components/settings/CloudVoiceConfiguration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ export function CloudVoiceConfiguration({
const destination = (): CloudDestination =>
routed && isModelId(model)
? { kind: "cloud", provider: "openrouter", environmentId: id, model }
: { kind: "cloud", provider: provider as "openai" | "xai", environmentId: id };
: { kind: "cloud", provider: provider as "openai" | "xai" | "deepgram", environmentId: id };
const enable = () => chooseAudioDestination(destination());
const canSubmit = routed
? modelChosen && keyReady && (!active || modelChanged || keyChanged)
Expand Down Expand Up @@ -343,11 +343,10 @@ export function CloudVoiceConfiguration({
<Button size="sm" variant="outline" onClick={refresh}>
Retry loading API key
</Button>
) : routed &&
AsyncResult.isSuccess(presence) &&
presence.value &&
saved === undefined ? (
<p>Update {environment.label} to use OpenRouter dictation.</p>
) : AsyncResult.isSuccess(presence) && presence.value && saved === undefined ? (
<p>
Update {environment.label} to use {name} dictation.
</p>
) : saved === undefined ? (
<p role="status">Checking for an API key…</p>
) : routed ? (
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/settings/VoiceSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"

const OFF = "off";
function CloudSpeechLogo({ provider }: { provider: CloudSpeechProvider }) {
if (provider === "deepgram") return <AudioLinesIcon aria-hidden className="size-4 shrink-0" />;
if (provider === "openrouter")
return <ProviderPackageIcon icon={openRouterIcon} aria-hidden className="size-4 shrink-0" />;
return (
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Transcribe with",
to: "/settings/voice",
searchTerms: [
"send microphone audio dictation destination speech voice stt transcription environment machine consent privacy cloud OpenAI Grok OpenRouter",
"send microphone audio dictation destination speech voice stt transcription environment machine consent privacy cloud OpenAI Grok OpenRouter Deepgram",
],
},
{
Expand Down
4 changes: 2 additions & 2 deletions docs/user/voice-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ Set it back to **Off** at any time; a recording in progress is discarded.
If the chosen environment goes offline, voice input waits for it. T3 Code never
substitutes another environment.

For cloud dictation, choose **OpenAI** or **Grok Voice Transcribe** via the environment
you want to use. If it needs a key, enter one and choose **Save and enable**.
For cloud dictation, choose **OpenAI**, **Grok Voice Transcribe**, or **Deepgram** via the
environment you want to use. If it needs a key, enter one and choose **Save and enable**.
You can also choose a [secret provider](secrets.md) instead of **Value** and enter a
reference. Unlock the vault on that environment before dictating. Anyone with
dictation access to the environment can use the key, but only this device opts in.
Expand Down
Loading