@mulmoclaude/core 0.1.0 → 0.2.2
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.
- package/assets/helps/collection-skills.md +52 -4
- package/assets/helps/feeds.md +16 -7
- package/assets/skills-preset/mc-manage-automations/SKILL.md +36 -2
- package/dist/collection/core/schema.d.ts +37 -10
- package/dist/collection/index.cjs +4 -3
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.js +4 -4
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/paths.cjs.map +1 -1
- package/dist/collection/paths.js.map +1 -1
- package/dist/collection/server/discovery.d.ts +14 -2
- package/dist/collection/server/index.cjs +46 -1716
- package/dist/collection/server/index.js +1 -1669
- package/dist/collection/server/io.d.ts +1 -1
- package/dist/collection-watchers/config.d.ts +49 -0
- package/dist/collection-watchers/index.cjs +553 -0
- package/dist/collection-watchers/index.cjs.map +1 -0
- package/dist/collection-watchers/index.d.ts +3 -0
- package/dist/collection-watchers/index.js +539 -0
- package/dist/collection-watchers/index.js.map +1 -0
- package/dist/collection-watchers/reconciler.d.ts +33 -0
- package/dist/collection-watchers/watcher.d.ts +34 -0
- package/dist/{deriveAll-C15OpM3K.cjs → deriveAll-VRWrs3SF.cjs} +19 -5
- package/dist/deriveAll-VRWrs3SF.cjs.map +1 -0
- package/dist/{deriveAll-C6BYnpBL.js → deriveAll-vzIhhKBK.js} +14 -6
- package/dist/deriveAll-vzIhhKBK.js.map +1 -0
- package/dist/file-change/index.cjs +2 -2
- package/dist/file-change/index.cjs.map +1 -1
- package/dist/notifier/index.cjs +20 -483
- package/dist/notifier/index.js +1 -463
- package/dist/notifier-ChpY0XrY.js +464 -0
- package/dist/{notifier/index.js.map → notifier-ChpY0XrY.js.map} +1 -1
- package/dist/notifier-bS8IEeLA.cjs +577 -0
- package/dist/{notifier/index.cjs.map → notifier-bS8IEeLA.cjs.map} +1 -1
- package/dist/scheduler/index.cjs +2 -2
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.js.map +1 -1
- package/dist/server-BPDCdvOI.js +1680 -0
- package/dist/server-BPDCdvOI.js.map +1 -0
- package/dist/server-Bego8VyU.cjs +1951 -0
- package/dist/server-Bego8VyU.cjs.map +1 -0
- package/dist/skill-bridge/index.cjs +88 -0
- package/dist/skill-bridge/index.cjs.map +1 -0
- package/dist/skill-bridge/index.d.ts +30 -0
- package/dist/skill-bridge/index.js +80 -0
- package/dist/skill-bridge/index.js.map +1 -0
- package/dist/whisper/client.cjs +1 -1
- package/dist/whisper/client.cjs.map +1 -1
- package/dist/whisper/client.js +1 -1
- package/dist/whisper/client.js.map +1 -1
- package/dist/whisper/index.cjs +3 -3
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/whisper/index.js +1 -1
- package/dist/whisper/index.js.map +1 -1
- package/dist/workspace-setup/index.js.map +1 -1
- package/package.json +15 -3
- package/dist/collection/server/index.cjs.map +0 -1
- package/dist/collection/server/index.js.map +0 -1
- package/dist/deriveAll-C15OpM3K.cjs.map +0 -1
- package/dist/deriveAll-C6BYnpBL.js.map +0 -1
- /package/dist/{chunk-CKQMccvm.cjs → rolldown-runtime-D6vf50IK.cjs} +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { isSafeActionTemplatePath } from "../collection/paths.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
//#region src/skill-bridge/index.ts
|
|
5
|
+
var DATA_SKILLS_DIR = path.join("data", "skills");
|
|
6
|
+
var CLAUDE_SKILLS_DIR = path.join(".claude", "skills");
|
|
7
|
+
var SKILL_FILENAME = "SKILL.md";
|
|
8
|
+
var SCHEMA_FILENAME = "schema.json";
|
|
9
|
+
var SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
10
|
+
var RM_RE = /^\s*rm\s+((?:-[a-zA-Z]+\s+)+)['"]?data\/skills\/([a-z0-9-]+)\/?['"]?\s*$/;
|
|
11
|
+
var RECURSIVE_FLAG_RE = /[rR]/;
|
|
12
|
+
/** `<workspaceRoot>/data/skills/<slug>` — the staging dir for a skill. */
|
|
13
|
+
function dataSkillDir(workspaceRoot, slug) {
|
|
14
|
+
return path.join(workspaceRoot, DATA_SKILLS_DIR, slug);
|
|
15
|
+
}
|
|
16
|
+
/** `<workspaceRoot>/.claude/skills/<slug>` — the active dir Claude CLI discovers. */
|
|
17
|
+
function claudeSkillDir(workspaceRoot, slug) {
|
|
18
|
+
return path.join(workspaceRoot, CLAUDE_SKILLS_DIR, slug);
|
|
19
|
+
}
|
|
20
|
+
function isAllowlisted(relSegments) {
|
|
21
|
+
if (relSegments.length === 1) return relSegments[0] === SKILL_FILENAME || relSegments[0] === SCHEMA_FILENAME;
|
|
22
|
+
return isSafeActionTemplatePath(relSegments.join("/"));
|
|
23
|
+
}
|
|
24
|
+
/** Resolve a Write/Edit path to the staging file it mirrors, or null when it isn't a
|
|
25
|
+
* bridged file (not under `data/skills/<slug>/`, bad slug, or off the allowlist). */
|
|
26
|
+
function bridgeTargetFromDataPath(workspaceRoot, filePath) {
|
|
27
|
+
const staging = path.join(workspaceRoot, DATA_SKILLS_DIR);
|
|
28
|
+
const rel = path.relative(staging, filePath);
|
|
29
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
30
|
+
const segments = rel.split(path.sep);
|
|
31
|
+
if (segments.length < 2) return null;
|
|
32
|
+
const [slug, ...relSegments] = segments;
|
|
33
|
+
if (!SLUG_RE.test(slug)) return null;
|
|
34
|
+
if (!isAllowlisted(relSegments)) return null;
|
|
35
|
+
return {
|
|
36
|
+
slug,
|
|
37
|
+
relSegments
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Extract the slug from a Bash `rm -rf data/skills/<slug>` command, or null on any
|
|
41
|
+
* mismatch (wildcards, paths outside staging, non-recursive `rm`). */
|
|
42
|
+
function slugFromRmCommand(command) {
|
|
43
|
+
const match = RM_RE.exec(command);
|
|
44
|
+
if (!match) return null;
|
|
45
|
+
const [, flags, slug] = match;
|
|
46
|
+
if (!RECURSIVE_FLAG_RE.test(flags)) return null;
|
|
47
|
+
return SLUG_RE.test(slug) ? slug : null;
|
|
48
|
+
}
|
|
49
|
+
/** Atomic mirror of one allowlisted staging file → its active path: write a tmp file
|
|
50
|
+
* in the dest dir, then rename onto the canonical path (atomic on POSIX; both paths
|
|
51
|
+
* share a filesystem). A killed mid-write leaves a harmless tmp and the previous
|
|
52
|
+
* SKILL.md intact. Returns the resolved src/dest for logging. */
|
|
53
|
+
function mirrorSkillWrite(workspaceRoot, target) {
|
|
54
|
+
const { slug, relSegments } = target;
|
|
55
|
+
const src = path.join(dataSkillDir(workspaceRoot, slug), ...relSegments);
|
|
56
|
+
const content = readFileSync(src, "utf-8");
|
|
57
|
+
const dest = path.join(claudeSkillDir(workspaceRoot, slug), ...relSegments);
|
|
58
|
+
const destDir = path.dirname(dest);
|
|
59
|
+
mkdirSync(destDir, { recursive: true });
|
|
60
|
+
const tmp = path.join(destDir, `.${path.basename(dest)}.${process.pid}.tmp`);
|
|
61
|
+
writeFileSync(tmp, content, "utf-8");
|
|
62
|
+
renameSync(tmp, dest);
|
|
63
|
+
return {
|
|
64
|
+
src,
|
|
65
|
+
dest
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Remove the active skill dir (mirrors a staging `rm -rf`). Returns the dest. */
|
|
69
|
+
function mirrorSkillDelete(workspaceRoot, slug) {
|
|
70
|
+
const dest = claudeSkillDir(workspaceRoot, slug);
|
|
71
|
+
rmSync(dest, {
|
|
72
|
+
recursive: true,
|
|
73
|
+
force: true
|
|
74
|
+
});
|
|
75
|
+
return { dest };
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
export { bridgeTargetFromDataPath, claudeSkillDir, dataSkillDir, mirrorSkillDelete, mirrorSkillWrite, slugFromRmCommand };
|
|
79
|
+
|
|
80
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/skill-bridge/index.ts"],"sourcesContent":["// @mulmoclaude/core/skill-bridge — the skill staging→active mirror RULE, shared by\n// MulmoClaude and MulmoTerminal so the two hosts can't drift on what crosses the\n// `.claude/` permission gate.\n//\n// The agent writes skill drafts under `data/skills/<slug>/` (a plain data dir, no\n// `.claude/` permission special-case). This package decides which writes are\n// allowlisted and mirrors them 1:1 into `.claude/skills/<slug>/` so Claude CLI's\n// skill discovery + the collection engine pick them up. It also mirrors a\n// `rm -rf data/skills/<slug>` delete.\n//\n// What crosses — a FIXED ALLOWLIST, not the whole dir:\n// SKILL.md the skill body Claude CLI reads\n// schema.json the collection definition (a \"collection skill\" ships one)\n// templates/<path> action templates a schema's `actions` reference — exactly the\n// set the schema validator accepts (`isSafeActionTemplatePath`)\n// Everything else (README.md, assets/, arbitrary nesting) stays staging-side.\n//\n// Pure rule + fs ops, parameterized by `workspaceRoot` (passed in, not read from a\n// host module) so either host can call it. Each host wires this into its own hook /\n// PostToolUse path and triggers its own config refresh afterward.\nimport path from \"node:path\";\nimport { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport { isSafeActionTemplatePath } from \"../collection/server/templatePath\";\n\nconst DATA_SKILLS_DIR = path.join(\"data\", \"skills\");\nconst CLAUDE_SKILLS_DIR = path.join(\".claude\", \"skills\");\nconst SKILL_FILENAME = \"SKILL.md\";\nconst SCHEMA_FILENAME = \"schema.json\";\n\n// Slugs follow Claude Code's skill-name convention: lowercase ASCII letters/digits\n// with single-hyphen separators. Strict so a typo / traversal never reaches the dest.\n// eslint-disable-next-line security/detect-unsafe-regex -- input is a basename slice ≤64 chars; bounded backtracking.\nconst SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;\n\n// `rm -rf data/skills/<slug>` matcher. `match[1]` is the flag run (must contain a\n// recursive flag); `match[2]` is the slug. Wildcards / the parent dir / non-recursive\n// `rm` are intentionally NOT matched (a non-recursive rm can't delete the dir anyway,\n// so mirroring would desync the trees).\n// eslint-disable-next-line security/detect-unsafe-regex -- single-line captured Bash command; bounded.\nconst RM_RE = /^\\s*rm\\s+((?:-[a-zA-Z]+\\s+)+)['\"]?data\\/skills\\/([a-z0-9-]+)\\/?['\"]?\\s*$/;\nconst RECURSIVE_FLAG_RE = /[rR]/;\n\n/** A staging file cleared to cross the `.claude/` gate. */\nexport interface BridgeTarget {\n /** The skill slug (the dir directly under data/skills/). */\n slug: string;\n /** Path segments BELOW the slug dir, e.g. `[\"SKILL.md\"]`, `[\"templates\",\"x.md\"]`.\n * Used verbatim for both source and destination so the mirror is 1:1. */\n relSegments: string[];\n}\n\n/** `<workspaceRoot>/data/skills/<slug>` — the staging dir for a skill. */\nexport function dataSkillDir(workspaceRoot: string, slug: string): string {\n return path.join(workspaceRoot, DATA_SKILLS_DIR, slug);\n}\n\n/** `<workspaceRoot>/.claude/skills/<slug>` — the active dir Claude CLI discovers. */\nexport function claudeSkillDir(workspaceRoot: string, slug: string): string {\n return path.join(workspaceRoot, CLAUDE_SKILLS_DIR, slug);\n}\n\n// Allowlist a path below the slug dir: SKILL.md / schema.json at the top, or a safe\n// `templates/<path>` (the same predicate the schema validator uses, so what a schema\n// can declare is exactly what crosses). Everything else → false.\nfunction isAllowlisted(relSegments: string[]): boolean {\n if (relSegments.length === 1) {\n return relSegments[0] === SKILL_FILENAME || relSegments[0] === SCHEMA_FILENAME;\n }\n return isSafeActionTemplatePath(relSegments.join(\"/\"));\n}\n\n/** Resolve a Write/Edit path to the staging file it mirrors, or null when it isn't a\n * bridged file (not under `data/skills/<slug>/`, bad slug, or off the allowlist). */\nexport function bridgeTargetFromDataPath(workspaceRoot: string, filePath: string): BridgeTarget | null {\n const staging = path.join(workspaceRoot, DATA_SKILLS_DIR);\n const rel = path.relative(staging, filePath);\n if (!rel || rel.startsWith(\"..\") || path.isAbsolute(rel)) return null;\n const segments = rel.split(path.sep);\n if (segments.length < 2) return null; // need at least `<slug>/<file>`\n const [slug, ...relSegments] = segments;\n if (!SLUG_RE.test(slug)) return null;\n if (!isAllowlisted(relSegments)) return null;\n return { slug, relSegments };\n}\n\n/** Extract the slug from a Bash `rm -rf data/skills/<slug>` command, or null on any\n * mismatch (wildcards, paths outside staging, non-recursive `rm`). */\nexport function slugFromRmCommand(command: string): string | null {\n const match = RM_RE.exec(command);\n if (!match) return null;\n const [, flags, slug] = match;\n if (!RECURSIVE_FLAG_RE.test(flags)) return null;\n return SLUG_RE.test(slug) ? slug : null;\n}\n\n/** Atomic mirror of one allowlisted staging file → its active path: write a tmp file\n * in the dest dir, then rename onto the canonical path (atomic on POSIX; both paths\n * share a filesystem). A killed mid-write leaves a harmless tmp and the previous\n * SKILL.md intact. Returns the resolved src/dest for logging. */\nexport function mirrorSkillWrite(workspaceRoot: string, target: BridgeTarget): { src: string; dest: string } {\n const { slug, relSegments } = target;\n const src = path.join(dataSkillDir(workspaceRoot, slug), ...relSegments);\n const content = readFileSync(src, \"utf-8\");\n const dest = path.join(claudeSkillDir(workspaceRoot, slug), ...relSegments);\n const destDir = path.dirname(dest);\n mkdirSync(destDir, { recursive: true }); // covers templates/ + the slug dir on first install\n const tmp = path.join(destDir, `.${path.basename(dest)}.${process.pid}.tmp`);\n writeFileSync(tmp, content, \"utf-8\");\n renameSync(tmp, dest);\n return { src, dest };\n}\n\n/** Remove the active skill dir (mirrors a staging `rm -rf`). Returns the dest. */\nexport function mirrorSkillDelete(workspaceRoot: string, slug: string): { dest: string } {\n const dest = claudeSkillDir(workspaceRoot, slug);\n rmSync(dest, { recursive: true, force: true });\n return { dest };\n}\n"],"mappings":";;;;AAwBA,IAAM,kBAAkB,KAAK,KAAK,QAAQ,QAAQ;AAClD,IAAM,oBAAoB,KAAK,KAAK,WAAW,QAAQ;AACvD,IAAM,iBAAiB;AACvB,IAAM,kBAAkB;AAKxB,IAAM,UAAU;AAOhB,IAAM,QAAQ;AACd,IAAM,oBAAoB;;AAY1B,SAAgB,aAAa,eAAuB,MAAsB;CACxE,OAAO,KAAK,KAAK,eAAe,iBAAiB,IAAI;AACvD;;AAGA,SAAgB,eAAe,eAAuB,MAAsB;CAC1E,OAAO,KAAK,KAAK,eAAe,mBAAmB,IAAI;AACzD;AAKA,SAAS,cAAc,aAAgC;CACrD,IAAI,YAAY,WAAW,GACzB,OAAO,YAAY,OAAO,kBAAkB,YAAY,OAAO;CAEjE,OAAO,yBAAyB,YAAY,KAAK,GAAG,CAAC;AACvD;;;AAIA,SAAgB,yBAAyB,eAAuB,UAAuC;CACrG,MAAM,UAAU,KAAK,KAAK,eAAe,eAAe;CACxD,MAAM,MAAM,KAAK,SAAS,SAAS,QAAQ;CAC3C,IAAI,CAAC,OAAO,IAAI,WAAW,IAAI,KAAK,KAAK,WAAW,GAAG,GAAG,OAAO;CACjE,MAAM,WAAW,IAAI,MAAM,KAAK,GAAG;CACnC,IAAI,SAAS,SAAS,GAAG,OAAO;CAChC,MAAM,CAAC,MAAM,GAAG,eAAe;CAC/B,IAAI,CAAC,QAAQ,KAAK,IAAI,GAAG,OAAO;CAChC,IAAI,CAAC,cAAc,WAAW,GAAG,OAAO;CACxC,OAAO;EAAE;EAAM;CAAY;AAC7B;;;AAIA,SAAgB,kBAAkB,SAAgC;CAChE,MAAM,QAAQ,MAAM,KAAK,OAAO;CAChC,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,GAAG,OAAO,QAAQ;CACxB,IAAI,CAAC,kBAAkB,KAAK,KAAK,GAAG,OAAO;CAC3C,OAAO,QAAQ,KAAK,IAAI,IAAI,OAAO;AACrC;;;;;AAMA,SAAgB,iBAAiB,eAAuB,QAAqD;CAC3G,MAAM,EAAE,MAAM,gBAAgB;CAC9B,MAAM,MAAM,KAAK,KAAK,aAAa,eAAe,IAAI,GAAG,GAAG,WAAW;CACvE,MAAM,UAAU,aAAa,KAAK,OAAO;CACzC,MAAM,OAAO,KAAK,KAAK,eAAe,eAAe,IAAI,GAAG,GAAG,WAAW;CAC1E,MAAM,UAAU,KAAK,QAAQ,IAAI;CACjC,UAAU,SAAS,EAAE,WAAW,KAAK,CAAC;CACtC,MAAM,MAAM,KAAK,KAAK,SAAS,IAAI,KAAK,SAAS,IAAI,EAAE,GAAG,QAAQ,IAAI,KAAK;CAC3E,cAAc,KAAK,SAAS,OAAO;CACnC,WAAW,KAAK,IAAI;CACpB,OAAO;EAAE;EAAK;CAAK;AACrB;;AAGA,SAAgB,kBAAkB,eAAuB,MAAgC;CACvF,MAAM,OAAO,eAAe,eAAe,IAAI;CAC/C,OAAO,MAAM;EAAE,WAAW;EAAM,OAAO;CAAK,CAAC;CAC7C,OAAO,EAAE,KAAK;AAChB"}
|
package/dist/whisper/client.cjs
CHANGED
|
@@ -69,7 +69,7 @@ function createVoiceCapture(transport, language, callbacks) {
|
|
|
69
69
|
let stream = null;
|
|
70
70
|
let audioCtx = null;
|
|
71
71
|
let analyser = null;
|
|
72
|
-
let vadBuffer = new Float32Array(0);
|
|
72
|
+
let vadBuffer = /* @__PURE__ */ new Float32Array(0);
|
|
73
73
|
let monitorHandle = null;
|
|
74
74
|
let recorder = null;
|
|
75
75
|
let chunks = [];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.cjs","names":[],"sources":["../../src/whisper/client.ts"],"sourcesContent":["// @mulmoclaude/core/whisper/client — framework-neutral browser capture controller.\n// Records one utterance at a time with MediaRecorder, segments on pauses via a\n// Web Audio VAD, and sends each segment through an injected transport. State is\n// pushed via `onState`; the host wraps this into its own reactivity (Vue refs,\n// React state, …). No framework dependency. See plans/feat-extract-whisper-package.md.\n\n// Map a UI locale to a Whisper language code. UI language is a strong prior for\n// the spoken language; \"auto\" lets Whisper detect it from the audio.\nconst LOCALE_TO_WHISPER: Record<string, string> = {\n en: \"en\",\n ja: \"ja\",\n zh: \"zh\",\n ko: \"ko\",\n es: \"es\",\n \"pt-BR\": \"pt\",\n fr: \"fr\",\n de: \"de\",\n};\n\nexport function localeToWhisperLanguage(locale: string): string {\n return LOCALE_TO_WHISPER[locale] ?? \"auto\";\n}\n\n// VAD tuning. RMS over [-1,1] float samples; a pause is SILENCE_MS of\n// sub-threshold level after speech. MAX_SEGMENT_MS force-cuts a long unbroken\n// utterance so no clip exceeds Whisper's 30s window or the server's size cap.\nconst SPEECH_RMS = 0.015;\nconst SILENCE_MS = 800;\nconst MAX_SEGMENT_MS = 20_000;\nconst MONITOR_INTERVAL_MS = 100;\nconst AVAILABILITY_POLL_MS = 2_000;\n\nfunction pickRecorderMime(): string | undefined {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\"];\n if (typeof MediaRecorder === \"undefined\") return undefined;\n return candidates.find((type) => MediaRecorder.isTypeSupported(type));\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"FileReader failed\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction computeRms(buffer: Float32Array): number {\n let sum = 0;\n for (const sample of buffer) sum += sample * sample;\n return Math.sqrt(sum / buffer.length);\n}\n\nfunction toMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport interface VoiceCaptureTransport {\n /** Transcribe one segment. Throws on failure. */\n transcribe: (dataUrl: string, language: string) => Promise<{ text: string }>;\n /** Current readiness. `downloading` true keeps the controller polling so it\n * flips to ready as soon as a model download finishes. */\n getStatus: () => Promise<{ ready: boolean; downloading: boolean }>;\n}\n\nexport interface VoiceCaptureState {\n available: boolean;\n listening: boolean;\n transcribing: boolean;\n}\n\nexport interface VoiceCaptureCallbacks {\n /** A recognized (non-empty) segment transcript. */\n onTranscript: (text: string) => void;\n /** A segment produced no speech. */\n onEmpty?: () => void;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: (message: string) => void;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: (state: VoiceCaptureState) => void;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n let available = false;\n let listening = false;\n let transcribing = false;\n\n function emit(): void {\n callbacks.onState?.({ available, listening, transcribing });\n }\n function setAvailable(value: boolean): void {\n if (available !== value) {\n available = value;\n emit();\n }\n }\n function setListening(value: boolean): void {\n if (listening !== value) {\n listening = value;\n emit();\n }\n }\n\n let stream: MediaStream | null = null;\n let audioCtx: AudioContext | null = null;\n let analyser: AnalyserNode | null = null;\n let vadBuffer = new Float32Array(0);\n let monitorHandle: number | null = null;\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let mimeType = \"\";\n let segmentHasSpeech = false;\n let silenceStart: number | null = null;\n let segmentStart = 0;\n let pending = 0;\n let queue: Promise<void> = Promise.resolve();\n let availabilityPollHandle: number | null = null;\n // Bumped on stop(). Segments captured / sends resolved under an older\n // generation are dropped, so a late transcript never leaks across sessions.\n let generation = 0;\n let segmentGeneration = 0;\n // Single-flight guard for start(): true between entry and the moment capture\n // is set up (or the attempt aborts), so a second start can't race the first.\n let startInFlight = false;\n\n function setPending(delta: number): void {\n pending += delta;\n const next = pending > 0;\n if (transcribing !== next) {\n transcribing = next;\n emit();\n }\n }\n\n function stopAvailabilityPoll(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refreshAvailability(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stopAvailabilityPoll();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refreshAvailability();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stopAvailabilityPoll();\n }\n }\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== generation) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== generation) return;\n const text = result.text.trim();\n if (text.length === 0) callbacks.onEmpty?.();\n else callbacks.onTranscript(text);\n } catch (err) {\n // Generation-guard the failure path too: a send rejected after stop()/\n // session change belongs to a session the user already left.\n if (gen === generation) callbacks.onError?.(toMessage(err));\n }\n }\n\n // Serialize sends so transcripts append in capture order; `pending` keeps\n // `transcribing` true from enqueue until the send resolves.\n function enqueue(blob: Blob, gen: number): void {\n setPending(1);\n queue = queue\n .then(() => sendSegment(blob, gen))\n .catch(() => undefined)\n .finally(() => setPending(-1));\n }\n\n function containerType(): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n }\n\n function onSegmentStop(): void {\n const hadSpeech = segmentHasSpeech;\n const gen = segmentGeneration;\n const blob = new Blob(chunks, { type: containerType() });\n if (listening) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === generation) enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n if (!stream) return;\n chunks = [];\n segmentHasSpeech = false;\n silenceStart = null;\n segmentStart = Date.now();\n segmentGeneration = generation;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onSegmentStop;\n recorder.start();\n }\n\n function cutSegment(): void {\n if (recorder && recorder.state === \"recording\") recorder.stop();\n }\n\n function monitorTick(): void {\n if (!analyser) return;\n analyser.getFloatTimeDomainData(vadBuffer);\n const rms = computeRms(vadBuffer);\n const now = Date.now();\n if (rms > SPEECH_RMS) {\n segmentHasSpeech = true;\n silenceStart = null;\n } else if (segmentHasSpeech) {\n if (silenceStart === null) silenceStart = now;\n else if (now - silenceStart > SILENCE_MS) cutSegment();\n }\n if (segmentHasSpeech && now - segmentStart > MAX_SEGMENT_MS) cutSegment();\n }\n\n async function start(): Promise<boolean> {\n // Single-flight: `listening` only flips true AFTER getUserMedia resolves, so\n // a benign skip returns true (a start is already active / in progress).\n if (startInFlight || listening) return true;\n startInFlight = true;\n const startGen = generation;\n try {\n mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return false;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return false;\n }\n // stop() bumps the generation; if it fired while we awaited permission\n // this start is stale — release the mic and abort.\n if (startGen !== generation) {\n acquired.getTracks().forEach((track) => track.stop());\n return false;\n }\n stream = acquired;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(stream).connect(analyser);\n setListening(true);\n startRecorder();\n monitorHandle = window.setInterval(monitorTick, MONITOR_INTERVAL_MS);\n return true;\n } finally {\n startInFlight = false;\n }\n }\n\n function stop(): void {\n // Bump the generation so any in-flight/queued segment is dropped rather than\n // applied after the user stops or switches sessions.\n generation += 1;\n setListening(false);\n if (monitorHandle !== null) {\n window.clearInterval(monitorHandle);\n monitorHandle = null;\n }\n if (recorder && recorder.state === \"recording\") recorder.stop();\n recorder = null;\n if (audioCtx) {\n audioCtx.close().catch(() => undefined);\n audioCtx = null;\n }\n analyser = null;\n stream?.getTracks().forEach((track) => track.stop());\n stream = null;\n }\n\n function dispose(): void {\n stopAvailabilityPoll();\n stop();\n }\n\n return { refreshAvailability, start, stop, dispose };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.cjs","names":[],"sources":["../../src/whisper/client.ts"],"sourcesContent":["// @mulmoclaude/core/whisper/client — framework-neutral browser capture controller.\n// Records one utterance at a time with MediaRecorder, segments on pauses via a\n// Web Audio VAD, and sends each segment through an injected transport. State is\n// pushed via `onState`; the host wraps this into its own reactivity (Vue refs,\n// React state, …). No framework dependency. See plans/feat-extract-whisper-package.md.\n\n// Map a UI locale to a Whisper language code. UI language is a strong prior for\n// the spoken language; \"auto\" lets Whisper detect it from the audio.\nconst LOCALE_TO_WHISPER: Record<string, string> = {\n en: \"en\",\n ja: \"ja\",\n zh: \"zh\",\n ko: \"ko\",\n es: \"es\",\n \"pt-BR\": \"pt\",\n fr: \"fr\",\n de: \"de\",\n};\n\nexport function localeToWhisperLanguage(locale: string): string {\n return LOCALE_TO_WHISPER[locale] ?? \"auto\";\n}\n\n// VAD tuning. RMS over [-1,1] float samples; a pause is SILENCE_MS of\n// sub-threshold level after speech. MAX_SEGMENT_MS force-cuts a long unbroken\n// utterance so no clip exceeds Whisper's 30s window or the server's size cap.\nconst SPEECH_RMS = 0.015;\nconst SILENCE_MS = 800;\nconst MAX_SEGMENT_MS = 20_000;\nconst MONITOR_INTERVAL_MS = 100;\nconst AVAILABILITY_POLL_MS = 2_000;\n\nfunction pickRecorderMime(): string | undefined {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\"];\n if (typeof MediaRecorder === \"undefined\") return undefined;\n return candidates.find((type) => MediaRecorder.isTypeSupported(type));\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"FileReader failed\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction computeRms(buffer: Float32Array): number {\n let sum = 0;\n for (const sample of buffer) sum += sample * sample;\n return Math.sqrt(sum / buffer.length);\n}\n\nfunction toMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport interface VoiceCaptureTransport {\n /** Transcribe one segment. Throws on failure. */\n transcribe: (dataUrl: string, language: string) => Promise<{ text: string }>;\n /** Current readiness. `downloading` true keeps the controller polling so it\n * flips to ready as soon as a model download finishes. */\n getStatus: () => Promise<{ ready: boolean; downloading: boolean }>;\n}\n\nexport interface VoiceCaptureState {\n available: boolean;\n listening: boolean;\n transcribing: boolean;\n}\n\nexport interface VoiceCaptureCallbacks {\n /** A recognized (non-empty) segment transcript. */\n onTranscript: (text: string) => void;\n /** A segment produced no speech. */\n onEmpty?: () => void;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: (message: string) => void;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: (state: VoiceCaptureState) => void;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n let available = false;\n let listening = false;\n let transcribing = false;\n\n function emit(): void {\n callbacks.onState?.({ available, listening, transcribing });\n }\n function setAvailable(value: boolean): void {\n if (available !== value) {\n available = value;\n emit();\n }\n }\n function setListening(value: boolean): void {\n if (listening !== value) {\n listening = value;\n emit();\n }\n }\n\n let stream: MediaStream | null = null;\n let audioCtx: AudioContext | null = null;\n let analyser: AnalyserNode | null = null;\n let vadBuffer = new Float32Array(0);\n let monitorHandle: number | null = null;\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let mimeType = \"\";\n let segmentHasSpeech = false;\n let silenceStart: number | null = null;\n let segmentStart = 0;\n let pending = 0;\n let queue: Promise<void> = Promise.resolve();\n let availabilityPollHandle: number | null = null;\n // Bumped on stop(). Segments captured / sends resolved under an older\n // generation are dropped, so a late transcript never leaks across sessions.\n let generation = 0;\n let segmentGeneration = 0;\n // Single-flight guard for start(): true between entry and the moment capture\n // is set up (or the attempt aborts), so a second start can't race the first.\n let startInFlight = false;\n\n function setPending(delta: number): void {\n pending += delta;\n const next = pending > 0;\n if (transcribing !== next) {\n transcribing = next;\n emit();\n }\n }\n\n function stopAvailabilityPoll(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refreshAvailability(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stopAvailabilityPoll();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refreshAvailability();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stopAvailabilityPoll();\n }\n }\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== generation) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== generation) return;\n const text = result.text.trim();\n if (text.length === 0) callbacks.onEmpty?.();\n else callbacks.onTranscript(text);\n } catch (err) {\n // Generation-guard the failure path too: a send rejected after stop()/\n // session change belongs to a session the user already left.\n if (gen === generation) callbacks.onError?.(toMessage(err));\n }\n }\n\n // Serialize sends so transcripts append in capture order; `pending` keeps\n // `transcribing` true from enqueue until the send resolves.\n function enqueue(blob: Blob, gen: number): void {\n setPending(1);\n queue = queue\n .then(() => sendSegment(blob, gen))\n .catch(() => undefined)\n .finally(() => setPending(-1));\n }\n\n function containerType(): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n }\n\n function onSegmentStop(): void {\n const hadSpeech = segmentHasSpeech;\n const gen = segmentGeneration;\n const blob = new Blob(chunks, { type: containerType() });\n if (listening) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === generation) enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n if (!stream) return;\n chunks = [];\n segmentHasSpeech = false;\n silenceStart = null;\n segmentStart = Date.now();\n segmentGeneration = generation;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onSegmentStop;\n recorder.start();\n }\n\n function cutSegment(): void {\n if (recorder && recorder.state === \"recording\") recorder.stop();\n }\n\n function monitorTick(): void {\n if (!analyser) return;\n analyser.getFloatTimeDomainData(vadBuffer);\n const rms = computeRms(vadBuffer);\n const now = Date.now();\n if (rms > SPEECH_RMS) {\n segmentHasSpeech = true;\n silenceStart = null;\n } else if (segmentHasSpeech) {\n if (silenceStart === null) silenceStart = now;\n else if (now - silenceStart > SILENCE_MS) cutSegment();\n }\n if (segmentHasSpeech && now - segmentStart > MAX_SEGMENT_MS) cutSegment();\n }\n\n async function start(): Promise<boolean> {\n // Single-flight: `listening` only flips true AFTER getUserMedia resolves, so\n // a benign skip returns true (a start is already active / in progress).\n if (startInFlight || listening) return true;\n startInFlight = true;\n const startGen = generation;\n try {\n mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return false;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return false;\n }\n // stop() bumps the generation; if it fired while we awaited permission\n // this start is stale — release the mic and abort.\n if (startGen !== generation) {\n acquired.getTracks().forEach((track) => track.stop());\n return false;\n }\n stream = acquired;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(stream).connect(analyser);\n setListening(true);\n startRecorder();\n monitorHandle = window.setInterval(monitorTick, MONITOR_INTERVAL_MS);\n return true;\n } finally {\n startInFlight = false;\n }\n }\n\n function stop(): void {\n // Bump the generation so any in-flight/queued segment is dropped rather than\n // applied after the user stops or switches sessions.\n generation += 1;\n setListening(false);\n if (monitorHandle !== null) {\n window.clearInterval(monitorHandle);\n monitorHandle = null;\n }\n if (recorder && recorder.state === \"recording\") recorder.stop();\n recorder = null;\n if (audioCtx) {\n audioCtx.close().catch(() => undefined);\n audioCtx = null;\n }\n analyser = null;\n stream?.getTracks().forEach((track) => track.stop());\n stream = null;\n }\n\n function dispose(): void {\n stopAvailabilityPoll();\n stop();\n }\n\n return { refreshAvailability, start, stop, dispose };\n}\n"],"mappings":"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"}
|
package/dist/whisper/client.js
CHANGED
|
@@ -68,7 +68,7 @@ function createVoiceCapture(transport, language, callbacks) {
|
|
|
68
68
|
let stream = null;
|
|
69
69
|
let audioCtx = null;
|
|
70
70
|
let analyser = null;
|
|
71
|
-
let vadBuffer = new Float32Array(0);
|
|
71
|
+
let vadBuffer = /* @__PURE__ */ new Float32Array(0);
|
|
72
72
|
let monitorHandle = null;
|
|
73
73
|
let recorder = null;
|
|
74
74
|
let chunks = [];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":[],"sources":["../../src/whisper/client.ts"],"sourcesContent":["// @mulmoclaude/core/whisper/client — framework-neutral browser capture controller.\n// Records one utterance at a time with MediaRecorder, segments on pauses via a\n// Web Audio VAD, and sends each segment through an injected transport. State is\n// pushed via `onState`; the host wraps this into its own reactivity (Vue refs,\n// React state, …). No framework dependency. See plans/feat-extract-whisper-package.md.\n\n// Map a UI locale to a Whisper language code. UI language is a strong prior for\n// the spoken language; \"auto\" lets Whisper detect it from the audio.\nconst LOCALE_TO_WHISPER: Record<string, string> = {\n en: \"en\",\n ja: \"ja\",\n zh: \"zh\",\n ko: \"ko\",\n es: \"es\",\n \"pt-BR\": \"pt\",\n fr: \"fr\",\n de: \"de\",\n};\n\nexport function localeToWhisperLanguage(locale: string): string {\n return LOCALE_TO_WHISPER[locale] ?? \"auto\";\n}\n\n// VAD tuning. RMS over [-1,1] float samples; a pause is SILENCE_MS of\n// sub-threshold level after speech. MAX_SEGMENT_MS force-cuts a long unbroken\n// utterance so no clip exceeds Whisper's 30s window or the server's size cap.\nconst SPEECH_RMS = 0.015;\nconst SILENCE_MS = 800;\nconst MAX_SEGMENT_MS = 20_000;\nconst MONITOR_INTERVAL_MS = 100;\nconst AVAILABILITY_POLL_MS = 2_000;\n\nfunction pickRecorderMime(): string | undefined {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\"];\n if (typeof MediaRecorder === \"undefined\") return undefined;\n return candidates.find((type) => MediaRecorder.isTypeSupported(type));\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"FileReader failed\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction computeRms(buffer: Float32Array): number {\n let sum = 0;\n for (const sample of buffer) sum += sample * sample;\n return Math.sqrt(sum / buffer.length);\n}\n\nfunction toMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport interface VoiceCaptureTransport {\n /** Transcribe one segment. Throws on failure. */\n transcribe: (dataUrl: string, language: string) => Promise<{ text: string }>;\n /** Current readiness. `downloading` true keeps the controller polling so it\n * flips to ready as soon as a model download finishes. */\n getStatus: () => Promise<{ ready: boolean; downloading: boolean }>;\n}\n\nexport interface VoiceCaptureState {\n available: boolean;\n listening: boolean;\n transcribing: boolean;\n}\n\nexport interface VoiceCaptureCallbacks {\n /** A recognized (non-empty) segment transcript. */\n onTranscript: (text: string) => void;\n /** A segment produced no speech. */\n onEmpty?: () => void;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: (message: string) => void;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: (state: VoiceCaptureState) => void;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n let available = false;\n let listening = false;\n let transcribing = false;\n\n function emit(): void {\n callbacks.onState?.({ available, listening, transcribing });\n }\n function setAvailable(value: boolean): void {\n if (available !== value) {\n available = value;\n emit();\n }\n }\n function setListening(value: boolean): void {\n if (listening !== value) {\n listening = value;\n emit();\n }\n }\n\n let stream: MediaStream | null = null;\n let audioCtx: AudioContext | null = null;\n let analyser: AnalyserNode | null = null;\n let vadBuffer = new Float32Array(0);\n let monitorHandle: number | null = null;\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let mimeType = \"\";\n let segmentHasSpeech = false;\n let silenceStart: number | null = null;\n let segmentStart = 0;\n let pending = 0;\n let queue: Promise<void> = Promise.resolve();\n let availabilityPollHandle: number | null = null;\n // Bumped on stop(). Segments captured / sends resolved under an older\n // generation are dropped, so a late transcript never leaks across sessions.\n let generation = 0;\n let segmentGeneration = 0;\n // Single-flight guard for start(): true between entry and the moment capture\n // is set up (or the attempt aborts), so a second start can't race the first.\n let startInFlight = false;\n\n function setPending(delta: number): void {\n pending += delta;\n const next = pending > 0;\n if (transcribing !== next) {\n transcribing = next;\n emit();\n }\n }\n\n function stopAvailabilityPoll(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refreshAvailability(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stopAvailabilityPoll();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refreshAvailability();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stopAvailabilityPoll();\n }\n }\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== generation) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== generation) return;\n const text = result.text.trim();\n if (text.length === 0) callbacks.onEmpty?.();\n else callbacks.onTranscript(text);\n } catch (err) {\n // Generation-guard the failure path too: a send rejected after stop()/\n // session change belongs to a session the user already left.\n if (gen === generation) callbacks.onError?.(toMessage(err));\n }\n }\n\n // Serialize sends so transcripts append in capture order; `pending` keeps\n // `transcribing` true from enqueue until the send resolves.\n function enqueue(blob: Blob, gen: number): void {\n setPending(1);\n queue = queue\n .then(() => sendSegment(blob, gen))\n .catch(() => undefined)\n .finally(() => setPending(-1));\n }\n\n function containerType(): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n }\n\n function onSegmentStop(): void {\n const hadSpeech = segmentHasSpeech;\n const gen = segmentGeneration;\n const blob = new Blob(chunks, { type: containerType() });\n if (listening) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === generation) enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n if (!stream) return;\n chunks = [];\n segmentHasSpeech = false;\n silenceStart = null;\n segmentStart = Date.now();\n segmentGeneration = generation;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onSegmentStop;\n recorder.start();\n }\n\n function cutSegment(): void {\n if (recorder && recorder.state === \"recording\") recorder.stop();\n }\n\n function monitorTick(): void {\n if (!analyser) return;\n analyser.getFloatTimeDomainData(vadBuffer);\n const rms = computeRms(vadBuffer);\n const now = Date.now();\n if (rms > SPEECH_RMS) {\n segmentHasSpeech = true;\n silenceStart = null;\n } else if (segmentHasSpeech) {\n if (silenceStart === null) silenceStart = now;\n else if (now - silenceStart > SILENCE_MS) cutSegment();\n }\n if (segmentHasSpeech && now - segmentStart > MAX_SEGMENT_MS) cutSegment();\n }\n\n async function start(): Promise<boolean> {\n // Single-flight: `listening` only flips true AFTER getUserMedia resolves, so\n // a benign skip returns true (a start is already active / in progress).\n if (startInFlight || listening) return true;\n startInFlight = true;\n const startGen = generation;\n try {\n mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return false;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return false;\n }\n // stop() bumps the generation; if it fired while we awaited permission\n // this start is stale — release the mic and abort.\n if (startGen !== generation) {\n acquired.getTracks().forEach((track) => track.stop());\n return false;\n }\n stream = acquired;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(stream).connect(analyser);\n setListening(true);\n startRecorder();\n monitorHandle = window.setInterval(monitorTick, MONITOR_INTERVAL_MS);\n return true;\n } finally {\n startInFlight = false;\n }\n }\n\n function stop(): void {\n // Bump the generation so any in-flight/queued segment is dropped rather than\n // applied after the user stops or switches sessions.\n generation += 1;\n setListening(false);\n if (monitorHandle !== null) {\n window.clearInterval(monitorHandle);\n monitorHandle = null;\n }\n if (recorder && recorder.state === \"recording\") recorder.stop();\n recorder = null;\n if (audioCtx) {\n audioCtx.close().catch(() => undefined);\n audioCtx = null;\n }\n analyser = null;\n stream?.getTracks().forEach((track) => track.stop());\n stream = null;\n }\n\n function dispose(): void {\n stopAvailabilityPoll();\n stop();\n }\n\n return { refreshAvailability, start, stop, dispose };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.js","names":[],"sources":["../../src/whisper/client.ts"],"sourcesContent":["// @mulmoclaude/core/whisper/client — framework-neutral browser capture controller.\n// Records one utterance at a time with MediaRecorder, segments on pauses via a\n// Web Audio VAD, and sends each segment through an injected transport. State is\n// pushed via `onState`; the host wraps this into its own reactivity (Vue refs,\n// React state, …). No framework dependency. See plans/feat-extract-whisper-package.md.\n\n// Map a UI locale to a Whisper language code. UI language is a strong prior for\n// the spoken language; \"auto\" lets Whisper detect it from the audio.\nconst LOCALE_TO_WHISPER: Record<string, string> = {\n en: \"en\",\n ja: \"ja\",\n zh: \"zh\",\n ko: \"ko\",\n es: \"es\",\n \"pt-BR\": \"pt\",\n fr: \"fr\",\n de: \"de\",\n};\n\nexport function localeToWhisperLanguage(locale: string): string {\n return LOCALE_TO_WHISPER[locale] ?? \"auto\";\n}\n\n// VAD tuning. RMS over [-1,1] float samples; a pause is SILENCE_MS of\n// sub-threshold level after speech. MAX_SEGMENT_MS force-cuts a long unbroken\n// utterance so no clip exceeds Whisper's 30s window or the server's size cap.\nconst SPEECH_RMS = 0.015;\nconst SILENCE_MS = 800;\nconst MAX_SEGMENT_MS = 20_000;\nconst MONITOR_INTERVAL_MS = 100;\nconst AVAILABILITY_POLL_MS = 2_000;\n\nfunction pickRecorderMime(): string | undefined {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\"];\n if (typeof MediaRecorder === \"undefined\") return undefined;\n return candidates.find((type) => MediaRecorder.isTypeSupported(type));\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"FileReader failed\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction computeRms(buffer: Float32Array): number {\n let sum = 0;\n for (const sample of buffer) sum += sample * sample;\n return Math.sqrt(sum / buffer.length);\n}\n\nfunction toMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport interface VoiceCaptureTransport {\n /** Transcribe one segment. Throws on failure. */\n transcribe: (dataUrl: string, language: string) => Promise<{ text: string }>;\n /** Current readiness. `downloading` true keeps the controller polling so it\n * flips to ready as soon as a model download finishes. */\n getStatus: () => Promise<{ ready: boolean; downloading: boolean }>;\n}\n\nexport interface VoiceCaptureState {\n available: boolean;\n listening: boolean;\n transcribing: boolean;\n}\n\nexport interface VoiceCaptureCallbacks {\n /** A recognized (non-empty) segment transcript. */\n onTranscript: (text: string) => void;\n /** A segment produced no speech. */\n onEmpty?: () => void;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: (message: string) => void;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: (state: VoiceCaptureState) => void;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n let available = false;\n let listening = false;\n let transcribing = false;\n\n function emit(): void {\n callbacks.onState?.({ available, listening, transcribing });\n }\n function setAvailable(value: boolean): void {\n if (available !== value) {\n available = value;\n emit();\n }\n }\n function setListening(value: boolean): void {\n if (listening !== value) {\n listening = value;\n emit();\n }\n }\n\n let stream: MediaStream | null = null;\n let audioCtx: AudioContext | null = null;\n let analyser: AnalyserNode | null = null;\n let vadBuffer = new Float32Array(0);\n let monitorHandle: number | null = null;\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let mimeType = \"\";\n let segmentHasSpeech = false;\n let silenceStart: number | null = null;\n let segmentStart = 0;\n let pending = 0;\n let queue: Promise<void> = Promise.resolve();\n let availabilityPollHandle: number | null = null;\n // Bumped on stop(). Segments captured / sends resolved under an older\n // generation are dropped, so a late transcript never leaks across sessions.\n let generation = 0;\n let segmentGeneration = 0;\n // Single-flight guard for start(): true between entry and the moment capture\n // is set up (or the attempt aborts), so a second start can't race the first.\n let startInFlight = false;\n\n function setPending(delta: number): void {\n pending += delta;\n const next = pending > 0;\n if (transcribing !== next) {\n transcribing = next;\n emit();\n }\n }\n\n function stopAvailabilityPoll(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refreshAvailability(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stopAvailabilityPoll();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refreshAvailability();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stopAvailabilityPoll();\n }\n }\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== generation) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== generation) return;\n const text = result.text.trim();\n if (text.length === 0) callbacks.onEmpty?.();\n else callbacks.onTranscript(text);\n } catch (err) {\n // Generation-guard the failure path too: a send rejected after stop()/\n // session change belongs to a session the user already left.\n if (gen === generation) callbacks.onError?.(toMessage(err));\n }\n }\n\n // Serialize sends so transcripts append in capture order; `pending` keeps\n // `transcribing` true from enqueue until the send resolves.\n function enqueue(blob: Blob, gen: number): void {\n setPending(1);\n queue = queue\n .then(() => sendSegment(blob, gen))\n .catch(() => undefined)\n .finally(() => setPending(-1));\n }\n\n function containerType(): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n }\n\n function onSegmentStop(): void {\n const hadSpeech = segmentHasSpeech;\n const gen = segmentGeneration;\n const blob = new Blob(chunks, { type: containerType() });\n if (listening) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === generation) enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n if (!stream) return;\n chunks = [];\n segmentHasSpeech = false;\n silenceStart = null;\n segmentStart = Date.now();\n segmentGeneration = generation;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onSegmentStop;\n recorder.start();\n }\n\n function cutSegment(): void {\n if (recorder && recorder.state === \"recording\") recorder.stop();\n }\n\n function monitorTick(): void {\n if (!analyser) return;\n analyser.getFloatTimeDomainData(vadBuffer);\n const rms = computeRms(vadBuffer);\n const now = Date.now();\n if (rms > SPEECH_RMS) {\n segmentHasSpeech = true;\n silenceStart = null;\n } else if (segmentHasSpeech) {\n if (silenceStart === null) silenceStart = now;\n else if (now - silenceStart > SILENCE_MS) cutSegment();\n }\n if (segmentHasSpeech && now - segmentStart > MAX_SEGMENT_MS) cutSegment();\n }\n\n async function start(): Promise<boolean> {\n // Single-flight: `listening` only flips true AFTER getUserMedia resolves, so\n // a benign skip returns true (a start is already active / in progress).\n if (startInFlight || listening) return true;\n startInFlight = true;\n const startGen = generation;\n try {\n mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return false;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return false;\n }\n // stop() bumps the generation; if it fired while we awaited permission\n // this start is stale — release the mic and abort.\n if (startGen !== generation) {\n acquired.getTracks().forEach((track) => track.stop());\n return false;\n }\n stream = acquired;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(stream).connect(analyser);\n setListening(true);\n startRecorder();\n monitorHandle = window.setInterval(monitorTick, MONITOR_INTERVAL_MS);\n return true;\n } finally {\n startInFlight = false;\n }\n }\n\n function stop(): void {\n // Bump the generation so any in-flight/queued segment is dropped rather than\n // applied after the user stops or switches sessions.\n generation += 1;\n setListening(false);\n if (monitorHandle !== null) {\n window.clearInterval(monitorHandle);\n monitorHandle = null;\n }\n if (recorder && recorder.state === \"recording\") recorder.stop();\n recorder = null;\n if (audioCtx) {\n audioCtx.close().catch(() => undefined);\n audioCtx = null;\n }\n analyser = null;\n stream?.getTracks().forEach((track) => track.stop());\n stream = null;\n }\n\n function dispose(): void {\n stopAvailabilityPoll();\n stop();\n }\n\n return { refreshAvailability, start, stop, dispose };\n}\n"],"mappings":"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"}
|
package/dist/whisper/index.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const
|
|
2
|
+
const require_rolldown_runtime = require("../rolldown-runtime-D6vf50IK.cjs");
|
|
3
3
|
let node_path = require("node:path");
|
|
4
|
-
node_path =
|
|
4
|
+
node_path = require_rolldown_runtime.__toESM(node_path, 1);
|
|
5
5
|
let node_fs = require("node:fs");
|
|
6
6
|
let node_fs_promises = require("node:fs/promises");
|
|
7
7
|
let node_crypto = require("node:crypto");
|
|
@@ -382,7 +382,7 @@ function createSidecar(modelsDir, serverBinary = "whisper-server", logger = NOOP
|
|
|
382
382
|
}
|
|
383
383
|
//#endregion
|
|
384
384
|
//#region src/whisper/whisper.ts
|
|
385
|
-
var BLANK_MARKERS = new Set([
|
|
385
|
+
var BLANK_MARKERS = /* @__PURE__ */ new Set([
|
|
386
386
|
"[blank_audio]",
|
|
387
387
|
"[silence]",
|
|
388
388
|
"(silence)",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":[],"sources":["../../src/whisper/internal.ts","../../src/whisper/ffmpeg.ts","../../src/whisper/models.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// 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 const live = downloadStatus.get(name);\n if (live?.state === \"downloading\") return live;\n if (isModelReady(modelsDir, name)) return { state: \"ready\" };\n return live ?? { state: \"idle\" };\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 = Number(response.headers.get(\"content-length\")) || 0;\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","// 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\";\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;\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 = (tail.text + chunk).slice(-4000);\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\nfunction 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\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 = [\"--model\", modelFilePath(modelsDir, model), \"--host\", HOST, \"--port\", String(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":";;;;;;;;;;;;;AAEA,IAAa,gBAAgB;AAC7B,IAAa,gBAAgB;AAE7B,SAAgB,aAAa,KAAsB;CACjD,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AACxD;AAUA,IAAM,aAAmB,KAAA;AACzB,IAAa,cAA6B;CAAE,MAAM;CAAM,MAAM;CAAM,OAAO;AAAK;;;ACVhF,IAAM,iBAAA,GAAA,UAAA,WAA0B,mBAAA,QAAQ;;;AAIxC,SAAgB,gBAAgB,WAAmB,YAA8B;CAC/E,OAAO;EAAC;EAAM;EAAa;EAAS;EAAM;EAAW;EAAO;EAAS;EAAO;EAAK;EAAQ;EAAa;CAAU;AAClH;;;AAIA,eAAsB,gBAAgB,WAAmB,YAAoB,eAAe,UAAyB;CACnH,MAAM,cAAc,cAAc,gBAAgB,WAAW,UAAU,GAAG,EACxE,SAAS,cACX,CAAC;AACH;;;ACHA,IAAM,UAAU;AAIhB,IAAa,iBAAiB;CAC5B,kBAAkB;EAAE,MAAM;EAA2B,KAAK,GAAG,QAAQ;EAA2B,UAAU;CAAc;CACxH,OAAO;EAAE,MAAM;EAAkB,KAAK,GAAG,QAAQ;EAAkB,UAAU;CAAY;CACzF,MAAM;EAAE,MAAM;EAAiB,KAAK,GAAG,QAAQ;EAAiB,UAAU;CAAY;AACxF;AAGA,IAAa,wBAA0C;AAEvD,SAAgB,mBAAmB,OAA2C;CAI5E,OAAO,OAAO,UAAU,YAAY,OAAO,UAAU,eAAe,KAAK,gBAAgB,KAAK;AAChG;;AAGA,SAAgB,iBAAiB,MAA4C;CAC3E,OAAO,mBAAmB,IAAI,IAAI,OAAO;AAC3C;AAEA,SAAgB,cAAc,WAAmB,MAAgC;CAC/E,OAAO,UAAA,QAAK,KAAK,WAAW,eAAe,MAAM,IAAI;AACvD;;AAGA,SAAgB,aAAa,WAAmB,MAAiC;CAC/E,IAAI;EACF,QAAA,GAAA,QAAA,UAAgB,cAAc,WAAW,IAAI,CAAC,EAAE,QAAQ,eAAe,MAAM;CAC/E,QAAQ;EACN,OAAO;CACT;AACF;AAaA,IAAM,4BAA4B;AAOlC,SAAgB,sBAAsB,WAAmB,SAAwB,aAA8B;CAG7G,MAAM,iCAAiB,IAAI,IAAmC;CAE9D,SAAS,UAAU,MAAqC;EACtD,MAAM,OAAO,eAAe,IAAI,IAAI;EACpC,IAAI,MAAM,UAAU,eAAe,OAAO;EAC1C,IAAI,aAAa,WAAW,IAAI,GAAG,OAAO,EAAE,OAAO,QAAQ;EAC3D,OAAO,QAAQ,EAAE,OAAO,OAAO;CACjC;CAEA,eAAe,aACb,MACA,aACA,OACA,MACA,YACe;EACf,MAAM,SAAS,KAAK,UAAU;EAC9B,MAAM,cAAA,GAAA,QAAA,mBAA+B,WAAW;EAChD,IAAI,WAAW;EACf,IAAI;GACF,SAAS;IACP,MAAM,EAAE,MAAM,UAAU,MAAM,OAAO,KAAK;IAC1C,IAAI,MAAM;IACV,WAAW;IACX,YAAY,MAAM;IAClB,IAAI,CAAC,WAAW,MAAM,KAAK,GAAG,OAAA,GAAA,YAAA,MAAW,YAAY,OAAO;IAC5D,IAAI,QAAQ,GAAG,eAAe,IAAI,MAAM;KAAE,OAAO;KAAe,UAAU,WAAW;IAAM,CAAC;GAC9F;GACA,WAAW,IAAI;GACf,OAAA,GAAA,YAAA,MAAW,YAAY,QAAQ;EACjC,SAAS,KAAK;GACZ,WAAW,QAAQ;GACnB,MAAM;EACR;CACF;CAEA,eAAe,SAAS,MAAuC;EAC7D,MAAM,OAAO,eAAe;EAC5B,CAAA,GAAA,QAAA,WAAU,WAAW,EAAE,WAAW,KAAK,CAAC;EACxC,MAAM,OAAO,cAAc,WAAW,IAAI;EAC1C,MAAM,UAAU,GAAG,KAAK;EACxB,MAAM,aAAa,IAAI,gBAAgB;EACvC,IAAI,aAAmD;EACvD,MAAM,mBAAmB;GACvB,IAAI,YAAY,aAAa,UAAU;GACvC,aAAa,iBAAiB,WAAW,MAAM,GAAG,yBAAyB;EAC7E;EACA,IAAI;GACF,MAAM,WAAW,MAAM,MAAM,KAAK,KAAK,EAAE,QAAQ,WAAW,OAAO,CAAC;GACpE,IAAI,CAAC,SAAS,MAAM,CAAC,SAAS,MAC5B,MAAM,IAAI,MAAM,yBAAyB,SAAS,QAAQ;GAE5D,MAAM,QAAQ,OAAO,SAAS,QAAQ,IAAI,gBAAgB,CAAC,KAAK;GAChE,WAAW;GACX,MAAM,aAAa,SAAS,MAAM,SAAS,OAAO,MAAM,UAAU;GAClE,KAAA,GAAA,QAAA,UAAa,OAAO,EAAE,OAAO,KAAK,UAAU;IAC1C,CAAA,GAAA,QAAA,YAAW,OAAO;IAClB,MAAM,IAAI,MAAM,6DAA6D;GAC/E;GACA,CAAA,GAAA,QAAA,YAAW,SAAS,IAAI;EAC1B,UAAU;GACR,IAAI,YAAY,aAAa,UAAU;EACzC;CACF;CAIA,eAAe,OAAO,MAAuC;EAC3D,IAAI,aAAa,WAAW,IAAI,GAAG;GACjC,eAAe,IAAI,MAAM,EAAE,OAAO,QAAQ,CAAC;GAC3C;EACF;EACA,IAAI,eAAe,IAAI,IAAI,GAAG,UAAU,eAAe;EACvD,eAAe,IAAI,MAAM;GAAE,OAAO;GAAe,UAAU;EAAE,CAAC;EAC9D,OAAO,KAAK,yBAAyB,EAAE,OAAO,KAAK,CAAC;EACpD,IAAI;GACF,MAAM,SAAS,IAAI;GACnB,eAAe,IAAI,MAAM,EAAE,OAAO,QAAQ,CAAC;GAC3C,OAAO,KAAK,sBAAsB,EAAE,OAAO,KAAK,CAAC;EACnD,SAAS,KAAK;GACZ,MAAM,QAAQ,aAAa,GAAG;GAC9B,eAAe,IAAI,MAAM;IAAE,OAAO;IAAS;GAAM,CAAC;GAClD,OAAO,MAAM,0BAA0B;IAAE,OAAO;IAAM;GAAM,CAAC;EAC/D;CACF;CAEA,OAAO;EAAE;EAAW;CAAO;AAC7B;;;ACzJA,IAAM,OAAO;AACb,IAAM,mBAAmB,KAAK;AAC9B,IAAM,yBAAyB;AAC/B,IAAM,uBAAuB,IAAI;AAcjC,eAAe,eAAgC;CAC7C,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,OAAA,GAAA,SAAA,cAAmB;EACzB,IAAI,GAAG,SAAS,MAAM;EACtB,IAAI,OAAO,GAAG,YAAY;GACxB,MAAM,OAAO,IAAI,QAAQ;GACzB,IAAI,QAAQ,OAAO,SAAS,UAAU;IACpC,MAAM,EAAE,SAAS;IACjB,IAAI,YAAY,QAAQ,IAAI,CAAC;GAC/B,OACE,IAAI,YAAY,uBAAO,IAAI,MAAM,iCAAiC,CAAC,CAAC;EAExE,CAAC;CACH,CAAC;AACH;;;AAIA,eAAe,eAAe,MAA6B;CACzD,MAAM,WAAW,KAAK,IAAI,IAAI;CAC9B,OAAO,KAAK,IAAI,IAAI,UAClB,IAAI;EACF,MAAM,MAAM,UAAU,KAAK,GAAG,KAAK,IAAI,EAAE,QAAQ,YAAY,QAAQ,aAAa,EAAE,CAAC;EACrF;CACF,QAAQ;EACN,OAAA,GAAA,qBAAA,YAAY,sBAAsB;CACpC;CAEF,MAAM,IAAI,MAAM,6CAA6C;AAC/D;AAIA,SAAS,YAAY,MAAoB,MAA8B;CACrE,KAAK,QAAQ,YAAY,MAAM;CAC/B,KAAK,QAAQ,GAAG,SAAS,UAAkB;EACzC,KAAK,QAAQ,KAAK,OAAO,OAAO,MAAM,IAAK;CAC7C,CAAC;AACH;AAIA,SAAS,sBAAsB,MAAoB,MAA6B;CAC9E,OAAO,IAAI,SAAe,SAAS,WAAW;EAC5C,IAAI,gBAAsC,KAAA;EAC1C,IAAI,eAA8C,KAAA;EAClD,MAAM,gBAAgB;GACpB,KAAK,eAAe,SAAS,OAAO;GACpC,KAAK,eAAe,QAAQ,MAAM;EACpC;EACA,WAAW,QAAe;GACxB,QAAQ;GACR,uBAAO,IAAI,MAAM,iBAAiB,aAAa,GAAG,GAAG,CAAC;EACxD;EACA,UAAU,SAAwB;GAChC,QAAQ;GACR,uBAAO,IAAI,MAAM,sBAAsB,KAAK,EAAE,CAAC;EACjD;EACA,KAAK,KAAK,SAAS,OAAO;EAC1B,KAAK,KAAK,QAAQ,MAAM;EACxB,eAAe,IAAI,EAChB,WAAW;GACV,QAAQ;GACR,QAAQ;EACV,CAAC,EACA,OAAO,QAAiB;GACvB,QAAQ;GACR,OAAO,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC,CAAC;EAC5D,CAAC;CACL,CAAC;AACH;AAEA,SAAS,mBAAmB,MAAuB;CACjD,IAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,UAAU,MAAM;EAC/D,MAAM,EAAE,SAAS;EACjB,IAAI,OAAO,SAAS,UAAU,OAAO;CACvC;CACA,OAAO;AACT;AAEA,SAAgB,cAAc,WAAmB,eAAe,kBAAkB,SAAwB,aAAsB;CAC9H,IAAI,UAAgC;CACpC,IAAI,WAAgF;CAIpF,IAAI,eAAoC;CACxC,IAAI,aAAa;CAEjB,SAAS,WAAiB;EACxB,cAAc;EACd,IAAI,cAAc;GAChB,aAAa,KAAK;GAClB,eAAe;EACjB;EACA,IAAI,SAAS;GACX,QAAQ,KAAK,KAAK;GAClB,UAAU;EACZ;CACF;CAEA,eAAe,aAAa,OAAiD;EAC3E,MAAM,QAAQ,EAAE;EAChB,MAAM,OAAO,MAAM,aAAa;EAChC,MAAM,OAAO;GAAC;GAAW,cAAc,WAAW,KAAK;GAAG;GAAU;GAAM;GAAU,OAAO,IAAI;EAAC;EAChG,OAAO,KAAK,qBAAqB;GAAE;GAAO;EAAK,CAAC;EAChD,MAAM,QAAA,GAAA,mBAAA,OAAa,cAAc,MAAM,EAAE,OAAO;GAAC;GAAU;GAAU;EAAM,EAAE,CAAC;EAC9E,eAAe;EACf,MAAM,aAAa,EAAE,MAAM,GAAG;EAC9B,YAAY,MAAM,UAAU;EAG5B,KAAK,GAAG,UAAU,QAAQ,OAAO,KAAK,0BAA0B;GAAE;GAAO,OAAO,aAAa,GAAG;EAAE,CAAC,CAAC;EACpG,KAAK,GAAG,SAAS,SAAS;GACxB,OAAO,KAAK,mBAAmB;IAAE;IAAO;IAAM,YAAY,WAAW,KAAK,MAAM,IAAI;GAAE,CAAC;GACvF,IAAI,SAAS,SAAS,MAAM,UAAU;EACxC,CAAC;EACD,IAAI;GACF,MAAM,sBAAsB,MAAM,IAAI;EACxC,SAAS,KAAK;GACZ,KAAK,KAAK;GACV,MAAM,IAAI,MAAM,mCAAmC,aAAa,GAAG,EAAE,aAAa,WAAW,KAAK,MAAM,IAAI,GAAG;EACjH,UAAU;GACR,IAAI,iBAAiB,MAAM,eAAe;EAC5C;EAIA,IAAI,UAAU,YAAY;GACxB,KAAK,KAAK;GACV,MAAM,IAAI,MAAM,gCAAgC;EAClD;EACA,UAAU;GAAE;GAAM;GAAM;EAAM;EAC9B,OAAO,KAAK,kBAAkB;GAAE;GAAO;EAAK,CAAC;EAC7C,OAAO;CACT;CAIA,SAAS,WAAW,OAAiD;EACnE,MAAM,UAAU,aAAa,KAAK,EAAE,cAAc;GAChD,WAAW;EACb,CAAC;EACD,WAAW;GAAE;GAAO;EAAQ;EAC5B,OAAO;CACT;CAEA,eAAe,cAAc,OAAiD;EAI5E,SAAS;GACP,IAAI,WAAW,QAAQ,UAAU,SAAS,CAAC,QAAQ,KAAK,QAAQ,OAAO;GACvE,IAAI,YAAY,SAAS,UAAU,OAAO,OAAO,SAAS;GAC1D,IAAI,UAAU;IACZ,MAAM,SAAS,QAAQ,YAAY,KAAA,CAAS;IAC5C;GACF;GACA,IAAI,WAAW,QAAQ,UAAU,OAAO,SAAS;GACjD,OAAO,WAAW,KAAK;EACzB;CACF;CAEA,eAAe,OAAO,OAAwC;EAC5D,IAAI;GACF,MAAM,cAAc,KAAK;EAC3B,SAAS,KAAK;GACZ,OAAO,KAAK,0BAA0B;IAAE;IAAO,OAAO,aAAa,GAAG;GAAE,CAAC;EAC3E;CACF;CAEA,eAAe,cAAc,SAAiB,UAAkB,OAA0C;EACxG,MAAM,SAAS,MAAM,cAAc,KAAK;EACxC,MAAM,MAAM,OAAA,GAAA,iBAAA,UAAe,OAAO;EAClC,MAAM,OAAO,IAAI,SAAS;EAC1B,KAAK,OAAO,QAAQ,IAAI,KAAK,CAAC,GAAG,GAAG,EAAE,MAAM,YAAY,CAAC,GAAG,WAAW;EACvE,KAAK,OAAO,mBAAmB,MAAM;EACrC,KAAK,OAAO,YAAY,YAAY,MAAM;EAC1C,IAAI;EACJ,IAAI;GACF,MAAM,MAAM,MAAM,UAAU,KAAK,GAAG,OAAO,KAAK,aAAa;IAAE,QAAQ;IAAQ,MAAM;IAAM,QAAQ,YAAY,QAAQ,oBAAoB;GAAE,CAAC;EAChJ,SAAS,KAAK;GACZ,MAAM,IAAI,MAAM,kCAAkC,aAAa,GAAG,GAAG;EACvE;EACA,IAAI,CAAC,IAAI,IAAI,MAAM,IAAI,MAAM,gCAAgC,IAAI,QAAQ;EACzE,OAAO,mBAAmB,MAAM,IAAI,KAAK,CAAC;CAC5C;CAEA,OAAO;EAAE;EAAe;EAAQ;CAAS;AAC3C;;;AC/KA,IAAM,gBAAgB,IAAI,IAAI;CAAC;CAAiB;CAAa;CAAa;AAAe,CAAC;AAE1F,SAAS,oBAAoB,KAAqB;CAChD,MAAM,UAAU,IAAI,KAAK,EAAE,QAAQ,QAAQ,GAAG;CAC9C,OAAO,cAAc,IAAI,QAAQ,YAAY,CAAC,IAAI,KAAK;AACzD;AAEA,SAAgB,cAAc,MAA+B;CAC3D,MAAM,EAAE,cAAc;CACtB,MAAM,SAAS,KAAK,UAAU;CAC9B,MAAM,eAAe,KAAK,gBAAgB;CAC1C,MAAM,aAAa,sBAAsB,WAAW,MAAM;CAC1D,MAAM,UAAU,cAAc,WAAW,KAAK,gBAAgB,kBAAkB,MAAM;CAItF,MAAM,aAAa,UAAA,QAAK,KAAK,WAAW,UAAU;CAElD,eAAe,WAAW,KAAmD;EAC3E,CAAA,GAAA,QAAA,WAAU,YAAY,EAAE,WAAW,KAAK,CAAC;EACzC,MAAM,UAAA,GAAA,YAAA,YAAoB;EAC1B,MAAM,YAAY,UAAA,QAAK,KAAK,YAAY,aAAa,OAAO,MAAM;EAClE,MAAM,UAAU,UAAA,QAAK,KAAK,YAAY,aAAa,OAAO,KAAK;EAC/D,IAAI;GACF,OAAA,GAAA,iBAAA,WAAgB,WAAW,OAAO,KAAK,IAAI,QAAQ,QAAQ,CAAC;GAC5D,MAAM,gBAAgB,WAAW,SAAS,YAAY;GAEtD,OAAO,EAAE,MAAM,oBAAoB,MADhB,QAAQ,cAAc,SAAS,IAAI,UAAU,IAAI,KAAK,CAClC,EAAE;EAC3C,UAAU;GACR,OAAA,GAAA,iBAAA,IAAS,WAAW,EAAE,OAAO,KAAK,CAAC;GACnC,OAAA,GAAA,iBAAA,IAAS,SAAS,EAAE,OAAO,KAAK,CAAC;EACnC;CACF;CAEA,OAAO;EACL,eAAe,UAAU,aAAa,WAAW,KAAK;EACtD,iBAAiB,UAAU,WAAW,UAAU,KAAK;EACrD,wBAAwB,UAAU,WAAW,OAAO,KAAK;EACzD,SAAS,UAAU,QAAQ,OAAO,KAAK;EACvC;EACA,gBAAgB,QAAQ,SAAS;CACnC;AACF"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../../src/whisper/internal.ts","../../src/whisper/ffmpeg.ts","../../src/whisper/models.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// 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 const live = downloadStatus.get(name);\n if (live?.state === \"downloading\") return live;\n if (isModelReady(modelsDir, name)) return { state: \"ready\" };\n return live ?? { state: \"idle\" };\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 = Number(response.headers.get(\"content-length\")) || 0;\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","// 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\";\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;\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 = (tail.text + chunk).slice(-4000);\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\nfunction 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\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 = [\"--model\", modelFilePath(modelsDir, model), \"--host\", HOST, \"--port\", String(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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"}
|
package/dist/whisper/index.js
CHANGED
|
@@ -379,7 +379,7 @@ function createSidecar(modelsDir, serverBinary = "whisper-server", logger = NOOP
|
|
|
379
379
|
}
|
|
380
380
|
//#endregion
|
|
381
381
|
//#region src/whisper/whisper.ts
|
|
382
|
-
var BLANK_MARKERS = new Set([
|
|
382
|
+
var BLANK_MARKERS = /* @__PURE__ */ new Set([
|
|
383
383
|
"[blank_audio]",
|
|
384
384
|
"[silence]",
|
|
385
385
|
"(silence)",
|
|
@@ -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.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// 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 const live = downloadStatus.get(name);\n if (live?.state === \"downloading\") return live;\n if (isModelReady(modelsDir, name)) return { state: \"ready\" };\n return live ?? { state: \"idle\" };\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 = Number(response.headers.get(\"content-length\")) || 0;\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","// 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\";\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;\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 = (tail.text + chunk).slice(-4000);\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\nfunction 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\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 = [\"--model\", modelFilePath(modelsDir, model), \"--host\", HOST, \"--port\", String(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":";;;;;;;;;;AAEA,IAAa,gBAAgB;AAC7B,IAAa,gBAAgB;AAE7B,SAAgB,aAAa,KAAsB;CACjD,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AACxD;AAUA,IAAM,aAAmB,KAAA;AACzB,IAAa,cAA6B;CAAE,MAAM;CAAM,MAAM;CAAM,OAAO;AAAK;;;ACVhF,IAAM,gBAAgB,UAAU,QAAQ;;;AAIxC,SAAgB,gBAAgB,WAAmB,YAA8B;CAC/E,OAAO;EAAC;EAAM;EAAa;EAAS;EAAM;EAAW;EAAO;EAAS;EAAO;EAAK;EAAQ;EAAa;CAAU;AAClH;;;AAIA,eAAsB,gBAAgB,WAAmB,YAAoB,eAAe,UAAyB;CACnH,MAAM,cAAc,cAAc,gBAAgB,WAAW,UAAU,GAAG,EACxE,SAAS,cACX,CAAC;AACH;;;ACHA,IAAM,UAAU;AAIhB,IAAa,iBAAiB;CAC5B,kBAAkB;EAAE,MAAM;EAA2B,KAAK,GAAG,QAAQ;EAA2B,UAAU;CAAc;CACxH,OAAO;EAAE,MAAM;EAAkB,KAAK,GAAG,QAAQ;EAAkB,UAAU;CAAY;CACzF,MAAM;EAAE,MAAM;EAAiB,KAAK,GAAG,QAAQ;EAAiB,UAAU;CAAY;AACxF;AAGA,IAAa,wBAA0C;AAEvD,SAAgB,mBAAmB,OAA2C;CAI5E,OAAO,OAAO,UAAU,YAAY,OAAO,UAAU,eAAe,KAAK,gBAAgB,KAAK;AAChG;;AAGA,SAAgB,iBAAiB,MAA4C;CAC3E,OAAO,mBAAmB,IAAI,IAAI,OAAO;AAC3C;AAEA,SAAgB,cAAc,WAAmB,MAAgC;CAC/E,OAAO,KAAK,KAAK,WAAW,eAAe,MAAM,IAAI;AACvD;;AAGA,SAAgB,aAAa,WAAmB,MAAiC;CAC/E,IAAI;EACF,OAAO,SAAS,cAAc,WAAW,IAAI,CAAC,EAAE,QAAQ,eAAe,MAAM;CAC/E,QAAQ;EACN,OAAO;CACT;AACF;AAaA,IAAM,4BAA4B;AAOlC,SAAgB,sBAAsB,WAAmB,SAAwB,aAA8B;CAG7G,MAAM,iCAAiB,IAAI,IAAmC;CAE9D,SAAS,UAAU,MAAqC;EACtD,MAAM,OAAO,eAAe,IAAI,IAAI;EACpC,IAAI,MAAM,UAAU,eAAe,OAAO;EAC1C,IAAI,aAAa,WAAW,IAAI,GAAG,OAAO,EAAE,OAAO,QAAQ;EAC3D,OAAO,QAAQ,EAAE,OAAO,OAAO;CACjC;CAEA,eAAe,aACb,MACA,aACA,OACA,MACA,YACe;EACf,MAAM,SAAS,KAAK,UAAU;EAC9B,MAAM,aAAa,kBAAkB,WAAW;EAChD,IAAI,WAAW;EACf,IAAI;GACF,SAAS;IACP,MAAM,EAAE,MAAM,UAAU,MAAM,OAAO,KAAK;IAC1C,IAAI,MAAM;IACV,WAAW;IACX,YAAY,MAAM;IAClB,IAAI,CAAC,WAAW,MAAM,KAAK,GAAG,MAAM,KAAK,YAAY,OAAO;IAC5D,IAAI,QAAQ,GAAG,eAAe,IAAI,MAAM;KAAE,OAAO;KAAe,UAAU,WAAW;IAAM,CAAC;GAC9F;GACA,WAAW,IAAI;GACf,MAAM,KAAK,YAAY,QAAQ;EACjC,SAAS,KAAK;GACZ,WAAW,QAAQ;GACnB,MAAM;EACR;CACF;CAEA,eAAe,SAAS,MAAuC;EAC7D,MAAM,OAAO,eAAe;EAC5B,UAAU,WAAW,EAAE,WAAW,KAAK,CAAC;EACxC,MAAM,OAAO,cAAc,WAAW,IAAI;EAC1C,MAAM,UAAU,GAAG,KAAK;EACxB,MAAM,aAAa,IAAI,gBAAgB;EACvC,IAAI,aAAmD;EACvD,MAAM,mBAAmB;GACvB,IAAI,YAAY,aAAa,UAAU;GACvC,aAAa,iBAAiB,WAAW,MAAM,GAAG,yBAAyB;EAC7E;EACA,IAAI;GACF,MAAM,WAAW,MAAM,MAAM,KAAK,KAAK,EAAE,QAAQ,WAAW,OAAO,CAAC;GACpE,IAAI,CAAC,SAAS,MAAM,CAAC,SAAS,MAC5B,MAAM,IAAI,MAAM,yBAAyB,SAAS,QAAQ;GAE5D,MAAM,QAAQ,OAAO,SAAS,QAAQ,IAAI,gBAAgB,CAAC,KAAK;GAChE,WAAW;GACX,MAAM,aAAa,SAAS,MAAM,SAAS,OAAO,MAAM,UAAU;GAClE,IAAI,SAAS,OAAO,EAAE,OAAO,KAAK,UAAU;IAC1C,WAAW,OAAO;IAClB,MAAM,IAAI,MAAM,6DAA6D;GAC/E;GACA,WAAW,SAAS,IAAI;EAC1B,UAAU;GACR,IAAI,YAAY,aAAa,UAAU;EACzC;CACF;CAIA,eAAe,OAAO,MAAuC;EAC3D,IAAI,aAAa,WAAW,IAAI,GAAG;GACjC,eAAe,IAAI,MAAM,EAAE,OAAO,QAAQ,CAAC;GAC3C;EACF;EACA,IAAI,eAAe,IAAI,IAAI,GAAG,UAAU,eAAe;EACvD,eAAe,IAAI,MAAM;GAAE,OAAO;GAAe,UAAU;EAAE,CAAC;EAC9D,OAAO,KAAK,yBAAyB,EAAE,OAAO,KAAK,CAAC;EACpD,IAAI;GACF,MAAM,SAAS,IAAI;GACnB,eAAe,IAAI,MAAM,EAAE,OAAO,QAAQ,CAAC;GAC3C,OAAO,KAAK,sBAAsB,EAAE,OAAO,KAAK,CAAC;EACnD,SAAS,KAAK;GACZ,MAAM,QAAQ,aAAa,GAAG;GAC9B,eAAe,IAAI,MAAM;IAAE,OAAO;IAAS;GAAM,CAAC;GAClD,OAAO,MAAM,0BAA0B;IAAE,OAAO;IAAM;GAAM,CAAC;EAC/D;CACF;CAEA,OAAO;EAAE;EAAW;CAAO;AAC7B;;;ACzJA,IAAM,OAAO;AACb,IAAM,mBAAmB,KAAK;AAC9B,IAAM,yBAAyB;AAC/B,IAAM,uBAAuB,IAAI;AAcjC,eAAe,eAAgC;CAC7C,OAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,MAAM,aAAa;EACzB,IAAI,GAAG,SAAS,MAAM;EACtB,IAAI,OAAO,GAAG,YAAY;GACxB,MAAM,OAAO,IAAI,QAAQ;GACzB,IAAI,QAAQ,OAAO,SAAS,UAAU;IACpC,MAAM,EAAE,SAAS;IACjB,IAAI,YAAY,QAAQ,IAAI,CAAC;GAC/B,OACE,IAAI,YAAY,uBAAO,IAAI,MAAM,iCAAiC,CAAC,CAAC;EAExE,CAAC;CACH,CAAC;AACH;;;AAIA,eAAe,eAAe,MAA6B;CACzD,MAAM,WAAW,KAAK,IAAI,IAAI;CAC9B,OAAO,KAAK,IAAI,IAAI,UAClB,IAAI;EACF,MAAM,MAAM,UAAU,KAAK,GAAG,KAAK,IAAI,EAAE,QAAQ,YAAY,QAAQ,aAAa,EAAE,CAAC;EACrF;CACF,QAAQ;EACN,MAAM,aAAM,sBAAsB;CACpC;CAEF,MAAM,IAAI,MAAM,6CAA6C;AAC/D;AAIA,SAAS,YAAY,MAAoB,MAA8B;CACrE,KAAK,QAAQ,YAAY,MAAM;CAC/B,KAAK,QAAQ,GAAG,SAAS,UAAkB;EACzC,KAAK,QAAQ,KAAK,OAAO,OAAO,MAAM,IAAK;CAC7C,CAAC;AACH;AAIA,SAAS,sBAAsB,MAAoB,MAA6B;CAC9E,OAAO,IAAI,SAAe,SAAS,WAAW;EAC5C,IAAI,gBAAsC,KAAA;EAC1C,IAAI,eAA8C,KAAA;EAClD,MAAM,gBAAgB;GACpB,KAAK,eAAe,SAAS,OAAO;GACpC,KAAK,eAAe,QAAQ,MAAM;EACpC;EACA,WAAW,QAAe;GACxB,QAAQ;GACR,uBAAO,IAAI,MAAM,iBAAiB,aAAa,GAAG,GAAG,CAAC;EACxD;EACA,UAAU,SAAwB;GAChC,QAAQ;GACR,uBAAO,IAAI,MAAM,sBAAsB,KAAK,EAAE,CAAC;EACjD;EACA,KAAK,KAAK,SAAS,OAAO;EAC1B,KAAK,KAAK,QAAQ,MAAM;EACxB,eAAe,IAAI,EAChB,WAAW;GACV,QAAQ;GACR,QAAQ;EACV,CAAC,EACA,OAAO,QAAiB;GACvB,QAAQ;GACR,OAAO,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC,CAAC;EAC5D,CAAC;CACL,CAAC;AACH;AAEA,SAAS,mBAAmB,MAAuB;CACjD,IAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,UAAU,MAAM;EAC/D,MAAM,EAAE,SAAS;EACjB,IAAI,OAAO,SAAS,UAAU,OAAO;CACvC;CACA,OAAO;AACT;AAEA,SAAgB,cAAc,WAAmB,eAAe,kBAAkB,SAAwB,aAAsB;CAC9H,IAAI,UAAgC;CACpC,IAAI,WAAgF;CAIpF,IAAI,eAAoC;CACxC,IAAI,aAAa;CAEjB,SAAS,WAAiB;EACxB,cAAc;EACd,IAAI,cAAc;GAChB,aAAa,KAAK;GAClB,eAAe;EACjB;EACA,IAAI,SAAS;GACX,QAAQ,KAAK,KAAK;GAClB,UAAU;EACZ;CACF;CAEA,eAAe,aAAa,OAAiD;EAC3E,MAAM,QAAQ,EAAE;EAChB,MAAM,OAAO,MAAM,aAAa;EAChC,MAAM,OAAO;GAAC;GAAW,cAAc,WAAW,KAAK;GAAG;GAAU;GAAM;GAAU,OAAO,IAAI;EAAC;EAChG,OAAO,KAAK,qBAAqB;GAAE;GAAO;EAAK,CAAC;EAChD,MAAM,OAAO,MAAM,cAAc,MAAM,EAAE,OAAO;GAAC;GAAU;GAAU;EAAM,EAAE,CAAC;EAC9E,eAAe;EACf,MAAM,aAAa,EAAE,MAAM,GAAG;EAC9B,YAAY,MAAM,UAAU;EAG5B,KAAK,GAAG,UAAU,QAAQ,OAAO,KAAK,0BAA0B;GAAE;GAAO,OAAO,aAAa,GAAG;EAAE,CAAC,CAAC;EACpG,KAAK,GAAG,SAAS,SAAS;GACxB,OAAO,KAAK,mBAAmB;IAAE;IAAO;IAAM,YAAY,WAAW,KAAK,MAAM,IAAI;GAAE,CAAC;GACvF,IAAI,SAAS,SAAS,MAAM,UAAU;EACxC,CAAC;EACD,IAAI;GACF,MAAM,sBAAsB,MAAM,IAAI;EACxC,SAAS,KAAK;GACZ,KAAK,KAAK;GACV,MAAM,IAAI,MAAM,mCAAmC,aAAa,GAAG,EAAE,aAAa,WAAW,KAAK,MAAM,IAAI,GAAG;EACjH,UAAU;GACR,IAAI,iBAAiB,MAAM,eAAe;EAC5C;EAIA,IAAI,UAAU,YAAY;GACxB,KAAK,KAAK;GACV,MAAM,IAAI,MAAM,gCAAgC;EAClD;EACA,UAAU;GAAE;GAAM;GAAM;EAAM;EAC9B,OAAO,KAAK,kBAAkB;GAAE;GAAO;EAAK,CAAC;EAC7C,OAAO;CACT;CAIA,SAAS,WAAW,OAAiD;EACnE,MAAM,UAAU,aAAa,KAAK,EAAE,cAAc;GAChD,WAAW;EACb,CAAC;EACD,WAAW;GAAE;GAAO;EAAQ;EAC5B,OAAO;CACT;CAEA,eAAe,cAAc,OAAiD;EAI5E,SAAS;GACP,IAAI,WAAW,QAAQ,UAAU,SAAS,CAAC,QAAQ,KAAK,QAAQ,OAAO;GACvE,IAAI,YAAY,SAAS,UAAU,OAAO,OAAO,SAAS;GAC1D,IAAI,UAAU;IACZ,MAAM,SAAS,QAAQ,YAAY,KAAA,CAAS;IAC5C;GACF;GACA,IAAI,WAAW,QAAQ,UAAU,OAAO,SAAS;GACjD,OAAO,WAAW,KAAK;EACzB;CACF;CAEA,eAAe,OAAO,OAAwC;EAC5D,IAAI;GACF,MAAM,cAAc,KAAK;EAC3B,SAAS,KAAK;GACZ,OAAO,KAAK,0BAA0B;IAAE;IAAO,OAAO,aAAa,GAAG;GAAE,CAAC;EAC3E;CACF;CAEA,eAAe,cAAc,SAAiB,UAAkB,OAA0C;EACxG,MAAM,SAAS,MAAM,cAAc,KAAK;EACxC,MAAM,MAAM,MAAM,SAAS,OAAO;EAClC,MAAM,OAAO,IAAI,SAAS;EAC1B,KAAK,OAAO,QAAQ,IAAI,KAAK,CAAC,GAAG,GAAG,EAAE,MAAM,YAAY,CAAC,GAAG,WAAW;EACvE,KAAK,OAAO,mBAAmB,MAAM;EACrC,KAAK,OAAO,YAAY,YAAY,MAAM;EAC1C,IAAI;EACJ,IAAI;GACF,MAAM,MAAM,MAAM,UAAU,KAAK,GAAG,OAAO,KAAK,aAAa;IAAE,QAAQ;IAAQ,MAAM;IAAM,QAAQ,YAAY,QAAQ,oBAAoB;GAAE,CAAC;EAChJ,SAAS,KAAK;GACZ,MAAM,IAAI,MAAM,kCAAkC,aAAa,GAAG,GAAG;EACvE;EACA,IAAI,CAAC,IAAI,IAAI,MAAM,IAAI,MAAM,gCAAgC,IAAI,QAAQ;EACzE,OAAO,mBAAmB,MAAM,IAAI,KAAK,CAAC;CAC5C;CAEA,OAAO;EAAE;EAAe;EAAQ;CAAS;AAC3C;;;AC/KA,IAAM,gBAAgB,IAAI,IAAI;CAAC;CAAiB;CAAa;CAAa;AAAe,CAAC;AAE1F,SAAS,oBAAoB,KAAqB;CAChD,MAAM,UAAU,IAAI,KAAK,EAAE,QAAQ,QAAQ,GAAG;CAC9C,OAAO,cAAc,IAAI,QAAQ,YAAY,CAAC,IAAI,KAAK;AACzD;AAEA,SAAgB,cAAc,MAA+B;CAC3D,MAAM,EAAE,cAAc;CACtB,MAAM,SAAS,KAAK,UAAU;CAC9B,MAAM,eAAe,KAAK,gBAAgB;CAC1C,MAAM,aAAa,sBAAsB,WAAW,MAAM;CAC1D,MAAM,UAAU,cAAc,WAAW,KAAK,gBAAgB,kBAAkB,MAAM;CAItF,MAAM,aAAa,KAAK,KAAK,WAAW,UAAU;CAElD,eAAe,WAAW,KAAmD;EAC3E,UAAU,YAAY,EAAE,WAAW,KAAK,CAAC;EACzC,MAAM,SAAS,WAAW;EAC1B,MAAM,YAAY,KAAK,KAAK,YAAY,aAAa,OAAO,MAAM;EAClE,MAAM,UAAU,KAAK,KAAK,YAAY,aAAa,OAAO,KAAK;EAC/D,IAAI;GACF,MAAM,UAAU,WAAW,OAAO,KAAK,IAAI,QAAQ,QAAQ,CAAC;GAC5D,MAAM,gBAAgB,WAAW,SAAS,YAAY;GAEtD,OAAO,EAAE,MAAM,oBAAoB,MADhB,QAAQ,cAAc,SAAS,IAAI,UAAU,IAAI,KAAK,CAClC,EAAE;EAC3C,UAAU;GACR,MAAM,GAAG,WAAW,EAAE,OAAO,KAAK,CAAC;GACnC,MAAM,GAAG,SAAS,EAAE,OAAO,KAAK,CAAC;EACnC;CACF;CAEA,OAAO;EACL,eAAe,UAAU,aAAa,WAAW,KAAK;EACtD,iBAAiB,UAAU,WAAW,UAAU,KAAK;EACrD,wBAAwB,UAAU,WAAW,OAAO,KAAK;EACzD,SAAS,UAAU,QAAQ,OAAO,KAAK;EACvC;EACA,gBAAgB,QAAQ,SAAS;CACnC;AACF"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/whisper/internal.ts","../../src/whisper/ffmpeg.ts","../../src/whisper/models.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// 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 const live = downloadStatus.get(name);\n if (live?.state === \"downloading\") return live;\n if (isModelReady(modelsDir, name)) return { state: \"ready\" };\n return live ?? { state: \"idle\" };\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 = Number(response.headers.get(\"content-length\")) || 0;\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","// 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\";\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;\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 = (tail.text + chunk).slice(-4000);\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\nfunction 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\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 = [\"--model\", modelFilePath(modelsDir, model), \"--host\", HOST, \"--port\", String(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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"}
|