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
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const layerElectronApp = (calls: ElectronAppCalls) =>
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
systemLocale: Effect.succeed("en-US"),
preferredSystemLanguages: Effect.succeed(["en-US"]),
whenReady: Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
quit: Effect.void,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function layerElectronApp(
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
systemLocale: Effect.succeed("en-US"),
preferredSystemLanguages: Effect.succeed(["en-US"]),
whenReady: Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
quit,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/electron/ElectronApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export class ElectronApp extends Context.Service<
readonly name: Effect.Effect<string>;
/** Packaged builds ship only en-US; app.getLocale() and Intl defaults do not reflect the OS locale. */
readonly systemLocale: Effect.Effect<string>;
readonly preferredSystemLanguages: Effect.Effect<ReadonlyArray<string>>;
readonly whenReady: Effect.Effect<void, ElectronAppWhenReadyError>;
readonly requestSingleInstanceLock: Effect.Effect<boolean>;
readonly quit: Effect.Effect<void>;
Expand Down Expand Up @@ -155,6 +156,7 @@ export const make = ElectronApp.of({
}
return toSystemLocaleTag(identifier, hourCycle);
}),
preferredSystemLanguages: Effect.sync(() => Electron.app.getPreferredSystemLanguages()),
whenReady: Effect.gen(function* () {
const isPackaged = Electron.app.isPackaged;
yield* Effect.tryPromise({
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
getLocalEnvironmentBearerToken,
installRelayTelemetryEnabled,
getSystemLocale,
getPreferredSystemLanguages,
getWindowFullscreenState,
openExternal,
readMedia,
Expand Down Expand Up @@ -94,6 +95,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handleSync(getAppBranding);
yield* installRelayTelemetryEnabled();
yield* ipc.handleSync(getSystemLocale);
yield* ipc.handleSync(getPreferredSystemLanguages);
yield* ipc.handleSync(getWindowFullscreenState);
yield* ipc.handleSync(getLocalEnvironmentBootstraps);
yield* ipc.handleSync(getLocalEnvironmentEnabled);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall";
export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled";
export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";
export const GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL = "desktop:get-preferred-system-languages";
export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";
export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled";
export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled";
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({
}),
});

export const getPreferredSystemLanguages = DesktopIpc.makeSyncIpcMethod({
channel: IpcChannels.GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL,
result: Schema.Array(Schema.String),
handler: Effect.fn("desktop.ipc.window.getPreferredSystemLanguages")(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
return yield* electronApp.preferredSystemLanguages;
}),
});

export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({
channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL,
result: Schema.Boolean,
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
return typeof result === "string" ? result : null;
},
getPreferredSystemLanguages: () => {
const result: unknown = ipcRenderer.sendSync(
IpcChannels.GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL,
);
return Array.isArray(result) ? result.filter((tag) => typeof tag === "string") : [];
},
getLocalEnvironmentBootstraps: () => {
const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL);
if (!Array.isArray(result)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function layerElectronApp(
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
systemLocale: Effect.succeed("en-US"),
preferredSystemLanguages: Effect.succeed(["en-US"]),
whenReady: Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
quit: Effect.void,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const layerElectronApp = Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
systemLocale: Effect.succeed("en-US"),
preferredSystemLanguages: Effect.succeed(["en-US"]),
whenReady: Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
quit: Effect.void,
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3709,6 +3709,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
step={speechInput.setup.step}
status={speechInput.status}
model={speechInput.setup.model}
language={speechInput.setup.language}
downloading={speechInput.setup.downloading}
error={speechInput.setup.error}
onOpenChange={speechInput.setup.setOpen}
Expand Down
17 changes: 11 additions & 6 deletions apps/web/src/components/chat/VoiceInputSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useClientSettings, updateClientSettings } from "../../hooks/useSettings
import { useEnvironment } from "../../state/environments";
import { AuthSettingsWriteScope } from "@t3tools/contracts";
import { useEnvironmentScope } from "../../state/session";
import { languageLabel } from "../settings/SpeechLanguageSelect";
import { Button } from "../ui/button";
import { Dialog } from "../ui/dialog";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
Expand All @@ -24,6 +25,7 @@ export function VoiceInputSetup(props: {
step: number;
status: EnvironmentSpeechStatus | null;
model: EnvironmentSpeechModel | null;
language: string | null;
downloading: boolean;
error: string | null;
onOpenChange(open: boolean): void;
Expand Down Expand Up @@ -66,6 +68,7 @@ export function VoiceInputSetup(props: {
: "System default";
const status = props.status?.supported ? props.status : null;
const model = props.model;
const size = model?.size ?? status?.size;
const progress =
model?.downloaded === undefined ? null : Math.round((model.downloaded / model.size) * 100);
const openSettings = (hash: string) => {
Expand All @@ -87,17 +90,19 @@ export function VoiceInputSetup(props: {
<section className="space-y-3 text-sm">
<div className="rounded-lg border border-border/70 bg-card/60 p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium">{status?.model ?? "English transcription model"}</h3>
{status?.modelId === "handy-computer/parakeet-unified-en-0.6b-gguf" ? (
<h3 className="font-medium">
{model?.name ?? status?.model ?? "English transcription model"}
</h3>
{model?.recommended && props.language ? (
<span className="text-xs font-medium text-primary">
Recommended for English
Recommended for {languageLabel(props.language)}
</span>
) : null}
</div>
<p className="mt-2 text-muted-foreground">
Download the {status ? Math.round(status.size / 1024 / 1024) : 697} MB model to{" "}
{environment?.label ?? "your transcription environment"}. Microphone audio is sent
there for transcription, as chosen in Voice settings.
Download the {size === undefined ? 697 : Math.round(size / 1024 / 1024)} MB model
to {environment?.label ?? "your transcription environment"}. Microphone audio is
sent there for transcription, as chosen in Voice settings.
</p>
<p className="mt-2 text-muted-foreground">
Silero (1.2 MB), used for silence filtering and pause detection, downloads
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export function PreviewAnnotationSpeech({
step={speech.setup.step}
status={speech.status}
model={speech.setup.model}
language={speech.setup.language}
downloading={speech.setup.downloading}
error={speech.setup.error}
onOpenChange={speech.setup.setOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ it("preserves the model test transcript when its model is filtered out", async (
open: false,
step: 0,
model: null,
language: null,
downloading: false,
error: null,
setOpen: () => {},
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/settings/VoiceSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react";

import { useStableCallback } from "~/hooks/useStableCallback";
import { listBrowserMicrophones, type BrowserMicrophone } from "../../speech/browserMicrophones";
import { preferredSpeechLanguages } from "../../speech/preferredSpeechLanguages";
import {
chooseAudioDestination,
chooseTranscriptCleanup,
Expand Down Expand Up @@ -730,7 +731,7 @@ function SpeechModelBrowser(props: {
const [downloadedOnly, setDownloadedOnly] = useState(false);
const [languageSearch, setLanguageSearch] = useState("");
const [selection, setSelection] = useState<string[] | null>(null);
const preferred = navigator.languages.map((language) => language.toLowerCase().split("-")[0]!);
const [preferred] = useState(preferredSpeechLanguages);
const supportedLanguages = [...new Set(models.flatMap((model) => model.languages))];
const defaultLanguage = preferred.find((code) => supportedLanguages.includes(code));
const selectedLanguages = selection ?? (defaultLanguage ? [defaultLanguage] : []);
Expand Down Expand Up @@ -1047,6 +1048,7 @@ function TranscriptionTest(props: {
step={speech.setup.step}
status={speech.status}
model={speech.setup.model}
language={speech.setup.language}
downloading={speech.setup.downloading}
error={speech.setup.error}
onOpenChange={speech.setup.setOpen}
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/speech/preferredSpeechLanguages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Packaged desktop builds report only en-US in navigator.languages, so the OS languages lead.
export const preferredSpeechLanguages = () =>
[...(window.desktopBridge?.getPreferredSystemLanguages() ?? []), ...navigator.languages].map(
(tag) => tag.toLowerCase().split(/[-_.@]/)[0]!,
);
116 changes: 112 additions & 4 deletions apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import {
DEFAULT_CLIENT_SETTINGS,
DEFAULT_SERVER_SETTINGS,
type EnvironmentId,
type EnvironmentSpeechModel,
type ProjectId,
type SpeechCustomWords,
type SpeechPostProcessingOptions,
type SpeechTranscriptionOptions,
} from "@t3tools/contracts";

import { act, render } from "../../test/render";
import { act, render, stubGlobal } from "../../test/render";
import { useEnvironmentSpeechInput } from "./useEnvironmentSpeechInput";
import { chooseAudioDestination, chooseTranscriptCleanup, bindAudioGrant } from "./voiceConsent";
import { writeBrowserClientSettings } from "../clientPersistenceStorage";
Expand All @@ -29,7 +30,10 @@ const mocks = vi.hoisted(() => ({
statusRequests: [] as string[],
statusUnavailable: false,
modelState: "ready" as "ready" | "missing-model",
download: vi.fn<() => Promise<void>>(),
download: vi.fn<(modelId: string) => Promise<void>>(),
models: [] as EnvironmentSpeechModel[],
selected: [] as string[],
cancelled: [] as string[],
platforms: [] as string[],
microphoneRequests: [] as string[],
transcribeCalls: [] as string[],
Expand Down Expand Up @@ -109,8 +113,20 @@ vi.mock("@t3tools/client-runtime/voice-input", async (importOriginal) => ({
gpuDevices: [],
};
}),
downloadEnvironmentSpeechModel: () => Effect.promise(mocks.download),
getEnvironmentSpeechModels: () => Effect.succeed({ models: [] }),
downloadEnvironmentSpeechModel: (_prepared: PreparedConnection, modelId: string) =>
Effect.promise(async () => {
await mocks.download(modelId);
return { models: mocks.models };
}),
selectEnvironmentSpeechModel: (_prepared: PreparedConnection, modelId: string) =>
Effect.sync(() => {
mocks.selected.push(modelId);
}),
cancelEnvironmentSpeechModelDownload: (_prepared: PreparedConnection, modelId: string) =>
Effect.sync(() => {
mocks.cancelled.push(modelId);
}),
getEnvironmentSpeechModels: () => Effect.succeed({ models: mocks.models }),
postProcessEnvironmentTranscript: (
prepared: PreparedConnection,
transcript: string,
Expand Down Expand Up @@ -177,6 +193,9 @@ function setup(options: { readonly destination?: string | null } = {}) {
mocks.statusUnavailable = false;
mocks.modelState = "ready";
mocks.download.mockReset();
mocks.models = [];
mocks.selected = [];
mocks.cancelled = [];
mocks.platforms = [];
mocks.microphoneRequests = [];
mocks.transcribeCalls = [];
Expand Down Expand Up @@ -339,6 +358,95 @@ it("reopens setup after reconnecting during a download and ignores its stale com
expect(mocks.voice.setup.error).toBeNull();
});

const speechModel = (
id: string,
languages: string[],
state: EnvironmentSpeechModel["state"] = "downloadable",
): EnvironmentSpeechModel => ({
id,
name: id,
description: "",
size: 1,
languages,
accuracy: 0,
speed: 0,
recommended: true,
supportsStreaming: false,
supportsLanguageDetection: true,
supportsTranslation: false,
active: false,
state,
});

it("offers a model for the desktop's system languages and selects it only once it is installed", async () => {
setup({ destination: "env-a" });
mocks.modelState = "missing-model";
mocks.models = [speechModel("test-model", ["en"]), speechModel("multilingual", ["en", "de"])];
let finishDownload!: () => void;
mocks.download.mockReturnValue(
new Promise<void>((resolve) => {
finishDownload = resolve;
}),
);
await using _screen = await render(<Probe />);
using _bridge = stubGlobal("desktopBridge", { getPreferredSystemLanguages: () => ["de-DE"] });
await act(() => mocks.voice.start());
expect(mocks.voice.setup).toMatchObject({
open: true,
language: "de",
model: { id: "multilingual" },
});
let done: Promise<void> | undefined;
await act(() => {
done = mocks.voice.setup.download();
});
expect(mocks.download).toHaveBeenCalledExactlyOnceWith("multilingual");
expect(mocks.selected).toEqual([]);
mocks.models = [
speechModel("test-model", ["en"]),
speechModel("multilingual", ["en", "de"], "installed"),
];
mocks.modelState = "ready";
finishDownload();
await act(async () => {
await done;
});
expect(mocks.selected).toEqual(["multilingual"]);
expect(mocks.voice.setup).toMatchObject({ step: 1, downloading: false, error: null });
});

it.each([
{ by: "setup", cancelled: ["multilingual"] },
{ by: "another window", cancelled: [] },
])(
"does not select the recommended model when $by cancels its download",
async ({ by, cancelled }) => {
setup({ destination: "env-a" });
mocks.modelState = "missing-model";
mocks.models = [speechModel("test-model", ["en"]), speechModel("multilingual", ["en", "de"])];
let finishDownload!: () => void;
mocks.download.mockReturnValue(
new Promise<void>((resolve) => {
finishDownload = resolve;
}),
);
await using _screen = await render(<Probe />);
using _bridge = stubGlobal("desktopBridge", { getPreferredSystemLanguages: () => ["de-DE"] });
await act(() => mocks.voice.start());
let done: Promise<void> | undefined;
await act(() => {
done = mocks.voice.setup.download();
});
if (by === "setup") await act(() => mocks.voice.setup.cancelDownload());
finishDownload();
await act(async () => {
await done;
});
expect(mocks.cancelled).toEqual(cancelled);
expect(mocks.selected).toEqual([]);
},
);

it("does not resend a failed recording after consent is revoked", async () => {
setup({ destination: "env-a" });
mocks.transcripts = [new Error("inference failed")];
Expand Down
Loading
Loading