@mulmoclaude/core 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -97,60 +97,68 @@ function parseContentLength(header) {
97
97
  return Number(header) || 0;
98
98
  }
99
99
  var DOWNLOAD_STALL_TIMEOUT_MS = ONE_MINUTE_MS;
100
+ /** Drain the response stream into the `.partial` file, resetting the stall
101
+ * timer on every chunk and publishing progress once the total size is known. */
102
+ async function streamToFile(body, partialPath, total, name, deps) {
103
+ const reader = body.getReader();
104
+ const fileStream = createWriteStream(partialPath);
105
+ let received = 0;
106
+ try {
107
+ for (;;) {
108
+ const { done, value } = await reader.read();
109
+ if (done) break;
110
+ deps.onProgress();
111
+ received += value.byteLength;
112
+ if (!fileStream.write(value)) await once(fileStream, "drain");
113
+ if (total > 0) deps.downloadStatus.set(name, {
114
+ state: "downloading",
115
+ progress: received / total
116
+ });
117
+ }
118
+ fileStream.end();
119
+ await once(fileStream, "finish");
120
+ } catch (err) {
121
+ fileStream.on("error", () => {});
122
+ fileStream.destroy();
123
+ throw err;
124
+ }
125
+ }
126
+ /** Fetch a model into a `.partial` file, reject a truncated transfer via the
127
+ * size floor, then atomically rename it into place. */
128
+ async function downloadModel(name, modelsDir, downloadStatus) {
129
+ const spec = WHISPER_MODELS[name];
130
+ mkdirSync(modelsDir, { recursive: true });
131
+ const dest = modelFilePath(modelsDir, name);
132
+ const partial = `${dest}.partial`;
133
+ const controller = new AbortController();
134
+ let stallTimer = null;
135
+ const resetStall = () => {
136
+ if (stallTimer) clearTimeout(stallTimer);
137
+ stallTimer = setTimeout(() => controller.abort(), DOWNLOAD_STALL_TIMEOUT_MS);
138
+ };
139
+ try {
140
+ const response = await fetch(spec.url, { signal: controller.signal });
141
+ if (!response.ok || !response.body) throw new Error(`download failed: HTTP ${response.status}`);
142
+ const total = parseContentLength(response.headers.get("content-length"));
143
+ resetStall();
144
+ await streamToFile(response.body, partial, total, name, {
145
+ downloadStatus,
146
+ onProgress: resetStall
147
+ });
148
+ if (statSync(partial).size < spec.minBytes) {
149
+ unlinkSync(partial);
150
+ throw new Error("downloaded file is smaller than expected — likely truncated");
151
+ }
152
+ renameSync(partial, dest);
153
+ } finally {
154
+ if (stallTimer) clearTimeout(stallTimer);
155
+ }
156
+ }
100
157
  function createModelDownloader(modelsDir, logger = NOOP_LOGGER) {
101
158
  const downloadStatus = /* @__PURE__ */ new Map();
102
159
  function getStatus(name) {
103
160
  return pickModelStatus(downloadStatus.get(name), isModelReady(modelsDir, name));
104
161
  }
105
- async function streamToFile(body, partialPath, total, name, onProgress) {
106
- const reader = body.getReader();
107
- const fileStream = createWriteStream(partialPath);
108
- let received = 0;
109
- try {
110
- for (;;) {
111
- const { done, value } = await reader.read();
112
- if (done) break;
113
- onProgress();
114
- received += value.byteLength;
115
- if (!fileStream.write(value)) await once(fileStream, "drain");
116
- if (total > 0) downloadStatus.set(name, {
117
- state: "downloading",
118
- progress: received / total
119
- });
120
- }
121
- fileStream.end();
122
- await once(fileStream, "finish");
123
- } catch (err) {
124
- fileStream.destroy();
125
- throw err;
126
- }
127
- }
128
- async function download(name) {
129
- const spec = WHISPER_MODELS[name];
130
- mkdirSync(modelsDir, { recursive: true });
131
- const dest = modelFilePath(modelsDir, name);
132
- const partial = `${dest}.partial`;
133
- const controller = new AbortController();
134
- let stallTimer = null;
135
- const resetStall = () => {
136
- if (stallTimer) clearTimeout(stallTimer);
137
- stallTimer = setTimeout(() => controller.abort(), DOWNLOAD_STALL_TIMEOUT_MS);
138
- };
139
- try {
140
- const response = await fetch(spec.url, { signal: controller.signal });
141
- if (!response.ok || !response.body) throw new Error(`download failed: HTTP ${response.status}`);
142
- const total = parseContentLength(response.headers.get("content-length"));
143
- resetStall();
144
- await streamToFile(response.body, partial, total, name, resetStall);
145
- if (statSync(partial).size < spec.minBytes) {
146
- unlinkSync(partial);
147
- throw new Error("downloaded file is smaller than expected — likely truncated");
148
- }
149
- renameSync(partial, dest);
150
- } finally {
151
- if (stallTimer) clearTimeout(stallTimer);
152
- }
153
- }
154
162
  async function ensure(name) {
155
163
  if (isModelReady(modelsDir, name)) {
156
164
  downloadStatus.set(name, { state: "ready" });
@@ -163,7 +171,7 @@ function createModelDownloader(modelsDir, logger = NOOP_LOGGER) {
163
171
  });
164
172
  logger.info("model download: start", { model: name });
165
173
  try {
166
- await download(name);
174
+ await downloadModel(name, modelsDir, downloadStatus);
167
175
  downloadStatus.set(name, { state: "ready" });
168
176
  logger.info("model download: ok", { model: name });
169
177
  } catch (err) {
@@ -274,62 +282,95 @@ function waitForReadyOrFailure(proc, port) {
274
282
  });
275
283
  });
276
284
  }
