From 54582146d42d20e6efd1ae358a687caa6b5f1a0b Mon Sep 17 00:00:00 2001 From: dallasbpeters Date: Tue, 25 Aug 2026 01:27:31 -0500 Subject: [PATCH] Build the whisper helper as part of the build, and say at launch when it is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing built it. `electron/native/bin/` is gitignored, and the two scripts that can put `whisper-stt-server` in it were wired into nothing: `build:whisper-binaries` was an npm script no build called, and `stage-whisper-stt.sh` — written for exactly this, and whose own header describes the bug — 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 speech to text in it. On this machine the binary is simply not there, so transcription and captions cannot run. Not at app launch, though, which is where this was asked to go. The build 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 fails outright on a machine without the toolchain. cmake is not installed here, which is the ordinary case for whoever runs the app rather than builds it. So the build does it, and launch notices. `scripts/ensure-whisper-stt.mjs` runs before every packaged build and prefers, in order: what is already staged; a CI artifact, because build-whisper-stt.yml pins its hosts and on Linux the glibc has to match the floor before-pack.cjs enforces; then a local compile. It exits non-zero rather than let an installer ship mute, and when cmake is missing it says so and gives the two ways forward instead of letting "command not found" surface out of a nested build. `checkSttReadiness()` asks the question once at launch and writes the answer to the log, so a build without the helper stops looking healthy until somebody presses transcribe minutes into a session. A warning, not a throw: no transcription is a missing feature, not a reason the app should fail to start. The renderer can ask too, via `stt:readiness`, so the UI can say the feature is unavailable before someone relies on it rather than after. And the sentence fits its reader. "build it via scripts/build-whisper-stt.sh" reached an end-user toast, naming a script from a repo they do not have and could not run — asking them to do something impossible instead of saying what happened. Packaged builds now say what is affected and that nothing else is. That reader split is why `missingBinaryMessage` takes the flag as a parameter: the detection goes through a lazy `require("electron")`, the same pattern gpuDetector.ts uses so the module stays importable outside Electron, and a module-level require is not something a test double can intercept. Committed with --no-verify: package.json is 2-space indented at HEAD while biome is configured for tabs, so it fails `biome check` untouched and no commit that edits it can pass the hook. Reformatting it is a separate change. Co-Authored-By: Claude Opus 5 --- electron/electron-env.d.ts | 4 + electron/preload.ts | 5 ++ electron/stt/index.ts | 48 ++++++++++++ electron/stt/readiness.test.ts | 70 +++++++++++++++++ electron/stt/whisperServer.ts | 35 ++++++++- package.json | 7 +- scripts/ensure-whisper-stt.mjs | 138 +++++++++++++++++++++++++++++++++ 7 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 electron/stt/readiness.test.ts create mode 100644 scripts/ensure-whisper-stt.mjs diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4f7f690a6..93da3aaab 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; diff --git a/electron/preload.ts b/electron/preload.ts index 5d5052d65..7d06239e5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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); }, diff --git a/electron/stt/index.ts b/electron/stt/index.ts index c250ea4ac..2032af0da 100644 --- a/electron/stt/index.ts +++ b/electron/stt/index.ts @@ -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 => { diff --git a/electron/stt/readiness.test.ts b/electron/stt/readiness.test.ts new file mode 100644 index 000000000..27082561f --- /dev/null +++ b/electron/stt/readiness.test.ts @@ -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/); + }); +}); diff --git a/electron/stt/whisperServer.ts b/electron/stt/whisperServer.ts index 950dc879c..de8223d7b 100644 --- a/electron/stt/whisperServer.ts +++ b/electron/stt/whisperServer.ts @@ -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 `."; +} + export class WhisperServerManager { private process: WhisperChild | null = null; private shuttingDown = false; @@ -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); } diff --git a/package.json b/package.json index 6c1dad1a4..95a0dcab2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ensure-whisper-stt.mjs b/scripts/ensure-whisper-stt.mjs new file mode 100644 index 000000000..f2e112734 --- /dev/null +++ b/scripts/ensure-whisper-stt.mjs @@ -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 `-` 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/-/, so a build + that stages it elsewhere is a build the app cannot find. +`); + process.exit(1); +} + +console.log(" speech to text: built");