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
24 changes: 23 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
import { mainT, setMainLocale } from "./i18n";
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
import { installMainProcessErrorGuards } from "./main-process-errors";
import { registerSttIpc } from "./stt";
import { registerSttIpc, shutdownStt } from "./stt";
import {
createCountdownOverlayWindow,
createEditorWindow,
Expand Down Expand Up @@ -512,6 +512,28 @@ app.on("activate", () => {
}
});

let sttShutdownPromise: Promise<void> | null = null;
let sttShutdownFinished = false;

// Electron does not wait for an async event listener. Hold the first quit long
// enough to terminate the long-lived Whisper helper, then re-enter app.quit()
// with a guard so the second before-quit event can proceed normally. Without
// this, a normal Cmd+Q orphaned the helper under launchd with the model and GPU
// resources still resident after every OpenScreen window had gone away.
app.on("before-quit", (event) => {
if (sttShutdownFinished) return;
event.preventDefault();
if (sttShutdownPromise) return;
sttShutdownPromise = shutdownStt()
.catch((error) => {
console.error("[stt] Failed to stop whisper helper during app quit:", error);
})
.finally(() => {
sttShutdownFinished = true;
app.quit();
});
});

app.on("will-quit", () => {
unregisterAllGlobalShortcuts();
});
Expand Down
18 changes: 14 additions & 4 deletions electron/native/whisper-stt/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ int main(int argc, char** argv) {
std::string model_path;
std::string host = "127.0.0.1";
bool host_from_flag = false;
bool force_cpu = false;
int port = 0;
int threads = std::max(1u, std::thread::hardware_concurrency());

Expand All @@ -233,6 +234,7 @@ int main(int argc, char** argv) {
else if (a == "--host" && i + 1 < argc) { host = argv[++i]; host_from_flag = true; }
else if (a == "--port" && i + 1 < argc) port = std::atoi(argv[++i]);
else if (a == "--threads" && i + 1 < argc) threads = std::atoi(argv[++i]);
else if (a == "--cpu") force_cpu = true;
}
// ponytail: prefer env var (matches the prior native STT model env var
// shape; the Node wrapper passes both ways).
Expand Down Expand Up @@ -268,20 +270,28 @@ int main(int argc, char** argv) {

// ---- Init whisper context with DTW alignment (POC §4.1) ----
whisper_context_params cparams = whisper_context_default_params();
cparams.use_gpu = true; // GPU offload via whatever backend was linked
cparams.use_gpu = !force_cpu; // Parent retries with --cpu if a GPU backend aborts.
cparams.flash_attn = false; // CRITICAL: DTW is silently disabled by v1.9.1
// if flash_attn is true; the guardrail in the
// /inference handler still runs, but skipping
// the request is wasted work.
cparams.dtw_token_timestamps = true;
cparams.dtw_aheads_preset = WHISPER_AHEADS_SMALL;
cparams.dtw_aheads_preset = WHISPER_AHEADS_LARGE_V3_TURBO;
whisper_context* ctx = whisper_init_from_file_with_params(model_path.c_str(), cparams);
if (!ctx && cparams.use_gpu) {
// Metal/Vulkan allocation can fail transiently when the editor or another
// creative app is already using most GPU memory. Captions should degrade to
// a slower CPU run instead of leaving the HTTP readiness probe to time out.
log("GPU model initialization failed; retrying with CPU inference");
cparams.use_gpu = false;
ctx = whisper_init_from_file_with_params(model_path.c_str(), cparams);
}
if (!ctx) {
log("whisper_init_from_file_with_params failed for " + model_path);
return 3;
}
const std::string active_backend = detect_active_backend();
log("model loaded: multilingual small; backend=" + active_backend);
const std::string active_backend = cparams.use_gpu ? detect_active_backend() : "whispercpp-cpu";
log("model loaded; backend=" + active_backend);

// ---- HTTP server ----
httplib::Server svr;
Expand Down
71 changes: 69 additions & 2 deletions electron/stt/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { planChunks } from "./chunking";
import { _resetSttManagerForTests, SttManager } from "./index";
import { _resetSttManagerForTests, getSttManager, SttManager, shutdownStt } from "./index";
import type { SttStatusEvent, SttTranscribeResponse } from "./transcriptionContract";

// We swap the long-lived modules for fakes so the manager's `init()` and
Expand All @@ -18,6 +18,7 @@ const fakeWhisperServer = {
},
transcribe: vi.fn(),
stop: vi.fn(),
shutdown: vi.fn(),
};

vi.mock("./whisperServer", () => {
Expand All @@ -26,6 +27,7 @@ vi.mock("./whisperServer", () => {
status = fakeWhisperServer.status;
transcribe = fakeWhisperServer.transcribe;
stop = fakeWhisperServer.stop;
shutdown = fakeWhisperServer.shutdown;
}
return { WhisperServerManager: FakeWhisperServerManager };
});
Expand All @@ -52,6 +54,7 @@ describe("SttManager", () => {
fakeWhisperServer.start.mockClear();
fakeWhisperServer.transcribe.mockClear();
fakeWhisperServer.stop.mockClear();
fakeWhisperServer.shutdown.mockClear();
fakeWhisperServer.start.mockResolvedValue({ port: 9000, backend: "whispercpp-cpu" });
fakeWhisperServer.transcribe.mockResolvedValue({
segments: [{ text: "hello", startSec: 0, endSec: 0.5 }],
Expand Down Expand Up @@ -198,7 +201,71 @@ describe("SttManager", () => {
const mgr = new SttManager();
await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" });
await mgr.shutdown();
expect(fakeWhisperServer.stop).toHaveBeenCalledOnce();
expect(fakeWhisperServer.shutdown).toHaveBeenCalledOnce();
});

it("shutdownStt() stops and releases the singleton exactly once", async () => {
const mgr = getSttManager();
await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" });

await shutdownStt();
await shutdownStt();

expect(fakeWhisperServer.shutdown).toHaveBeenCalledOnce();
expect(() => getSttManager()).toThrowError(/cancel/i);
});

it("does not respawn the helper when shutdown interrupts a failed chunk", async () => {
const mgr = getSttManager();
await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" });
fakeWhisperServer.transcribe.mockImplementationOnce(async () => {
await shutdownStt();
throw new Error("connection closed during quit");
});

const error = await mgr
.transcribe({ samples: new Float32Array(16000), language: "en" })
.catch((value: unknown) => value);

expect((error as Error).name).toBe("AbortError");
expect(fakeWhisperServer.start).toHaveBeenCalledOnce();
expect(fakeWhisperServer.shutdown).toHaveBeenCalledOnce();
});

it("cancels when shutdown starts as setup rejects", async () => {
const mgr = new SttManager();
fakeWhisperServer.start.mockImplementationOnce(async () => {
await mgr.shutdown();
throw new Error("connection closed during startup");
});

const error = await mgr
.init({ modelsBaseDir: "/tmp/fake-stt-models" })
.catch((value: unknown) => value);

expect((error as Error).name).toBe("AbortError");
expect(fakeWhisperServer.shutdown).toHaveBeenCalledOnce();
});

it("cancels when shutdown starts as the final chunk resolves", async () => {
const mgr = getSttManager();
await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" });
fakeWhisperServer.transcribe.mockImplementationOnce(async () => {
await shutdownStt();
return {
segments: [{ text: "late", startSec: 0, endSec: 0.5 }],
wordSegments: [{ word: "late", startSec: 0, endSec: 0.5 }],
detectedLanguage: "en",
backend: "whispercpp-cpu" as const,
};
});

const error = await mgr
.transcribe({ samples: new Float32Array(16_000), language: "en" })
.catch((value: unknown) => value);

expect((error as Error).name).toBe("AbortError");
expect(fakeWhisperServer.transcribe).toHaveBeenCalledOnce();
});

it("retries setup after a failed one instead of caching the rejection", async () => {
Expand Down
44 changes: 40 additions & 4 deletions electron/stt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { WhisperServerManager } from "./whisperServer";
* (`chunking.ts`) and runs each through whisper-stt-server's HTTP
* `/inference`, which returns both phrase- and word-level segments in one
* pass (see whisperServer.ts). Word timestamps come from whisper.cpp's
* native DTW token timestamps (`t_dtw`, SMALL aheads preset,
* native DTW token timestamps (`t_dtw`, LARGE_V3_TURBO aheads preset,
* `flash_attn = false`), see
* technical-documentation/architecture/transcription-and-captions.md § Decision rationale.
* 3. `shutdown()` tears down on app quit.
Expand Down Expand Up @@ -69,6 +69,7 @@ export interface SttManagerInitOptions {

export class SttManager {
private readonly server = new WhisperServerManager();
private shuttingDown = false;
private modelsBaseDir: string | null = null;
private readonly statusSinks = new Set<(event: SttStatusEvent) => void>();
private initPromise: Promise<void> | null = null;
Expand Down Expand Up @@ -119,10 +120,11 @@ export class SttManager {
* means the second caller just awaits the same completion.
*/
init(options: SttManagerInitOptions = {}): Promise<void> {
if (this.shuttingDown) return Promise.reject(cancelledError());
if (options.statusSink) this.addStatusSink(options.statusSink);
if (options.modelsBaseDir) this.modelsBaseDir = options.modelsBaseDir;
if (!this.initPromise) {
// A REJECTED init must not be cached. `prepare()` downloads a 253 MB
// A REJECTED init must not be cached. `prepare()` downloads a large
// model on first run, and caching its rejection meant one dropped
// connection poisoned the whole app session: every later transcription
// — including the retry the UI offers, and every remaining asset in the
Expand Down Expand Up @@ -158,10 +160,20 @@ export class SttManager {
});
},
});
if (this.shuttingDown) throw cancelledError();

const paths = modelPaths(modelsDir);
this.modelPath = paths.whisper;
await this.server.start({ modelPath: paths.whisper });
try {
await this.server.start({ modelPath: paths.whisper });
} catch (error) {
if (this.shuttingDown) throw cancelledError();
throw error;
}
if (this.shuttingDown) {
await this.server.shutdown();
throw cancelledError();
}
this.emit({ phase: "transcribe" });
}

Expand All @@ -186,14 +198,17 @@ export class SttManager {
): Promise<Awaited<ReturnType<WhisperServerManager["transcribe"]>>> {
let lastError: unknown;
for (let attempt = 1; attempt <= CHUNK_ATTEMPTS; attempt++) {
if (this.shuttingDown) throw cancelledError();
try {
return await this.server.transcribe({ samples, language });
} catch (error) {
lastError = error;
if (this.shuttingDown) throw cancelledError();
if (attempt === CHUNK_ATTEMPTS) break;
if (this.modelPath) {
await this.server.start({ modelPath: this.modelPath }).catch(() => undefined);
}
if (this.shuttingDown) throw cancelledError();
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
}
}
Expand Down Expand Up @@ -244,6 +259,7 @@ export class SttManager {
req.samples.subarray(chunk.startSample, chunk.endSample),
language,
).catch((error) => {
if (error instanceof Error && error.name === "AbortError") throw error;
// Say where it died. Without this the user gets "Transcription
// failed" for a 30-minute recording with no hint that 18 of those
// minutes were fine and the helper fell over at one specific spot.
Expand All @@ -259,6 +275,7 @@ export class SttManager {
{ cause: error },
);
});
if (this.shuttingDown || this.cancelEpoch !== epoch) throw cancelledError();
// Chunk-relative timestamps → absolute, the only thing every consumer
// (captions, transcript editor, trims) reads.
for (const segment of result.segments) {
Expand Down Expand Up @@ -298,21 +315,40 @@ export class SttManager {

/** Best-effort shutdown; safe to call from `before-quit` hooks. */
async shutdown(): Promise<void> {
await this.server.stop();
if (this.shuttingDown) return;
this.shuttingDown = true;
this.cancelEpoch++;
await this.server.shutdown();
}
}

let singleton: SttManager | null = null;
let sttShuttingDown = false;

/** Lazy singleton for the IPC layer; processes one transcription at a time. */
export function getSttManager(): SttManager {
if (sttShuttingDown) throw cancelledError();
if (!singleton) singleton = new SttManager();
return singleton;
}

/**
* Stop and release the lazy singleton without creating one just to quit.
*
* Electron's GUI lifecycle awaits this from a guarded `before-quit` handler;
* clearing the slot first also makes repeated quit events idempotent.
*/
export async function shutdownStt(): Promise<void> {
sttShuttingDown = true;
const manager = singleton;
await manager?.shutdown();
singleton = null;
}

/** Reset the singleton — for tests. */
export function _resetSttManagerForTests(): void {
singleton = null;
sttShuttingDown = false;
}

/**
Expand Down
8 changes: 4 additions & 4 deletions electron/stt/modelManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe("modelManager", () => {
expect(STT_MODELS.whisper.cacheDir).toBe("whisper-ggml");
expect(STT_MODELS.whisper.repoId).toBe("ggerganov/whisper.cpp");
expect(STT_MODELS.whisper.files.length).toBe(1);
expect(STT_MODELS.whisper.files[0].name).toBe("ggml-small-q8_0.bin");
expect(STT_MODELS.whisper.files[0].name).toBe("ggml-large-v3-turbo-q5_0.bin");
expect(STT_MODELS.whisper.files[0].expectedSha256).not.toBeNull();
for (const f of STT_MODELS.whisper.files) {
expect(f.approximateBytes).toBeGreaterThan(0);
Expand All @@ -32,7 +32,7 @@ describe("modelManager", () => {

it("modelPaths places the GGML file under the cache directory", () => {
const paths = modelPaths(dir);
expect(paths.whisper).toBe(path.join(dir, "whisper-ggml", "ggml-small-q8_0.bin"));
expect(paths.whisper).toBe(path.join(dir, "whisper-ggml", "ggml-large-v3-turbo-q5_0.bin"));
});

it("areModelsPresent returns false when the model file is missing", async () => {
Expand Down Expand Up @@ -92,7 +92,7 @@ describe("modelManager", () => {
expect(fetches).toBe(1);
expect(await readFile(paths.whisper)).toEqual(replacement);
// The stale copy is displaced by the atomic rename, not quarantined
// beside it: a `.bad` sibling would strand 264 MB nothing ever reaps.
// beside it: a `.bad` sibling would strand hundreds of MB nothing ever reaps.
expect(existsSync(`${paths.whisper}.bad`)).toBe(false);
expect(existsSync(`${paths.whisper}.partial`)).toBe(false);
} finally {
Expand Down Expand Up @@ -180,7 +180,7 @@ describe("modelManager", () => {
const s = await stat(paths.whisper);
expect(s.size).toBeGreaterThan(0);
expect(progressCalls.length).toBeGreaterThanOrEqual(1);
expect(progressCalls[0].file).toBe("ggml-small-q8_0.bin");
expect(progressCalls[0].file).toBe("ggml-large-v3-turbo-q5_0.bin");
} finally {
STT_MODELS.whisper.files[0].expectedSha256 = originalSha;
}
Expand Down
Loading