277
- function createSidecar(modelsDir, serverBinary = "whisper-server", logger = NOOP_LOGGER) {
278
- let sidecar = null;
279
- let starting = null;
280
- let startingProc = null;
281
- let startToken = 0;
282
- function shutdown() {
283
- startToken += 1;
284
- if (startingProc) {
285
- startingProc.kill();
286
- startingProc = null;
287
- }
288
- if (sidecar) {
289
- sidecar.proc.kill();
290
- sidecar = null;
291
- }
285
+ async function postInference(port, wavPath, language) {
286
+ const buf = await readFile(wavPath);
287
+ const form = new FormData();
288
+ form.append("file", new Blob([buf], { type: "audio/wav" }), "audio.wav");
289
+ form.append("response_format", "json");
290
+ form.append("language", language || "auto");
291
+ let res;
292
+ try {
293
+ res = await fetch(`http://${HOST}:${port}/inference`, {
294
+ method: "POST",
295
+ body: form,
296
+ signal: AbortSignal.timeout(INFERENCE_TIMEOUT_MS)
297
+ });
298
+ } catch (err) {
299
+ throw new Error(`whisper-server request failed: ${errorMessage(err)}`);
292
300
  }
293
- async function startSidecar(model) {
294
- const token = ++startToken;
295
- const port = await findFreePort();
301
+ if (!res.ok) throw new Error(`whisper-server returned HTTP ${res.status}`);
302
+ return parseInferenceText(await res.json());
303
+ }
304
+ function defaultSpawnServer(modelsDir, serverBinary, logger) {
305
+ return (model, port) => {
296
306
  const args = buildServerArgs(modelFilePath(modelsDir, model), HOST, port);
297
307
  logger.info("sidecar: spawning", {
298
308
  model,
299
309
  port
300
310
  });
301
- const proc = spawn(serverBinary, args, { stdio: [
311
+ return spawn(serverBinary, args, { stdio: [
302
312
  "ignore",
303
313
  "ignore",
304
314
  "pipe"
305
315
  ] });
306
- startingProc = proc;
307
- const stderrTail = { text: "" };
308
- drainStderr(proc, stderrTail);
309
- proc.on("error", (err) => logger.warn("sidecar: process error", {
316
+ };
317
+ }
318
+ function instrumentChildProcess(proc, model, logger, onExit) {
319
+ const stderrTail = { text: "" };
320
+ drainStderr(proc, stderrTail);
321
+ proc.on("error", (err) => logger.warn("sidecar: process error", {
322
+ model,
323
+ error: errorMessage(err)
324
+ }));
325
+ proc.on("exit", (code) => {
326
+ logger.warn("sidecar: exited", {
310
327
  model,
311
- error: errorMessage(err)
312
- }));
313
- proc.on("exit", (code) => {
314
- logger.warn("sidecar: exited", {
315
- model,
316
- code,
317
- stderrTail: stderrTail.text.slice(-500)
318
- });
319
- if (sidecar?.proc === proc) sidecar = null;
328
+ code,
329
+ stderrTail: stderrTail.text.slice(-500)
320
330
  });
331
+ onExit(proc);
332
+ });
333
+ return stderrTail;
334
+ }
335
+ async function awaitReadyOrThrow(proc, port, stderrTail, waitReady) {
336
+ try {
337
+ await waitReady(proc, port);
338
+ } catch (err) {
339
+ proc.kill();
340
+ throw new Error(`whisper-server failed to start: ${errorMessage(err)} — stderr: ${stderrTail.text.slice(-500)}`);
341
+ }
342
+ }
343
+ function throwIfStartCancelled(startedToken, currentToken, proc) {
344
+ if (startedToken !== currentToken) {
345
+ proc.kill();
346
+ throw new Error("whisper-server start cancelled");
347
+ }
348
+ }
349
+ function createStartLifecycle(deps) {
350
+ const { allocatePort, spawnServer, waitReady, logger } = deps;
351
+ let sidecar = null;
352
+ let starting = null;
353
+ let startingProc = null;
354
+ let startToken = 0;
355
+ function shutdown() {
356
+ startToken += 1;
357
+ startingProc?.kill();
358
+ startingProc = null;
359
+ sidecar?.proc.kill();
360
+ sidecar = null;
361
+ }
362
+ async function startSidecar(model) {
363
+ const token = ++startToken;
364
+ const port = await allocatePort();
365
+ const proc = spawnServer(model, port);
366
+ startingProc = proc;
367
+ const stderrTail = instrumentChildProcess(proc, model, logger, (exited) => sidecar?.proc === exited && (sidecar = null));
321
368
  try {
322
- await waitForReadyOrFailure(proc, port);
323
- } catch (err) {
324
- proc.kill();
325
- throw new Error(`whisper-server failed to start: ${errorMessage(err)} — stderr: ${stderrTail.text.slice(-500)}`);
369
+ await awaitReadyOrThrow(proc, port, stderrTail, waitReady);
326
370
  } finally {
327
371
  if (startingProc === proc) startingProc = null;
328
372
  }
329
- if (token !== startToken) {
330
- proc.kill();
331
- throw new Error("whisper-server start cancelled");
332
- }
373
+ throwIfStartCancelled(token, startToken, proc);
333
374
  sidecar = {
334
375
  port,
335
376
  proc,
@@ -342,9 +383,7 @@ function createSidecar(modelsDir, serverBinary = "whisper-server", logger = NOOP
342
383
  return sidecar;
343
384
  }
344
385
  function beginStart(model) {
345
- const promise = startSidecar(model).finally(() => {
346
- starting = null;
347
- });
386
+ const promise = startSidecar(model).finally(() => starting = null);
348
387
  starting = {
349
388
  model,
350
389
  promise
@@ -363,9 +402,21 @@ function createSidecar(modelsDir, serverBinary = "whisper-server", logger = NOOP
363
402
  return beginStart(model);
364
403
  }
365
404
  }
405
+ return {
406
+ ensureSidecar,
407
+ shutdown
408
+ };
409
+ }
410
+ function createSidecar(modelsDir, serverBinary = "whisper-server", logger = NOOP_LOGGER) {
411
+ const lifecycle = createStartLifecycle({
412
+ allocatePort: findFreePort,
413
+ spawnServer: defaultSpawnServer(modelsDir, serverBinary, logger),
414
+ waitReady: waitForReadyOrFailure,
415
+ logger
416
+ });
366
417
  async function warmup(model) {
367
418
  try {
368
- await ensureSidecar(model);
419
+ await lifecycle.ensureSidecar(model);
369
420
  } catch (err) {
370
421
  logger.warn("sidecar: warmup failed", {
371
422
  model,
@@ -374,29 +425,12 @@ function createSidecar(modelsDir, serverBinary = "whisper-server", logger = NOOP
374
425
  }
375
426
  }
376
427
  async function transcribeWav(wavPath, language, model) {
377
- const active = await ensureSidecar(model);
378
- const buf = await readFile(wavPath);
379
- const form = new FormData();
380
- form.append("file", new Blob([buf], { type: "audio/wav" }), "audio.wav");
381
- form.append("response_format", "json");
382
- form.append("language", language || "auto");
383
- let res;
384
- try {
385
- res = await fetch(`http://${HOST}:${active.port}/inference`, {
386
- method: "POST",
387
- body: form,
388
- signal: AbortSignal.timeout(INFERENCE_TIMEOUT_MS)
389
- });
390
- } catch (err) {
391
- throw new Error(`whisper-server request failed: ${errorMessage(err)}`);
392
- }
393
- if (!res.ok) throw new Error(`whisper-server returned HTTP ${res.status}`);
394
- return parseInferenceText(await res.json());
428
+ return postInference((await lifecycle.ensureSidecar(model)).port, wavPath, language);
395
429
  }
396
430
  return {
397
431
  transcribeWav,
398
432
  warmup,
399
- shutdown
433
+ shutdown: lifecycle.shutdown
400
434
  };
401
435
  }
402
436
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/whisper/internal.ts","../../src/whisper/ffmpeg.ts","../../src/whisper/models.ts","../../src/whisper/sidecar-helpers.ts","../../src/whisper/sidecar.ts","../../src/whisper/whisper.ts"],"sourcesContent":["// Small self-contained utilities so the package has no host dependencies.\n\nexport const ONE_SECOND_MS = 1_000;\nexport const ONE_MINUTE_MS = 60_000;\n\nexport function errorMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n/** Minimal logger the host can inject; defaults to no-op so the package\n * is silent unless wired up. */\nexport interface WhisperLogger {\n info: (message: string, data?: unknown) => void;\n warn: (message: string, data?: unknown) => void;\n error: (message: string, data?: unknown) => void;\n}\n\nconst NOOP = (): void => undefined;\nexport const NOOP_LOGGER: WhisperLogger = { info: NOOP, warn: NOOP, error: NOOP };\n","// Thin wrapper around the system `ffmpeg` binary (provided by the host) for\n// converting a browser webm/opus clip to the 16 kHz mono 16-bit WAV whisper.cpp\n// requires.\n\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { ONE_MINUTE_MS } from \"./internal.ts\";\n\nconst execFileAsync = promisify(execFile);\n\n/** ffmpeg args to decode any input to 16 kHz mono signed-16-bit WAV. Pure +\n * exported for unit tests. */\nexport function buildWav16kArgs(inputPath: string, outputPath: string): string[] {\n return [\"-y\", \"-loglevel\", \"error\", \"-i\", inputPath, \"-ar\", \"16000\", \"-ac\", \"1\", \"-c:a\", \"pcm_s16le\", outputPath];\n}\n\n/** Convert `inputPath` to a 16 kHz mono WAV at `outputPath`. Throws on ffmpeg\n * failure or timeout. */\nexport async function convertToWav16k(inputPath: string, outputPath: string, ffmpegBinary = \"ffmpeg\"): Promise<void> {\n await execFileAsync(ffmpegBinary, buildWav16kArgs(inputPath, outputPath), {\n timeout: ONE_MINUTE_MS,\n });\n}\n","// Whisper GGML model registry + on-disk management. The host injects the models\n// directory (e.g. `{workspace}/models`); nothing here reads a host module.\n\nimport { createWriteStream, mkdirSync, renameSync, statSync, unlinkSync } from \"node:fs\";\nimport { once } from \"node:events\";\nimport path from \"node:path\";\nimport { errorMessage, NOOP_LOGGER, ONE_MINUTE_MS, type WhisperLogger } from \"./internal.ts\";\n\nexport interface WhisperModelSpec {\n /** GGML filename — identical on disk and in the Hugging Face repo. */\n readonly file: string;\n /** Download URL (Hugging Face whisper.cpp model repo). */\n readonly url: string;\n /** Conservative lower bound on the finished file size, in bytes — guards\n * against a truncated transfer or an HTML error page saved as the model\n * without pinning an exact checksum. */\n readonly minBytes: number;\n}\n\nconst HF_BASE = \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main\";\n\n// large-v3-turbo: strong accuracy, near-real-time on Apple Silicon with Metal.\n// small/base are lighter fallbacks for low-RAM machines.\nexport const WHISPER_MODELS = {\n \"large-v3-turbo\": { file: \"ggml-large-v3-turbo.bin\", url: `${HF_BASE}/ggml-large-v3-turbo.bin`, minBytes: 1_000_000_000 },\n small: { file: \"ggml-small.bin\", url: `${HF_BASE}/ggml-small.bin`, minBytes: 300_000_000 },\n base: { file: \"ggml-base.bin\", url: `${HF_BASE}/ggml-base.bin`, minBytes: 100_000_000 },\n} as const satisfies Record<string, WhisperModelSpec>;\n\nexport type WhisperModelName = keyof typeof WHISPER_MODELS;\nexport const DEFAULT_WHISPER_MODEL: WhisperModelName = \"large-v3-turbo\";\n\nexport function isWhisperModelName(value: unknown): value is WhisperModelName {\n // Own-property check — `in` would accept inherited keys like \"toString\",\n // which then crash the `WHISPER_MODELS[name]` lookups instead of falling\n // back to the default.\n return typeof value === \"string\" && Object.prototype.hasOwnProperty.call(WHISPER_MODELS, value);\n}\n\n/** Resolve a possibly-unset / unknown model name to a valid one. */\nexport function resolveModelName(name: string | undefined): WhisperModelName {\n return isWhisperModelName(name) ? name : DEFAULT_WHISPER_MODEL;\n}\n\nexport function modelFilePath(modelsDir: string, name: WhisperModelName): string {\n return path.join(modelsDir, WHISPER_MODELS[name].file);\n}\n\n/** A model is \"ready\" when its file exists and meets the size floor. */\nexport function isModelReady(modelsDir: string, name: WhisperModelName): boolean {\n try {\n return statSync(modelFilePath(modelsDir, name)).size >= WHISPER_MODELS[name].minBytes;\n } catch {\n return false;\n }\n}\n\nexport type ModelDownloadState = \"idle\" | \"downloading\" | \"ready\" | \"error\";\n\nexport interface ModelStatus {\n state: ModelDownloadState;\n /** 0..1 — present only while downloading and Content-Length is known. */\n progress?: number;\n /** Present only in the \"error\" state. */\n error?: string;\n}\n\n/** Report status precedence: an in-flight download wins, then an on-disk ready\n * file, then the last transient state (idle if none). Pure so the precedence\n * is unit-testable without touching the filesystem. */\nexport function pickModelStatus(live: ModelStatus | undefined, isReady: boolean): ModelStatus {\n if (live?.state === \"downloading\") return live;\n if (isReady) return { state: \"ready\" };\n return live ?? { state: \"idle\" };\n}\n\n/** Parse a `Content-Length` header to a byte count; unknown / malformed → 0. */\nexport function parseContentLength(header: string | null): number {\n return Number(header) || 0;\n}\n\n// Abort a download if no bytes arrive for this long (stalled connection).\nconst DOWNLOAD_STALL_TIMEOUT_MS = ONE_MINUTE_MS;\n\nexport interface ModelDownloader {\n getStatus: (name: WhisperModelName) => ModelStatus;\n ensure: (name: WhisperModelName) => Promise<void>;\n}\n\nexport function createModelDownloader(modelsDir: string, logger: WhisperLogger = NOOP_LOGGER): ModelDownloader {\n // A finished file on disk is the source of truth for \"ready\"; this map tracks\n // transient progress + the last error.\n const downloadStatus = new Map<WhisperModelName, ModelStatus>();\n\n function getStatus(name: WhisperModelName): ModelStatus {\n return pickModelStatus(downloadStatus.get(name), isModelReady(modelsDir, name));\n }\n\n async function streamToFile(\n body: ReadableStream<Uint8Array>,\n partialPath: string,\n total: number,\n name: WhisperModelName,\n onProgress: () => void,\n ): Promise<void> {\n const reader = body.getReader();\n const fileStream = createWriteStream(partialPath);\n let received = 0;\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n onProgress();\n received += value.byteLength;\n if (!fileStream.write(value)) await once(fileStream, \"drain\");\n if (total > 0) downloadStatus.set(name, { state: \"downloading\", progress: received / total });\n }\n fileStream.end();\n await once(fileStream, \"finish\");\n } catch (err) {\n fileStream.destroy();\n throw err;\n }\n }\n\n async function download(name: WhisperModelName): Promise<void> {\n const spec = WHISPER_MODELS[name];\n mkdirSync(modelsDir, { recursive: true });\n const dest = modelFilePath(modelsDir, name);\n const partial = `${dest}.partial`;\n const controller = new AbortController();\n let stallTimer: ReturnType<typeof setTimeout> | null = null;\n const resetStall = () => {\n if (stallTimer) clearTimeout(stallTimer);\n stallTimer = setTimeout(() => controller.abort(), DOWNLOAD_STALL_TIMEOUT_MS);\n };\n try {\n const response = await fetch(spec.url, { signal: controller.signal });\n if (!response.ok || !response.body) {\n throw new Error(`download failed: HTTP ${response.status}`);\n }\n const total = parseContentLength(response.headers.get(\"content-length\"));\n resetStall();\n await streamToFile(response.body, partial, total, name, resetStall);\n if (statSync(partial).size < spec.minBytes) {\n unlinkSync(partial);\n throw new Error(\"downloaded file is smaller than expected — likely truncated\");\n }\n renameSync(partial, dest);\n } finally {\n if (stallTimer) clearTimeout(stallTimer);\n }\n }\n\n // Fire-and-forget friendly: errors land in the status map, never thrown.\n // Idempotent — a second call while a download is in flight does nothing.\n async function ensure(name: WhisperModelName): Promise<void> {\n if (isModelReady(modelsDir, name)) {\n downloadStatus.set(name, { state: \"ready\" });\n return;\n }\n if (downloadStatus.get(name)?.state === \"downloading\") return;\n downloadStatus.set(name, { state: \"downloading\", progress: 0 });\n logger.info(\"model download: start\", { model: name });\n try {\n await download(name);\n downloadStatus.set(name, { state: \"ready\" });\n logger.info(\"model download: ok\", { model: name });\n } catch (err) {\n const error = errorMessage(err);\n downloadStatus.set(name, { state: \"error\", error });\n logger.error(\"model download: failed\", { model: name, error });\n }\n }\n\n return { getStatus, ensure };\n}\n","// Pure helpers for the whisper-server sidecar. Kept separate from `sidecar.ts`\n// so the argv/response/stderr logic can be unit-tested without spawning a\n// process or opening a socket.\n\n/** whisper-server CLI args to preload `modelPath` and bind to `host:port`. */\nexport function buildServerArgs(modelPath: string, host: string, port: number): string[] {\n return [\"--model\", modelPath, \"--host\", host, \"--port\", String(port)];\n}\n\n/** Append `chunk` to a bounded stderr tail, keeping only the last `maxChars`. */\nexport function appendStderrTail(previous: string, chunk: string, maxChars: number): string {\n return (previous + chunk).slice(-maxChars);\n}\n\n/** Extract the transcript from a whisper-server `/inference` JSON response.\n * Returns \"\" for any shape that lacks a string `text` field. */\nexport function parseInferenceText(data: unknown): string {\n if (typeof data === \"object\" && data !== null && \"text\" in data) {\n const { text } = data as { text: unknown };\n if (typeof text === \"string\") return text;\n }\n return \"\";\n}\n","// whisper.cpp warm-model sidecar. Spawns `whisper-server` once with the model\n// preloaded and reuses it across transcriptions over its local HTTP API, so the\n// weights stay resident (no per-request reload). State is encapsulated per\n// instance via the factory closure.\n\nimport { spawn, type ChildProcess } from \"node:child_process\";\nimport { createServer } from \"node:net\";\nimport { readFile } from \"node:fs/promises\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport { errorMessage, NOOP_LOGGER, ONE_MINUTE_MS, ONE_SECOND_MS, type WhisperLogger } from \"./internal.ts\";\nimport { modelFilePath, type WhisperModelName } from \"./models.ts\";\nimport { appendStderrTail, buildServerArgs, parseInferenceText } from \"./sidecar-helpers.ts\";\n\nconst HOST = \"127.0.0.1\";\nconst READY_TIMEOUT_MS = 60 * ONE_SECOND_MS;\nconst READY_POLL_INTERVAL_MS = 500;\nconst INFERENCE_TIMEOUT_MS = 2 * ONE_MINUTE_MS;\nconst STDERR_TAIL_MAX_CHARS = 4_000;\n\ninterface ActiveSidecar {\n readonly port: number;\n readonly proc: ChildProcess;\n readonly model: WhisperModelName;\n}\n\nexport interface Sidecar {\n transcribeWav: (wavPath: string, language: string, model: WhisperModelName) => Promise<string>;\n warmup: (model: WhisperModelName) => Promise<void>;\n shutdown: () => void;\n}\n\nasync function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const srv = createServer();\n srv.on(\"error\", reject);\n srv.listen(0, HOST, () => {\n const addr = srv.address();\n if (addr && typeof addr === \"object\") {\n const { port } = addr;\n srv.close(() => resolve(port));\n } else {\n srv.close(() => reject(new Error(\"could not determine a free port\")));\n }\n });\n });\n}\n\n/** Resolve once the server answers any HTTP request (any status = listener up),\n * or throw after the ready timeout. */\nasync function waitUntilReady(port: number): Promise<void> {\n const deadline = Date.now() + READY_TIMEOUT_MS;\n while (Date.now() < deadline) {\n try {\n await fetch(`http://${HOST}:${port}/`, { signal: AbortSignal.timeout(ONE_SECOND_MS) });\n return;\n } catch {\n await delay(READY_POLL_INTERVAL_MS);\n }\n }\n throw new Error(\"whisper-server did not become ready in time\");\n}\n\n// whisper-server logs verbosely to stderr; left unread the OS pipe buffer fills\n// and the child blocks on its next write. Drain into a small tail buffer.\nfunction drainStderr(proc: ChildProcess, tail: { text: string }): void {\n proc.stderr?.setEncoding(\"utf8\");\n proc.stderr?.on(\"data\", (chunk: string) => {\n tail.text = appendStderrTail(tail.text, chunk, STDERR_TAIL_MAX_CHARS);\n });\n}\n\n// Resolve when the server answers, or reject on spawn failure (e.g. ENOENT) /\n// early exit. Listeners are one-shot and removed once the race settles.\nfunction waitForReadyOrFailure(proc: ChildProcess, port: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n let onError: (err: Error) => void = () => undefined;\n let onExit: (code: number | null) => void = () => undefined;\n const cleanup = () => {\n proc.removeListener(\"error\", onError);\n proc.removeListener(\"exit\", onExit);\n };\n onError = (err: Error) => {\n cleanup();\n reject(new Error(`spawn failed: ${errorMessage(err)}`));\n };\n onExit = (code: number | null) => {\n cleanup();\n reject(new Error(`exited early (code ${code})`));\n };\n proc.once(\"error\", onError);\n proc.once(\"exit\", onExit);\n waitUntilReady(port)\n .then(() => {\n cleanup();\n resolve();\n })\n .catch((err: unknown) => {\n cleanup();\n reject(err instanceof Error ? err : new Error(String(err)));\n });\n });\n}\n\nexport function createSidecar(modelsDir: string, serverBinary = \"whisper-server\", logger: WhisperLogger = NOOP_LOGGER): Sidecar {\n let sidecar: ActiveSidecar | null = null;\n let starting: { model: WhisperModelName; promise: Promise<ActiveSidecar> } | null = null;\n // The child of an in-flight start (before it's published as `sidecar`), plus a\n // token that `shutdown()` bumps to cancel a start that's still booting — so\n // shutdown can't return with a child that then publishes itself afterwards.\n let startingProc: ChildProcess | null = null;\n let startToken = 0;\n\n function shutdown(): void {\n startToken += 1;\n if (startingProc) {\n startingProc.kill();\n startingProc = null;\n }\n if (sidecar) {\n sidecar.proc.kill();\n sidecar = null;\n }\n }\n\n async function startSidecar(model: WhisperModelName): Promise<ActiveSidecar> {\n const token = ++startToken;\n const port = await findFreePort();\n const args = buildServerArgs(modelFilePath(modelsDir, model), HOST, port);\n logger.info(\"sidecar: spawning\", { model, port });\n const proc = spawn(serverBinary, args, { stdio: [\"ignore\", \"ignore\", \"pipe\"] });\n startingProc = proc;\n const stderrTail = { text: \"\" };\n drainStderr(proc, stderrTail);\n // Permanent error listener — a missing one would let a process 'error'\n // (e.g. ENOENT) throw uncaught and crash the host.\n proc.on(\"error\", (err) => logger.warn(\"sidecar: process error\", { model, error: errorMessage(err) }));\n proc.on(\"exit\", (code) => {\n logger.warn(\"sidecar: exited\", { model, code, stderrTail: stderrTail.text.slice(-500) });\n if (sidecar?.proc === proc) sidecar = null;\n });\n try {\n await waitForReadyOrFailure(proc, port);\n } catch (err) {\n proc.kill();\n throw new Error(`whisper-server failed to start: ${errorMessage(err)} — stderr: ${stderrTail.text.slice(-500)}`);\n } finally {\n if (startingProc === proc) startingProc = null;\n }\n // shutdown() (or a newer start) ran while we were booting — discard this\n // child instead of publishing a sidecar after shutdown returned.\n // eslint-disable-next-line security/detect-possible-timing-attacks -- in-memory start-cancellation token, not an auth compare\n if (token !== startToken) {\n proc.kill();\n throw new Error(\"whisper-server start cancelled\");\n }\n sidecar = { port, proc, model };\n logger.info(\"sidecar: ready\", { model, port });\n return sidecar;\n }\n\n // Module-scoped spawn so it isn't a loop closure (no-loop-func). Only ever one\n // start is in flight at a time, so clearing `starting` on settle is safe.\n function beginStart(model: WhisperModelName): Promise<ActiveSidecar> {\n const promise = startSidecar(model).finally(() => {\n starting = null;\n });\n starting = { model, promise };\n return promise;\n }\n\n async function ensureSidecar(model: WhisperModelName): Promise<ActiveSidecar> {\n // Loop so that after awaiting an in-flight start for a DIFFERENT model we\n // re-evaluate; the decision-to-spawn path (beginStart) has no await, so the\n // first waiter sets `starting` synchronously and siblings then reuse it.\n for (;;) {\n if (sidecar && sidecar.model === model && !sidecar.proc.killed) return sidecar;\n if (starting && starting.model === model) return starting.promise;\n if (starting) {\n await starting.promise.catch(() => undefined);\n continue;\n }\n if (sidecar && sidecar.model !== model) shutdown();\n return beginStart(model);\n }\n }\n\n async function warmup(model: WhisperModelName): Promise<void> {\n try {\n await ensureSidecar(model);\n } catch (err) {\n logger.warn(\"sidecar: warmup failed\", { model, error: errorMessage(err) });\n }\n }\n\n async function transcribeWav(wavPath: string, language: string, model: WhisperModelName): Promise<string> {\n const active = await ensureSidecar(model);\n const buf = await readFile(wavPath);\n const form = new FormData();\n form.append(\"file\", new Blob([buf], { type: \"audio/wav\" }), \"audio.wav\");\n form.append(\"response_format\", \"json\");\n form.append(\"language\", language || \"auto\");\n let res: Response;\n try {\n res = await fetch(`http://${HOST}:${active.port}/inference`, { method: \"POST\", body: form, signal: AbortSignal.timeout(INFERENCE_TIMEOUT_MS) });\n } catch (err) {\n throw new Error(`whisper-server request failed: ${errorMessage(err)}`);\n }\n if (!res.ok) throw new Error(`whisper-server returned HTTP ${res.status}`);\n return parseInferenceText(await res.json());\n }\n\n return { transcribeWav, warmup, shutdown };\n}\n","// Public server-side façade: wires the model downloader, the warm sidecar, and\n// ffmpeg conversion into one host-agnostic service. The host injects the models\n// directory + a logger and gates capability itself (platform / binary presence);\n// this package assumes the binaries exist when called.\n\nimport { mkdirSync } from \"node:fs\";\nimport { rm, writeFile } from \"node:fs/promises\";\nimport { randomUUID } from \"node:crypto\";\nimport path from \"node:path\";\nimport { NOOP_LOGGER, type WhisperLogger } from \"./internal.ts\";\nimport { convertToWav16k } from \"./ffmpeg.ts\";\nimport { createModelDownloader } from \"./models.ts\";\nimport { createSidecar } from \"./sidecar.ts\";\nimport { isModelReady, type ModelStatus, type WhisperModelName } from \"./models.ts\";\n\nexport interface WhisperOptions {\n /** Directory that holds the GGML model files (e.g. `{workspace}/models`). */\n modelsDir: string;\n logger?: WhisperLogger;\n /** Defaults to \"whisper-server\" / \"ffmpeg\" on PATH. */\n serverBinary?: string;\n ffmpegBinary?: string;\n}\n\nexport interface TranscribeRequest {\n base64: string;\n mimeType: string;\n language: string;\n model: WhisperModelName;\n}\n\nexport interface Whisper {\n isModelReady: (model: WhisperModelName) => boolean;\n getModelStatus: (model: WhisperModelName) => ModelStatus;\n /** Fire-and-forget friendly; never throws (errors land in the status). */\n ensureModelDownloaded: (model: WhisperModelName) => Promise<void>;\n warmup: (model: WhisperModelName) => Promise<void>;\n transcribe: (req: TranscribeRequest) => Promise<{ text: string }>;\n shutdown: () => void;\n}\n\n// whisper.cpp returns these sentinels for non-speech windows; treat them as\n// empty so the UI shows \"didn't catch that\" rather than a literal marker.\nconst BLANK_MARKERS = new Set([\"[blank_audio]\", \"[silence]\", \"(silence)\", \"[ inaudible ]\"]);\n\nfunction normalizeTranscript(raw: string): string {\n const trimmed = raw.trim().replace(/\\s+/g, \" \");\n return BLANK_MARKERS.has(trimmed.toLowerCase()) ? \"\" : trimmed;\n}\n\nexport function createWhisper(opts: WhisperOptions): Whisper {\n const { modelsDir } = opts;\n const logger = opts.logger ?? NOOP_LOGGER;\n const ffmpegBinary = opts.ffmpegBinary ?? \"ffmpeg\";\n const downloader = createModelDownloader(modelsDir, logger);\n const sidecar = createSidecar(modelsDir, opts.serverBinary ?? \"whisper-server\", logger);\n\n // Scratch dir for transient audio — a hidden subdir of the models dir so it\n // shares the (non-git) models tree. Files are deleted after each transcription.\n const scratchDir = path.join(modelsDir, \".scratch\");\n\n async function transcribe(req: TranscribeRequest): Promise<{ text: string }> {\n mkdirSync(scratchDir, { recursive: true });\n const clipId = randomUUID();\n const inputPath = path.join(scratchDir, `utterance-${clipId}.webm`);\n const wavPath = path.join(scratchDir, `utterance-${clipId}.wav`);\n try {\n await writeFile(inputPath, Buffer.from(req.base64, \"base64\"));\n await convertToWav16k(inputPath, wavPath, ffmpegBinary);\n const text = await sidecar.transcribeWav(wavPath, req.language, req.model);\n return { text: normalizeTranscript(text) };\n } finally {\n await rm(inputPath, { force: true });\n await rm(wavPath, { force: true });\n }\n }\n\n return {\n isModelReady: (model) => isModelReady(modelsDir, model),\n getModelStatus: (model) => downloader.getStatus(model),\n ensureModelDownloaded: (model) => downloader.ensure(model),\n warmup: (model) => sidecar.warmup(model),\n transcribe,\n shutdown: () => sidecar.shutdown(),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/whisper/internal.ts","../../src/whisper/ffmpeg.ts","../../src/whisper/models.ts","../../src/whisper/sidecar-helpers.ts","../../src/whisper/sidecar.ts","../../src/whisper/whisper.ts"],"sourcesContent":["// Small self-contained utilities so the package has no host dependencies.\n\nexport const ONE_SECOND_MS = 1_000;\nexport const ONE_MINUTE_MS = 60_000;\n\nexport function errorMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n/** Minimal logger the host can inject; defaults to no-op so the package\n * is silent unless wired up. */\nexport interface WhisperLogger {\n info: (message: string, data?: unknown) => void;\n warn: (message: string, data?: unknown) => void;\n error: (message: string, data?: unknown) => void;\n}\n\nconst NOOP = (): void => undefined;\nexport const NOOP_LOGGER: WhisperLogger = { info: NOOP, warn: NOOP, error: NOOP };\n","// Thin wrapper around the system `ffmpeg` binary (provided by the host) for\n// converting a browser webm/opus clip to the 16 kHz mono 16-bit WAV whisper.cpp\n// requires.\n\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { ONE_MINUTE_MS } from \"./internal.ts\";\n\nconst execFileAsync = promisify(execFile);\n\n/** ffmpeg args to decode any input to 16 kHz mono signed-16-bit WAV. Pure +\n * exported for unit tests. */\nexport function buildWav16kArgs(inputPath: string, outputPath: string): string[] {\n return [\"-y\", \"-loglevel\", \"error\", \"-i\", inputPath, \"-ar\", \"16000\", \"-ac\", \"1\", \"-c:a\", \"pcm_s16le\", outputPath];\n}\n\n/** Convert `inputPath` to a 16 kHz mono WAV at `outputPath`. Throws on ffmpeg\n * failure or timeout. */\nexport async function convertToWav16k(inputPath: string, outputPath: string, ffmpegBinary = \"ffmpeg\"): Promise<void> {\n await execFileAsync(ffmpegBinary, buildWav16kArgs(inputPath, outputPath), {\n timeout: ONE_MINUTE_MS,\n });\n}\n","// Whisper GGML model registry + on-disk management. The host injects the models\n// directory (e.g. `{workspace}/models`); nothing here reads a host module.\n\nimport { createWriteStream, mkdirSync, renameSync, statSync, unlinkSync } from \"node:fs\";\nimport { once } from \"node:events\";\nimport path from \"node:path\";\nimport { errorMessage, NOOP_LOGGER, ONE_MINUTE_MS, type WhisperLogger } from \"./internal.ts\";\n\nexport interface WhisperModelSpec {\n /** GGML filename — identical on disk and in the Hugging Face repo. */\n readonly file: string;\n /** Download URL (Hugging Face whisper.cpp model repo). */\n readonly url: string;\n /** Conservative lower bound on the finished file size, in bytes — guards\n * against a truncated transfer or an HTML error page saved as the model\n * without pinning an exact checksum. */\n readonly minBytes: number;\n}\n\nconst HF_BASE = \"https://huggingface.co/ggerganov/whisper.cpp/resolve/main\";\n\n// large-v3-turbo: strong accuracy, near-real-time on Apple Silicon with Metal.\n// small/base are lighter fallbacks for low-RAM machines.\nexport const WHISPER_MODELS = {\n \"large-v3-turbo\": { file: \"ggml-large-v3-turbo.bin\", url: `${HF_BASE}/ggml-large-v3-turbo.bin`, minBytes: 1_000_000_000 },\n small: { file: \"ggml-small.bin\", url: `${HF_BASE}/ggml-small.bin`, minBytes: 300_000_000 },\n base: { file: \"ggml-base.bin\", url: `${HF_BASE}/ggml-base.bin`, minBytes: 100_000_000 },\n} as const satisfies Record<string, WhisperModelSpec>;\n\nexport type WhisperModelName = keyof typeof WHISPER_MODELS;\nexport const DEFAULT_WHISPER_MODEL: WhisperModelName = \"large-v3-turbo\";\n\nexport function isWhisperModelName(value: unknown): value is WhisperModelName {\n // Own-property check — `in` would accept inherited keys like \"toString\",\n // which then crash the `WHISPER_MODELS[name]` lookups instead of falling\n // back to the default.\n return typeof value === \"string\" && Object.prototype.hasOwnProperty.call(WHISPER_MODELS, value);\n}\n\n/** Resolve a possibly-unset / unknown model name to a valid one. */\nexport function resolveModelName(name: string | undefined): WhisperModelName {\n return isWhisperModelName(name) ? name : DEFAULT_WHISPER_MODEL;\n}\n\nexport function modelFilePath(modelsDir: string, name: WhisperModelName): string {\n return path.join(modelsDir, WHISPER_MODELS[name].file);\n}\n\n/** A model is \"ready\" when its file exists and meets the size floor. */\nexport function isModelReady(modelsDir: string, name: WhisperModelName): boolean {\n try {\n return statSync(modelFilePath(modelsDir, name)).size >= WHISPER_MODELS[name].minBytes;\n } catch {\n return false;\n }\n}\n\nexport type ModelDownloadState = \"idle\" | \"downloading\" | \"ready\" | \"error\";\n\nexport interface ModelStatus {\n state: ModelDownloadState;\n /** 0..1 — present only while downloading and Content-Length is known. */\n progress?: number;\n /** Present only in the \"error\" state. */\n error?: string;\n}\n\n/** Report status precedence: an in-flight download wins, then an on-disk ready\n * file, then the last transient state (idle if none). Pure so the precedence\n * is unit-testable without touching the filesystem. */\nexport function pickModelStatus(live: ModelStatus | undefined, isReady: boolean): ModelStatus {\n if (live?.state === \"downloading\") return live;\n if (isReady) return { state: \"ready\" };\n return live ?? { state: \"idle\" };\n}\n\n/** Parse a `Content-Length` header to a byte count; unknown / malformed → 0. */\nexport function parseContentLength(header: string | null): number {\n return Number(header) || 0;\n}\n\n// Abort a download if no bytes arrive for this long (stalled connection).\nconst DOWNLOAD_STALL_TIMEOUT_MS = ONE_MINUTE_MS;\n\nexport interface ModelDownloader {\n getStatus: (name: WhisperModelName) => ModelStatus;\n ensure: (name: WhisperModelName) => Promise<void>;\n}\n\ninterface StreamToFileDeps {\n downloadStatus: Map<WhisperModelName, ModelStatus>;\n onProgress: () => void;\n}\n\n/** Drain the response stream into the `.partial` file, resetting the stall\n * timer on every chunk and publishing progress once the total size is known. */\nasync function streamToFile(\n body: ReadableStream<Uint8Array>,\n partialPath: string,\n total: number,\n name: WhisperModelName,\n deps: StreamToFileDeps,\n): Promise<void> {\n const reader = body.getReader();\n const fileStream = createWriteStream(partialPath);\n let received = 0;\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n deps.onProgress();\n received += value.byteLength;\n if (!fileStream.write(value)) await once(fileStream, \"drain\");\n if (total > 0) deps.downloadStatus.set(name, { state: \"downloading\", progress: received / total });\n }\n fileStream.end();\n await once(fileStream, \"finish\");\n } catch (err) {\n // `createWriteStream` opens lazily. Destroying it while that open is still\n // in flight lets the open's own failure land later as an `error` event with\n // no listener — an uncaughtException that kills the process. `err` already\n // carries the real cause, so absorb the late one.\n fileStream.on(\"error\", () => {});\n fileStream.destroy();\n throw err;\n }\n}\n\n/** Fetch a model into a `.partial` file, reject a truncated transfer via the\n * size floor, then atomically rename it into place. */\nasync function downloadModel(name: WhisperModelName, modelsDir: string, downloadStatus: Map<WhisperModelName, ModelStatus>): Promise<void> {\n const spec = WHISPER_MODELS[name];\n mkdirSync(modelsDir, { recursive: true });\n const dest = modelFilePath(modelsDir, name);\n const partial = `${dest}.partial`;\n const controller = new AbortController();\n let stallTimer: ReturnType<typeof setTimeout> | null = null;\n const resetStall = () => {\n if (stallTimer) clearTimeout(stallTimer);\n stallTimer = setTimeout(() => controller.abort(), DOWNLOAD_STALL_TIMEOUT_MS);\n };\n try {\n const response = await fetch(spec.url, { signal: controller.signal });\n if (!response.ok || !response.body) {\n throw new Error(`download failed: HTTP ${response.status}`);\n }\n const total = parseContentLength(response.headers.get(\"content-length\"));\n resetStall();\n await streamToFile(response.body, partial, total, name, { downloadStatus, onProgress: resetStall });\n if (statSync(partial).size < spec.minBytes) {\n unlinkSync(partial);\n throw new Error(\"downloaded file is smaller than expected — likely truncated\");\n }\n renameSync(partial, dest);\n } finally {\n if (stallTimer) clearTimeout(stallTimer);\n }\n}\n\nexport function createModelDownloader(modelsDir: string, logger: WhisperLogger = NOOP_LOGGER): ModelDownloader {\n // A finished file on disk is the source of truth for \"ready\"; this map tracks\n // transient progress + the last error.\n const downloadStatus = new Map<WhisperModelName, ModelStatus>();\n\n function getStatus(name: WhisperModelName): ModelStatus {\n return pickModelStatus(downloadStatus.get(name), isModelReady(modelsDir, name));\n }\n\n // Fire-and-forget friendly: errors land in the status map, never thrown.\n // Idempotent — a second call while a download is in flight does nothing.\n async function ensure(name: WhisperModelName): Promise<void> {\n if (isModelReady(modelsDir, name)) {\n downloadStatus.set(name, { state: \"ready\" });\n return;\n }\n if (downloadStatus.get(name)?.state === \"downloading\") return;\n downloadStatus.set(name, { state: \"downloading\", progress: 0 });\n logger.info(\"model download: start\", { model: name });\n try {\n await downloadModel(name, modelsDir, downloadStatus);\n downloadStatus.set(name, { state: \"ready\" });\n logger.info(\"model download: ok\", { model: name });\n } catch (err) {\n const error = errorMessage(err);\n downloadStatus.set(name, { state: \"error\", error });\n logger.error(\"model download: failed\", { model: name, error });\n }\n }\n\n return { getStatus, ensure };\n}\n","// Pure helpers for the whisper-server sidecar. Kept separate from `sidecar.ts`\n// so the argv/response/stderr logic can be unit-tested without spawning a\n// process or opening a socket.\n\n/** whisper-server CLI args to preload `modelPath` and bind to `host:port`. */\nexport function buildServerArgs(modelPath: string, host: string, port: number): string[] {\n return [\"--model\", modelPath, \"--host\", host, \"--port\", String(port)];\n}\n\n/** Append `chunk` to a bounded stderr tail, keeping only the last `maxChars`. */\nexport function appendStderrTail(previous: string, chunk: string, maxChars: number): string {\n return (previous + chunk).slice(-maxChars);\n}\n\n/** Extract the transcript from a whisper-server `/inference` JSON response.\n * Returns \"\" for any shape that lacks a string `text` field. */\nexport function parseInferenceText(data: unknown): string {\n if (typeof data === \"object\" && data !== null && \"text\" in data) {\n const { text } = data as { text: unknown };\n if (typeof text === \"string\") return text;\n }\n return \"\";\n}\n","// whisper.cpp warm-model sidecar. Spawns `whisper-server` once with the model\n// preloaded and reuses it across transcriptions over its local HTTP API, so the\n// weights stay resident (no per-request reload). State is encapsulated per\n// instance via the factory closure.\n\nimport { spawn, type ChildProcess } from \"node:child_process\";\nimport { createServer } from \"node:net\";\nimport { readFile } from \"node:fs/promises\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport { errorMessage, NOOP_LOGGER, ONE_MINUTE_MS, ONE_SECOND_MS, type WhisperLogger } from \"./internal.ts\";\nimport { modelFilePath, type WhisperModelName } from \"./models.ts\";\nimport { appendStderrTail, buildServerArgs, parseInferenceText } from \"./sidecar-helpers.ts\";\n\nconst HOST = \"127.0.0.1\";\nconst READY_TIMEOUT_MS = 60 * ONE_SECOND_MS;\nconst READY_POLL_INTERVAL_MS = 500;\nconst INFERENCE_TIMEOUT_MS = 2 * ONE_MINUTE_MS;\nconst STDERR_TAIL_MAX_CHARS = 4_000;\n\nexport interface ActiveSidecar {\n readonly port: number;\n readonly proc: ChildProcess;\n readonly model: WhisperModelName;\n}\n\nexport interface Sidecar {\n transcribeWav: (wavPath: string, language: string, model: WhisperModelName) => Promise<string>;\n warmup: (model: WhisperModelName) => Promise<void>;\n shutdown: () => void;\n}\n\nasync function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const srv = createServer();\n srv.on(\"error\", reject);\n srv.listen(0, HOST, () => {\n const addr = srv.address();\n if (addr && typeof addr === \"object\") {\n const { port } = addr;\n srv.close(() => resolve(port));\n } else {\n srv.close(() => reject(new Error(\"could not determine a free port\")));\n }\n });\n });\n}\n\n/** Resolve once the server answers any HTTP request (any status = listener up),\n * or throw after the ready timeout. */\nasync function waitUntilReady(port: number): Promise<void> {\n const deadline = Date.now() + READY_TIMEOUT_MS;\n while (Date.now() < deadline) {\n try {\n await fetch(`http://${HOST}:${port}/`, { signal: AbortSignal.timeout(ONE_SECOND_MS) });\n return;\n } catch {\n await delay(READY_POLL_INTERVAL_MS);\n }\n }\n throw new Error(\"whisper-server did not become ready in time\");\n}\n\n// whisper-server logs verbosely to stderr; left unread the OS pipe buffer fills\n// and the child blocks on its next write. Drain into a small tail buffer.\nfunction drainStderr(proc: ChildProcess, tail: { text: string }): void {\n proc.stderr?.setEncoding(\"utf8\");\n proc.stderr?.on(\"data\", (chunk: string) => {\n tail.text = appendStderrTail(tail.text, chunk, STDERR_TAIL_MAX_CHARS);\n });\n}\n\n// Resolve when the server answers, or reject on spawn failure (e.g. ENOENT) /\n// early exit. Listeners are one-shot and removed once the race settles.\nfunction waitForReadyOrFailure(proc: ChildProcess, port: number): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n let onError: (err: Error) => void = () => undefined;\n let onExit: (code: number | null) => void = () => undefined;\n const cleanup = () => {\n proc.removeListener(\"error\", onError);\n proc.removeListener(\"exit\", onExit);\n };\n onError = (err: Error) => {\n cleanup();\n reject(new Error(`spawn failed: ${errorMessage(err)}`));\n };\n onExit = (code: number | null) => {\n cleanup();\n reject(new Error(`exited early (code ${code})`));\n };\n proc.once(\"error\", onError);\n proc.once(\"exit\", onExit);\n waitUntilReady(port)\n .then(() => {\n cleanup();\n resolve();\n })\n .catch((err: unknown) => {\n cleanup();\n reject(err instanceof Error ? err : new Error(String(err)));\n });\n });\n}\n\n// POST the wav to whisper-server's `/inference` and return the transcript.\n// Isolated from the lifecycle so the HTTP contract is unit-testable without a\n// live child (stub `fetch`, hand it a real wav path).\nexport async function postInference(port: number, wavPath: string, language: string): Promise<string> {\n const buf = await readFile(wavPath);\n const form = new FormData();\n form.append(\"file\", new Blob([buf], { type: \"audio/wav\" }), \"audio.wav\");\n form.append(\"response_format\", \"json\");\n form.append(\"language\", language || \"auto\");\n let res: Response;\n try {\n res = await fetch(`http://${HOST}:${port}/inference`, { method: \"POST\", body: form, signal: AbortSignal.timeout(INFERENCE_TIMEOUT_MS) });\n } catch (err) {\n throw new Error(`whisper-server request failed: ${errorMessage(err)}`);\n }\n if (!res.ok) throw new Error(`whisper-server returned HTTP ${res.status}`);\n return parseInferenceText(await res.json());\n}\n\n// The impure primitives the start lifecycle depends on, injected so the\n// cancellation / single-flight / model-switch logic can be driven with fakes.\nexport interface StartLifecycleDeps {\n /** Reserve a free TCP port for the next child — the sole async step of a start. */\n allocatePort: () => Promise<number>;\n /** Spawn the whisper-server child on `port`. MUST be synchronous so the spawn\n * and the `startingProc` handoff stay in one tick: `shutdown()` has to be able\n * to kill an in-flight child immediately, with no await gap after it exists. */\n spawnServer: (model: WhisperModelName, port: number) => ChildProcess;\n /** Resolve once the child answers HTTP, or reject on spawn failure / early exit. */\n waitReady: (proc: ChildProcess, port: number) => Promise<void>;\n logger: WhisperLogger;\n}\n\nexport interface StartLifecycle {\n ensureSidecar: (model: WhisperModelName) => Promise<ActiveSidecar>;\n shutdown: () => void;\n}\n\nexport function defaultSpawnServer(modelsDir: string, serverBinary: string, logger: WhisperLogger): StartLifecycleDeps[\"spawnServer\"] {\n return (model: WhisperModelName, port: number): ChildProcess => {\n const args = buildServerArgs(modelFilePath(modelsDir, model), HOST, port);\n logger.info(\"sidecar: spawning\", { model, port });\n return spawn(serverBinary, args, { stdio: [\"ignore\", \"ignore\", \"pipe\"] });\n };\n}\n\n// Wires the three listeners every whisper-server child needs: stderr drain,\n// permanent `error` handler (an unhandled 'error' from ENOENT etc. would crash\n// the host), and `exit` handler that both logs and lets the caller decide\n// whether this proc is still the active one (identity check via `onExit` — a\n// stale process must not evict a newer live sidecar).\nfunction instrumentChildProcess(proc: ChildProcess, model: WhisperModelName, logger: WhisperLogger, onExit: (exited: ChildProcess) => void): { text: string } {\n const stderrTail = { text: \"\" };\n drainStderr(proc, stderrTail);\n proc.on(\"error\", (err) => logger.warn(\"sidecar: process error\", { model, error: errorMessage(err) }));\n proc.on(\"exit\", (code) => {\n logger.warn(\"sidecar: exited\", { model, code, stderrTail: stderrTail.text.slice(-500) });\n onExit(proc);\n });\n return stderrTail;\n}\n\n// Await server readiness; on failure, kill the child and surface both the\n// underlying error and the tail of stderr (whisper-server usually explains its\n// crash there). Kept at module scope so `startSidecar` stays under the\n// per-function line cap.\nasync function awaitReadyOrThrow(proc: ChildProcess, port: number, stderrTail: { text: string }, waitReady: StartLifecycleDeps[\"waitReady\"]): Promise<void> {\n try {\n await waitReady(proc, port);\n } catch (err) {\n proc.kill();\n throw new Error(`whisper-server failed to start: ${errorMessage(err)} — stderr: ${stderrTail.text.slice(-500)}`);\n }\n}\n\n// Abort a start that was raced by `shutdown()` (or a newer start). Compares\n// the token captured at start entry against the current token; if they differ,\n// kill the freshly-booted child rather than publish it after shutdown returned.\nfunction throwIfStartCancelled(startedToken: number, currentToken: number, proc: ChildProcess): void {\n if (startedToken !== currentToken) {\n proc.kill();\n throw new Error(\"whisper-server start cancelled\");\n }\n}\n\n// Owns the single warm child's lifecycle: which model is live, the in-flight\n// start, and the cancellation token. Kept as a factory closure so the mutable\n// state is encapsulated rather than threaded through parameters.\nexport function createStartLifecycle(deps: StartLifecycleDeps): StartLifecycle {\n const { allocatePort, spawnServer, waitReady, logger } = deps;\n let sidecar: ActiveSidecar | null = null;\n let starting: { model: WhisperModelName; promise: Promise<ActiveSidecar> } | null = null;\n // The child of an in-flight start (before it's published as `sidecar`), plus a\n // token that `shutdown()` bumps to cancel a start that's still booting — so\n // shutdown can't return with a child that then publishes itself afterwards.\n let startingProc: ChildProcess | null = null;\n let startToken = 0;\n\n function shutdown(): void {\n startToken += 1;\n startingProc?.kill();\n startingProc = null;\n sidecar?.proc.kill();\n sidecar = null;\n }\n\n async function startSidecar(model: WhisperModelName): Promise<ActiveSidecar> {\n const token = ++startToken;\n const port = await allocatePort();\n const proc = spawnServer(model, port);\n startingProc = proc;\n // onExit fires when THIS proc exits: only null out `sidecar` if it's still the live one.\n const stderrTail = instrumentChildProcess(proc, model, logger, (exited) => sidecar?.proc === exited && (sidecar = null));\n try {\n await awaitReadyOrThrow(proc, port, stderrTail, waitReady);\n } finally {\n if (startingProc === proc) startingProc = null;\n }\n throwIfStartCancelled(token, startToken, proc);\n sidecar = { port, proc, model };\n logger.info(\"sidecar: ready\", { model, port });\n return sidecar;\n }\n\n // Own function so it isn't a loop closure (no-loop-func). Only ever one start\n // is in flight at a time, so clearing `starting` on settle is safe.\n function beginStart(model: WhisperModelName): Promise<ActiveSidecar> {\n const promise = startSidecar(model).finally(() => (starting = null));\n starting = { model, promise };\n return promise;\n }\n\n async function ensureSidecar(model: WhisperModelName): Promise<ActiveSidecar> {\n // Loop so that after awaiting an in-flight start for a DIFFERENT model we\n // re-evaluate; the decision-to-spawn path (beginStart) has no await, so the\n // first waiter sets `starting` synchronously and siblings then reuse it.\n for (;;) {\n if (sidecar && sidecar.model === model && !sidecar.proc.killed) return sidecar;\n if (starting && starting.model === model) return starting.promise;\n if (starting) {\n await starting.promise.catch(() => undefined);\n continue;\n }\n if (sidecar && sidecar.model !== model) shutdown();\n return beginStart(model);\n }\n }\n\n return { ensureSidecar, shutdown };\n}\n\nexport function createSidecar(modelsDir: string, serverBinary = \"whisper-server\", logger: WhisperLogger = NOOP_LOGGER): Sidecar {\n const lifecycle = createStartLifecycle({\n allocatePort: findFreePort,\n spawnServer: defaultSpawnServer(modelsDir, serverBinary, logger),\n waitReady: waitForReadyOrFailure,\n logger,\n });\n\n async function warmup(model: WhisperModelName): Promise<void> {\n try {\n await lifecycle.ensureSidecar(model);\n } catch (err) {\n logger.warn(\"sidecar: warmup failed\", { model, error: errorMessage(err) });\n }\n }\n\n async function transcribeWav(wavPath: string, language: string, model: WhisperModelName): Promise<string> {\n const active = await lifecycle.ensureSidecar(model);\n return postInference(active.port, wavPath, language);\n }\n\n return { transcribeWav, warmup, shutdown: lifecycle.shutdown };\n}\n","// Public server-side façade: wires the model downloader, the warm sidecar, and\n// ffmpeg conversion into one host-agnostic service. The host injects the models\n// directory + a logger and gates capability itself (platform / binary presence);\n// this package assumes the binaries exist when called.\n\nimport { mkdirSync } from \"node:fs\";\nimport { rm, writeFile } from \"node:fs/promises\";\nimport { randomUUID } from \"node:crypto\";\nimport path from \"node:path\";\nimport { NOOP_LOGGER, type WhisperLogger } from \"./internal.ts\";\nimport { convertToWav16k } from \"./ffmpeg.ts\";\nimport { createModelDownloader } from \"./models.ts\";\nimport { createSidecar } from \"./sidecar.ts\";\nimport { isModelReady, type ModelStatus, type WhisperModelName } from \"./models.ts\";\n\nexport interface WhisperOptions {\n /** Directory that holds the GGML model files (e.g. `{workspace}/models`). */\n modelsDir: string;\n logger?: WhisperLogger;\n /** Defaults to \"whisper-server\" / \"ffmpeg\" on PATH. */\n serverBinary?: string;\n ffmpegBinary?: string;\n}\n\nexport interface TranscribeRequest {\n base64: string;\n mimeType: string;\n language: string;\n model: WhisperModelName;\n}\n\nexport interface Whisper {\n isModelReady: (model: WhisperModelName) => boolean;\n getModelStatus: (model: WhisperModelName) => ModelStatus;\n /** Fire-and-forget friendly; never throws (errors land in the status). */\n ensureModelDownloaded: (model: WhisperModelName) => Promise<void>;\n warmup: (model: WhisperModelName) => Promise<void>;\n transcribe: (req: TranscribeRequest) => Promise<{ text: string }>;\n shutdown: () => void;\n}\n\n// whisper.cpp returns these sentinels for non-speech windows; treat them as\n// empty so the UI shows \"didn't catch that\" rather than a literal marker.\nconst BLANK_MARKERS = new Set([\"[blank_audio]\", \"[silence]\", \"(silence)\", \"[ inaudible ]\"]);\n\nfunction normalizeTranscript(raw: string): string {\n const trimmed = raw.trim().replace(/\\s+/g, \" \");\n return BLANK_MARKERS.has(trimmed.toLowerCase()) ? \"\" : trimmed;\n}\n\nexport function createWhisper(opts: WhisperOptions): Whisper {\n const { modelsDir } = opts;\n const logger = opts.logger ?? NOOP_LOGGER;\n const ffmpegBinary = opts.ffmpegBinary ?? \"ffmpeg\";\n const downloader = createModelDownloader(modelsDir, logger);\n const sidecar = createSidecar(modelsDir, opts.serverBinary ?? \"whisper-server\", logger);\n\n // Scratch dir for transient audio — a hidden subdir of the models dir so it\n // shares the (non-git) models tree. Files are deleted after each transcription.\n const scratchDir = path.join(modelsDir, \".scratch\");\n\n async function transcribe(req: TranscribeRequest): Promise<{ text: string }> {\n mkdirSync(scratchDir, { recursive: true });\n const clipId = randomUUID();\n const inputPath = path.join(scratchDir, `utterance-${clipId}.webm`);\n const wavPath = path.join(scratchDir, `utterance-${clipId}.wav`);\n try {\n await writeFile(inputPath, Buffer.from(req.base64, \"base64\"));\n await convertToWav16k(inputPath, wavPath, ffmpegBinary);\n const text = await sidecar.transcribeWav(wavPath, req.language, req.model);\n return { text: normalizeTranscript(text) };\n } finally {\n await rm(inputPath, { force: true });\n await rm(wavPath, { force: true });\n }\n }\n\n return {\n isModelReady: (model) => isModelReady(modelsDir, model),\n getModelStatus: (model) => downloader.getStatus(model),\n ensureModelDownloaded: (model) => downloader.ensure(model),\n warmup: (model) => sidecar.warmup(model),\n transcribe,\n shutdown: () => sidecar.shutdown(),\n };\n}\n"],"mappings":"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"}
@@ -1,8 +1,32 @@
1
+ import { ChildProcess } from 'node:child_process';
1
2
  import { WhisperLogger } from './internal.ts';
2
3
  import { WhisperModelName } from './models.ts';
4
+ export interface ActiveSidecar {
5
+ readonly port: number;
6
+ readonly proc: ChildProcess;
7
+ readonly model: WhisperModelName;
8
+ }
3
9
  export interface Sidecar {
4
10
  transcribeWav: (wavPath: string, language: string, model: WhisperModelName) => Promise<string>;
5
11
  warmup: (model: WhisperModelName) => Promise<void>;
6
12
  shutdown: () => void;
7
13
  }
14
+ export declare function postInference(port: number, wavPath: string, language: string): Promise<string>;
15
+ export interface StartLifecycleDeps {
16
+ /** Reserve a free TCP port for the next child — the sole async step of a start. */
17
+ allocatePort: () => Promise<number>;
18
+ /** Spawn the whisper-server child on `port`. MUST be synchronous so the spawn
19
+ * and the `startingProc` handoff stay in one tick: `shutdown()` has to be able
20
+ * to kill an in-flight child immediately, with no await gap after it exists. */
21
+ spawnServer: (model: WhisperModelName, port: number) => ChildProcess;
22
+ /** Resolve once the child answers HTTP, or reject on spawn failure / early exit. */
23
+ waitReady: (proc: ChildProcess, port: number) => Promise<void>;
24
+ logger: WhisperLogger;
25
+ }
26
+ export interface StartLifecycle {
27
+ ensureSidecar: (model: WhisperModelName) => Promise<ActiveSidecar>;
28
+ shutdown: () => void;
29
+ }
30
+ export declare function defaultSpawnServer(modelsDir: string, serverBinary: string, logger: WhisperLogger): StartLifecycleDeps["spawnServer"];
31
+ export declare function createStartLifecycle(deps: StartLifecycleDeps): StartLifecycle;
8
32
  export declare function createSidecar(modelsDir: string, serverBinary?: string, logger?: WhisperLogger): Sidecar;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmoclaude/core",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Shared server-side core for MulmoClaude and MulmoTerminal — the always-shipped-together subsystems consolidated behind subpath exports so the two hosts can't drift. Server-only except the browser-safe ./whisper/client, ./workspace-setup/slug, ./translation/client, ./remote-view and ./remote-host entries. All host specifics are injected.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -160,7 +160,7 @@
160
160
  "test": "tsx --test test/**/test_*.ts"
161
161
  },
162
162
  "dependencies": {
163
- "fast-xml-parser": "^5.9.3",
163
+ "fast-xml-parser": "^5.10.0",
164
164
  "js-yaml": "^5.2.1",
165
165
  "zod": "^4.4.3"
166
166
  },
@@ -180,7 +180,7 @@
180
180
  "gui-chat-protocol": "^0.4.0",
181
181
  "tsx": "^4.23.0",
182
182
  "typescript": "^6.0.3",
183
- "vite": "^8.1.3",
183
+ "vite": "^8.1.4",
184
184
  "vite-plugin-dts": "^5.0.3"
185
185
  },
186
186
  "license": "MIT"