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");