Skip to content
Merged
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
4 changes: 4 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,10 @@ interface Window {
message?: string;
error?: string;
}>;
/** Whether speech to text can run. `ready` false means the helper binary
* was not built or not packaged; transcription and captions are the only
* things affected. */
sttReadiness: () => Promise<{ ready: boolean; backend: string; path: string | null }>;
preparePreviewAudioTrack: (filePath: string) => Promise<{
success: boolean;
path?: string | null;
Expand Down
5 changes: 5 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
readFileChunk: (filePath: string, offset: number, length: number) => {
return ipcRenderer.invoke("read-file-chunk", filePath, offset, length);
},
/** Whether speech to text can run — asked at launch, so the UI can say so
* before somebody relies on it rather than after they press transcribe. */
sttReadiness: () => {
return ipcRenderer.invoke("stt:readiness");
},
preparePreviewAudioTrack: (filePath: string) => {
return ipcRenderer.invoke("prepare-preview-audio-track", filePath);
},
Expand Down
48 changes: 48 additions & 0 deletions electron/stt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,8 +440,56 @@ export function _resetSttManagerForTests(): void {
* fan out on `"stt:status"` (main → renderer push), scoped to the calling
* `webContents` so two windows don't cross-talk.
*/
/**
* Whether speech to text can run at all, resolved once at launch.
*
* The binary is found or not found by `resolveBinaryPath()` at the moment
* something asks for a transcript — so a build shipped without it looked
* completely healthy until somebody pressed transcribe, minutes into a session,
* and got a failure for a reason that had been true since startup.
*
* This asks the question at launch instead and writes the answer down. It does
* NOT build anything: that needs cmake, a C++ toolchain and three git clones
* from GitHub, and compiling whisper.cpp with Metal is minutes of CPU — work
* nobody would choose to wait through while an app opens, and work that simply
* fails on a machine without the toolchain. Being able to SAY it is missing is
* the part that belongs here.
*/
export async function checkSttReadiness(): Promise<{
ready: boolean;
backend: string;
path: string | null;
}> {
const { resolveBinaryPath } = await import("./gpuDetector");
const resolved = await resolveBinaryPath();
if (resolved.path) {
console.info(`[stt] ready — ${resolved.backend} at ${resolved.path}`);
} else {
// One line, not a throw: no transcription is a missing feature, not a
// reason the app should fail to start. Everything else still works.
const { missingBinaryMessage } = await import("./whisperServer");
console.warn(`[stt] unavailable — ${missingBinaryMessage()}`);
}
return { ready: Boolean(resolved.path), backend: resolved.backend, path: resolved.path };
}

export function registerSttIpc(ipcMain: IpcMain): void {
const manager = getSttManager();
/*
* Asked at launch, so the renderer can say "transcription is unavailable"
* before somebody relies on it rather than after. Not awaited: the probe is a
* few stat() calls, and registering IPC must not wait on the filesystem.
*/
let readiness: Promise<{ ready: boolean; backend: string; path: string | null }> | null =
checkSttReadiness();
ipcMain.handle("stt:readiness", async () => {
// Re-probed if the first answer was "missing": somebody may have built it
// since, and a cached no would outlive the fix for the whole session.
const current = await readiness;
if (current?.ready) return current;
readiness = checkSttReadiness();
return readiness;
});
ipcMain.handle(
"stt:transcribe",
async (event, req: SttTranscribeRequest): Promise<SttTranscribeResponse> => {
Expand Down
70 changes: 70 additions & 0 deletions electron/stt/readiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Speech to text is a feature that can be absent from a build, and the two
// things that made that hard to live with were (a) nothing noticed until
// somebody pressed transcribe, and (b) what they were then told was a developer
// instruction. These cover both.

import { afterEach, describe, expect, it, vi } from "vitest";

vi.mock("electron", () => ({ app: { isPackaged: false }, ipcMain: { handle: vi.fn() } }));

afterEach(() => {
vi.resetModules();
vi.doUnmock("./gpuDetector");
});

describe("checkSttReadiness", () => {
it("reports ready, with the backend and the path it found", async () => {
vi.doMock("./gpuDetector", () => ({
resolveBinaryPath: vi.fn(async () => ({
backend: "whispercpp-metal",
path: "/x/whisper-stt-server",
})),
}));
const { checkSttReadiness } = await import("./index");
await expect(checkSttReadiness()).resolves.toEqual({
ready: true,
backend: "whispercpp-metal",
path: "/x/whisper-stt-server",
});
});

// A missing helper is a missing feature, not a reason the app should fail to
// start: everything that is not transcription or captions still works.
it("reports not-ready without throwing when the binary is absent", async () => {
vi.doMock("./gpuDetector", () => ({
resolveBinaryPath: vi.fn(async () => ({ backend: "whispercpp-cpu", path: null })),
}));
const warn = vi.spyOn(console, "warn").mockImplementation(() => {
// Swallowed: the point of the assertion below is that it was called.
});
const { checkSttReadiness } = await import("./index");
await expect(checkSttReadiness()).resolves.toMatchObject({ ready: false, path: null });
// One line, so the log says why before anyone presses transcribe.
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toMatch(/\[stt\] unavailable/);
warn.mockRestore();
});
});

describe("missingBinaryMessage", () => {
it("tells a developer what to run", async () => {
const { missingBinaryMessage } = await import("./whisperServer");
expect(missingBinaryMessage(false)).toMatch(/npm run build:whisper-binaries/);
});

/*
* And tells an end user what happened instead.
*
* The packaged message must not name a script: the reader has no repo, and the
* build needs cmake and a C++ toolchain besides — so the old sentence asked
* them to do something impossible rather than saying what was wrong.
*/
it("tells an end user what happened, naming no script", async () => {
const { missingBinaryMessage } = await import("./whisperServer");
const message = missingBinaryMessage(true);
expect(message).toMatch(/not available in this build/);
expect(message).not.toMatch(/\.sh|npm run|cmake/);
// And it says what is affected, so nobody reads it as "the app is broken".
expect(message).toMatch(/Transcription and captions/);
});
});
35 changes: 33 additions & 2 deletions electron/stt/whisperServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,38 @@ interface WhisperJsonResponse {
timing?: WhisperJsonTiming;
}

/** Packaged or not, read defensively: this module is imported by unit tests and
* by scripts, where Electron's `app` does not exist. Unknown means developer. */
function isPackagedApp(): boolean {
try {
const { app } = require("electron") as typeof import("electron");
return Boolean(app?.isPackaged);
} catch {
return false;
}
}

/**
* Why there is no speech-to-text, phrased for whoever is reading it.
*
* This sentence reaches a toast. The old one — "build it via
* scripts/build-whisper-stt.sh" — is a developer instruction, and in a packaged
* app it names a script the reader does not have and could not run: the repo is
* not on their disk, and the build needs cmake and a C++ toolchain besides. It
* told them to do something impossible instead of telling them what happened.
*
* `app.isPackaged` is the only thing that separates the two audiences. It is
* taken as a parameter rather than only read here so both sentences are reachable
* from a test: the read goes through `require("electron")` — the same lazy
* pattern gpuDetector.ts uses, so this module stays importable outside Electron —
* and a module-level require is not something a test double can intercept.
*/
export function missingBinaryMessage(packaged: boolean = isPackagedApp()): string {
return packaged
? "Speech to text is not available in this build: its helper was not packaged. Transcription and captions need it; nothing else is affected."
: "whisper-stt-server binary not found. Build it with `npm run build:whisper-binaries` (needs cmake), or stage a CI build with `bash scripts/stage-whisper-stt.sh <tag>`.";
}

export class WhisperServerManager {
private process: WhisperChild | null = null;
private shuttingDown = false;
Expand Down Expand Up @@ -236,8 +268,7 @@ export class WhisperServerManager {
: await resolveBinaryPath();
const binaryPath = resolved.path;
if (!binaryPath) {
const message =
"whisper-stt-server binary not found; build it via scripts/build-whisper-stt.sh";
const message = missingBinaryMessage();
this.recordError(message);
throw new Error(message);
}
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,17 @@
"assets:appx": "node scripts/generate-appx-assets.mjs",
"preview": "vite preview",
"build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs",
"build:mac": "npm run build:native:mac && npm run fetch:ffmpeg:mac && npm run build:native:compositor:mac && tsc && vite build && electron-builder --mac",
"build:mac": "npm run whisper:ensure && npm run build:native:mac && npm run fetch:ffmpeg:mac && npm run build:native:compositor:mac && tsc && vite build && electron-builder --mac",
"build:native:win": "node scripts/build-windows-wgc-helper.mjs",
"stage:vcomp": "node scripts/stage-vcomp-runtime.mjs",
"build:native:compositor": "node scripts/build-windows-compositor-addon.mjs",
"build:native:compositor:mac": "node scripts/build-macos-compositor-addon.mjs",
"build:native:compositor:linux": "node scripts/build-linux-compositor-addon.mjs",
"build:native:linux": "node scripts/build-linux-pipewire-helper.mjs",
"build:win": "npm run build:native:win && npm run fetch:ffmpeg && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win --config.npmRebuild=false",
"build:win": "npm run whisper:ensure && npm run build:native:win && npm run fetch:ffmpeg && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win --config.npmRebuild=false",
"build:win:store": "npm run build:native:win && npm run fetch:ffmpeg && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win appx --config.npmRebuild=false",
"build:linux": "npm run fetch:ffmpeg:sdk && npm run build:native:linux && npm run build:native:compositor:linux && tsc && vite build && electron-builder --linux AppImage deb pacman rpm --config.npmRebuild=false",
"build:linux": "npm run whisper:ensure && npm run fetch:ffmpeg:sdk && npm run build:native:linux && npm run build:native:compositor:linux && tsc && vite build && electron-builder --linux AppImage deb pacman rpm --config.npmRebuild=false",
"whisper:ensure": "node scripts/ensure-whisper-stt.mjs",
"build:whisper-binaries": "bash scripts/build-whisper-stt.sh",
"test:whisper-stt": "node scripts/test-whisper-stt.mjs",
"test": "vitest --run",
Expand Down
138 changes: 138 additions & 0 deletions scripts/ensure-whisper-stt.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env node
/*
* Make sure the speech-to-text helper is present before an installer is built.
*
* WHY THIS EXISTS. `electron/native/bin/` is gitignored, and the two things that
* can put `whisper-stt-server` in it were wired into nothing:
*
* scripts/build-whisper-stt.sh compiles whisper.cpp here, from source
* scripts/stage-whisper-stt.sh downloads what build-whisper-stt.yml built in CI
*
* `build:whisper-binaries` existed as an npm script and no build called it, and
* the staging script was not an npm script at all. So `build:mac` — which builds
* the ScreenCaptureKit helper, fetches ffmpeg and builds the compositor addon —
* produced an app with no STT binary in it, and the failure surfaced much later
* as a toast telling an end user to run a shell script from a repo they do not
* have. The staging script's own header describes this; nothing had connected it.
*
* Deliberately NOT run at app launch. Compiling whisper.cpp needs cmake, a C++
* toolchain and three git clones, and the Metal build is minutes of CPU: work
* nobody would choose to wait through while an app opens, and work that fails
* outright on a machine without the toolchain. Launch's job is to notice the
* binary is missing and say so — see checkSttReadiness() in electron/stt/index.ts.
*
* Order of preference:
* 1. Already staged — nothing to do, and the common case.
* 2. A CI artifact — same provenance as a release, so prefer it.
* 3. Compile it here — needs cmake; the fallback for a local build.
*
* Exits non-zero when it cannot produce one. A release without speech to text is
* worse than a red build, and the failure this replaces was silent.
*/

import { spawnSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { arch, platform } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");

/** The same `<os>-<arch>` tag gpuDetector.ts builds its candidate paths from. */
function hostTag() {
const a = arch() === "arm64" ? "arm64" : "x64";
if (platform() === "darwin") return `darwin-${a}`;
if (platform() === "win32") return "win32-x64";
return "linux-x64";
}

const TAG = hostTag();
const BIN_DIR = path.join(ROOT, "electron", "native", "bin", TAG);
const EXE = platform() === "win32" ? "whisper-stt-server.exe" : "whisper-stt-server";

const has = () => existsSync(path.join(BIN_DIR, EXE));
const tool = (cmd) => spawnSync(cmd, ["--version"], { stdio: "ignore" }).status === 0;

function run(cmd, args, label) {
console.log(` ${label}…`);
// Inherited stdio on purpose: a cmake build is minutes long, and a silent
// pipe makes it look hung. Whoever started this wants to see it working.
const r = spawnSync(cmd, args, { cwd: ROOT, stdio: "inherit", env: process.env });
return r.status === 0;
}

if (has()) {
console.log(` speech to text: already staged (${path.join("electron/native/bin", TAG, EXE)})`);
process.exit(0);
}

console.log(` speech to text: no ${EXE} for ${TAG} — getting one`);

/*
* A CI artifact first, when the tools for it are here.
*
* Same provenance as a release: build-whisper-stt.yml pins its build hosts, and
* on Linux the glibc the binaries were linked against has to match the floor
* before-pack.cjs enforces. A local compile on a newer distro produces something
* that build then rejects. `gh` and a token are what staging needs; without them
* this is not an error, it is just not the available route.
*/
if (tool("gh") && (process.env.GH_TOKEN || process.env.GITHUB_TOKEN)) {
if (
run("bash", [path.join("scripts", "stage-whisper-stt.sh"), TAG], "staging the CI build") &&
has()
) {
console.log(" speech to text: staged from CI");
process.exit(0);
}
console.log(" staging did not produce a binary — falling back to a local build");
} else {
console.log(" no gh + GH_TOKEN, so no CI artifact to stage — building locally");
}

/*
* Otherwise compile it, and say exactly what is missing when we cannot.
*
* "cmake: command not found" out of a nested build is a long way from the thing
* to do about it, and this is the point where somebody is waiting.
*/
if (!tool("cmake")) {
console.error(`
Cannot build the speech-to-text helper: cmake is not installed.

Either install it and re-run: brew install cmake (macOS)
or stage the build CI already did: export GH_TOKEN=… && npm run whisper:ensure

Shipping without it means transcription and captions do not work in the
installer, and the app can only tell the person using it that they are missing.
`);
process.exit(1);
}

if (
!run(
"bash",
[path.join("scripts", "build-whisper-stt.sh")],
"compiling whisper.cpp (several minutes)",
)
) {
console.error("\n The speech-to-text build failed. See the output above.\n");
process.exit(1);
}

if (!has()) {
// The build reported success and produced nothing where the app looks. Worth
// distinguishing: it means the staging half of build-whisper-stt.sh changed,
// not that the compile is broken.
console.error(`
The build succeeded but ${EXE} is not in ${path.relative(ROOT, BIN_DIR)}.

Present there: ${existsSync(BIN_DIR) ? readdirSync(BIN_DIR).join(", ") || "(empty)" : "(no such directory)"}

gpuDetector.ts looks for it under electron/native/bin/<os>-<arch>/, so a build
that stages it elsewhere is a build the app cannot find.
`);
process.exit(1);
}

console.log(" speech to text: built");
Loading