fix(cli): use a healthy pinned Sherpa copy beside the CLI (#5095)

* docs(cli): map the pinned Sherpa runtime loading contract

* fix(cli): use a healthy pinned Sherpa copy beside the CLI

* chore(cli): keep runtime loader comments within the ratchet

* docs(cli): wrap the runtime contract palette declarations

* chore(cli): keep the Sherpa contract note out of the repo
This commit is contained in:
Miguel Ángel
2026-10-06 05:43:09 +00:00
committed by GitHub
parent d09e003b17
commit f6e419eb29
9 changed files with 504 additions and 113 deletions
+22 -2
View File
@@ -1,6 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CliRuntimeError, consumeCommandResult } from "../utils/commandResult.js";
const cachedRuntimePath =
"/cache/optional/sherpa-onnx-node@1.13.8/node_modules/sherpa-onnx-node/index.js";
const sherpa = {
SHERPA_RUNTIME_DIR: "/cache/optional/sherpa-onnx-node@1.13.8",
PARAKEET_MODEL_DIR: "/cache/parakeet/parakeet-tdt-0.6b-v3-int8",
@@ -44,7 +46,10 @@ describe("models install parakeet --json", () => {
consumeCommandResult();
vi.spyOn(console, "log").mockImplementation(() => {});
sherpa.sherpaUnsupportedReason.mockReturnValue(null);
sherpa.installSherpaRuntime.mockResolvedValue(true);
sherpa.installSherpaRuntime.mockResolvedValue({
installed: true,
runtimePath: cachedRuntimePath,
});
sherpa.ensureParakeetModel.mockResolvedValue(false);
});
afterEach(() => vi.restoreAllMocks());
@@ -58,6 +63,7 @@ describe("models install parakeet --json", () => {
model: "parakeet-tdt-0.6b-v3",
changed: true,
runtimeDir: sherpa.SHERPA_RUNTIME_DIR,
runtimePath: cachedRuntimePath,
modelDir: sherpa.PARAKEET_MODEL_DIR,
},
});
@@ -65,10 +71,24 @@ describe("models install parakeet --json", () => {
});
it("reports changed: false when the runtime loads and the model verifies", async () => {
sherpa.installSherpaRuntime.mockResolvedValue(false);
sherpa.installSherpaRuntime.mockResolvedValue({
installed: false,
runtimePath: cachedRuntimePath,
});
expect((await install()).out).toMatchObject({ ok: true, changed: false });
});
it("reports a selected beside copy while retaining the cache destination", async () => {
const runtimePath = "/bundle/node_modules/sherpa-onnx-node/index.js";
sherpa.installSherpaRuntime.mockResolvedValue({ installed: false, runtimePath });
expect((await install()).out).toMatchObject({
ok: true,
changed: false,
runtimeDir: sherpa.SHERPA_RUNTIME_DIR,
runtimePath,
});
});
it("reports a failed install as ok:false with exit 1", async () => {
sherpa.ensureParakeetModel.mockRejectedValue(new Error("tokens.txt did not match"));
expect(await install()).toEqual({
+13 -6
View File
@@ -43,18 +43,18 @@ function downloadProgress(spin: Spinner) {
type Sherpa = typeof import("../whisper/sherpa.js");
type Spinner = ReturnType<typeof clack.spinner> | null;
/** Installs what is missing; true when anything changed. */
/** Installs missing pieces and carries the selected runtime provenance into the result. */
async function installMissing(sherpa: Sherpa, spin: Spinner, signal: AbortSignal) {
const runtimeInstalled = await sherpa.installSherpaRuntime({ signal });
const runtime = await sherpa.installSherpaRuntime({ signal });
spin?.message("Verifying the Parakeet model...");
const modelFetched = await sherpa.ensureParakeetModel({
signal,
onBytes: downloadProgress(spin),
});
return runtimeInstalled || modelFetched;
return { changed: runtime.installed || modelFetched, runtimePath: runtime.runtimePath };
}
/** A synchronous child's Ctrl-C shows as its error before the scope's listener runs. */
/** Cancellation can reach the child before the scope observes the signal. */
const wasCancelled = (err: unknown, signal: AbortSignal, sherpa: Sherpa) =>
signal.aborted || err instanceof sherpa.DecodeCancelled;
@@ -68,12 +68,19 @@ async function installParakeet(json: boolean): Promise<void> {
const cancellation = createRenderCancellationScope();
spin?.start("Checking the sherpa-onnx runtime (installing it from npm if it does not load)...");
try {
const changed = await installMissing(sherpa, spin, cancellation.signal);
const { changed, runtimePath } = await installMissing(sherpa, spin, cancellation.signal);
spin?.stop(c.success(changed ? "Parakeet installed" : "Parakeet is already installed"));
if (json) {
const { SHERPA_RUNTIME_DIR: runtimeDir, PARAKEET_MODEL_DIR: modelDir } = sherpa;
console.log(
JSON.stringify({ ok: true, model: PARAKEET_MODEL_LABEL, changed, runtimeDir, modelDir }),
JSON.stringify({
ok: true,
model: PARAKEET_MODEL_LABEL,
changed,
runtimeDir,
runtimePath,
modelDir,
}),
);
}
} catch (err) {
+34 -5
View File
@@ -1,8 +1,17 @@
import { runCommand } from "citty";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { chmodSync, existsSync, writeFileSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
import {
chmodSync,
mkdirSync,
existsSync,
writeFileSync,
readFileSync,
mkdtempSync,
rmSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { pathToFileURL } from "node:url";
import { WhisperUnavailableError } from "../whisper/manager.js";
import { CliRuntimeError, consumeCommandResult } from "../utils/commandResult.js";
@@ -18,10 +27,24 @@ vi.mock("../whisper/transcribe.js", () => ({
// Engine selection: which runners look installed, and the sherpa decode child it spawns.
const runners = { sherpa: false, mlx: false };
vi.mock("../whisper/sherpa.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../whisper/sherpa.js")>()),
sherpaParakeetInstalled: () => runners.sherpa,
}));
let runtimeDir: string;
vi.mock("../whisper/sherpa.js", async (importOriginal) => {
const sherpa = await importOriginal<typeof import("../whisper/sherpa.js")>();
return {
...sherpa,
sherpaParakeetInstalled: () => runners.sherpa,
transcribeWithSherpa: (
wavPath: string,
dir: string,
options: Parameters<typeof sherpa.transcribeWithSherpa>[2],
) =>
sherpa.transcribeWithSherpa(wavPath, dir, {
...options,
runtimeDir,
cliUrl: pathToFileURL(join(runtimeDir, "dist", "cli.js")).href,
}),
};
});
const mlxMock = vi.fn();
vi.mock("../whisper/parakeet.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../whisper/parakeet.js")>()),
@@ -165,6 +188,12 @@ describe("transcribe command", () => {
describe("engine selection", () => {
beforeEach(() => {
runtimeDir = mkdtempSync(join(tmpdir(), "hf-transcribe-runtime-"));
dirs.push(runtimeDir);
const pkg = join(runtimeDir, "node_modules", "sherpa-onnx-node");
mkdirSync(pkg, { recursive: true });
writeFileSync(join(pkg, "package.json"), JSON.stringify({ main: "index.js" }));
writeFileSync(join(pkg, "index.js"), "module.exports = {};");
transcribeMock.mockImplementation(async (_in: string, dir: string) =>
fakeTranscript(dir, "whisper"),
);
@@ -132,9 +132,9 @@ describe("loadOptionalPackage", () => {
describe("a copy installed beside the CLI", () => {
// node_modules/hyperframes/dist/cli.js with onnxruntime-node installed next to hyperframes.
function layout(version: string) {
function layout(version: string, name = "onnxruntime-node") {
const root = mkdtempSync(join(tmpdir(), "hf-beside-"));
const pkg = join(root, "node_modules", "onnxruntime-node");
const pkg = join(root, "node_modules", name);
mkdirSync(pkg, { recursive: true });
writeFileSync(join(pkg, "package.json"), JSON.stringify({ version, main: "index.js" }));
writeFileSync(join(pkg, "index.js"), `module.exports = { copy: "beside ${version}" };`);
@@ -153,6 +153,27 @@ describe("a copy installed beside the CLI", () => {
}
});
it.each(["1.13.8", "1.13.7"])("accepts only the Sherpa 1.13.8 pin, received %s", (version) => {
const { root, cliUrl } = layout(version, "sherpa-onnx-node");
try {
const result = loadBesideCli("sherpa-onnx-node", cliUrl);
if (version === "1.13.8") expect(result).toEqual({ copy: "beside 1.13.8" });
else expect(result).toBeNull();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("keeps a missing optional-package entry eligible for cache fallback", () => {
const { root, cliUrl } = layout("1.21.1");
try {
rmSync(join(root, "node_modules", "onnxruntime-node", "index.js"));
expect(loadBesideCli("onnxruntime-node", cliUrl)).toBeNull();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("ignores it when its manifest is unreadable, instead of throwing", () => {
const { root, cliUrl } = layout(OPTIONAL_PACKAGES["onnxruntime-node"]);
writeFileSync(join(root, "node_modules", "onnxruntime-node", "package.json"), "{ version: 1");
+41 -11
View File
@@ -30,6 +30,13 @@ export const OPTIONAL_PACKAGES = {
"@google/genai": "1.52.0",
} as const satisfies Record<OptionalPackage, string>;
export const PINNED_PACKAGES = {
...OPTIONAL_PACKAGES,
"sherpa-onnx-node": "1.13.8",
} as const;
type PinnedPackage = keyof typeof PINNED_PACKAGES;
export const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "optional");
export interface OptionalPackageDeps {
@@ -53,7 +60,7 @@ export function installedOptionalPackageVersion(
cacheDir = CACHE_DIR,
cliUrl = import.meta.url,
): string | null {
if (pinnedCopyBesideCli(name, cliUrl)) return OPTIONAL_PACKAGES[name];
if (pinnedPackageBesideCli(name, cliUrl)?.resolution === "entry") return OPTIONAL_PACKAGES[name];
const dir = optionalPackageDir(name, cacheDir);
if (!isInstalled(dir, name)) return null;
return (JSON.parse(readFileSync(manifestPath(dir, name), "utf-8")) as { version: string })
@@ -113,28 +120,51 @@ export function isInstalled(dir: string, name: string): boolean {
return existsSync(manifestPath(dir, name));
}
export function loadInstalled(dir: string, name: string): unknown | null {
export function installedPackagePath(dir: string, name: string): string | null {
if (!isInstalled(dir, name)) return null;
return createRequire(join(dir, "package.json"))(name);
return createRequire(join(dir, "package.json")).resolve(name);
}
function pinnedCopyBesideCli(name: OptionalPackage, cliUrl: string): boolean {
function loadInstalled(dir: string, name: string): unknown | null {
const entry = installedPackagePath(dir, name);
return entry === null ? null : createRequire(join(dir, "package.json"))(entry);
}
export function pinnedPackageBesideCli(
name: PinnedPackage,
cliUrl = import.meta.url,
): { resolution: "entry" | "package"; path: string } | null {
const req = createRequire(cliUrl);
try {
const entry = realpathSync(req.resolve(name));
let entry: string;
try {
entry = realpathSync(req.resolve(name));
} catch {
const copy = (req.resolve.paths(name) ?? [])
.map((dir) => join(dir, name))
.find((dir) => existsSync(join(dir, "package.json")));
return copy && hasPinnedVersion(copy, name)
? { resolution: "package", path: realpathSync(copy) }
: null;
}
const copy = (req.resolve.paths(name) ?? [])
.map((dir) => join(dir, name))
.find((dir) => existsSync(dir) && entry.startsWith(realpathSync(dir) + sep));
if (!copy) return false;
const manifest = readFileSync(join(copy, "package.json"), "utf-8");
return (JSON.parse(manifest) as { version?: string }).version === OPTIONAL_PACKAGES[name];
if (!copy) return null;
return hasPinnedVersion(copy, name) ? { resolution: "entry", path: entry } : null;
} catch {
return false;
return null;
}
}
export function loadBesideCli(name: OptionalPackage, cliUrl = import.meta.url): unknown | null {
return pinnedCopyBesideCli(name, cliUrl) ? createRequire(cliUrl)(name) : null;
function hasPinnedVersion(copy: string, name: PinnedPackage): boolean {
const manifest = readFileSync(join(copy, "package.json"), "utf-8");
return (JSON.parse(manifest) as { version?: string }).version === PINNED_PACKAGES[name];
}
export function loadBesideCli(name: PinnedPackage, cliUrl = import.meta.url): unknown | null {
const copy = pinnedPackageBesideCli(name, cliUrl);
return copy?.resolution === "entry" ? createRequire(cliUrl)(copy.path) : null;
}
export function runNpm(args: string[], signal?: AbortSignal): Promise<void> {
+216 -43
View File
@@ -4,13 +4,15 @@ import {
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
truncateSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DownloadOptions } from "../cloud/download.js";
import {
ensureParakeetModel,
@@ -20,6 +22,7 @@ import {
DecodeCancelled,
sherpaRuntimeLoadError,
sherpaUnsupportedReason,
transcribeWithSherpa,
type ModelFile,
} from "./sherpa.js";
@@ -41,6 +44,19 @@ function fakeDownload(served: Record<string, string>) {
});
}
/** A stand-in runtime in `root` whose entry point loads, or throws as a missing library does. */
const LOADS = "module.exports = {};";
const BROKEN = 'throw new Error("libonnxruntime.so: cannot open\\n shared object");';
function fakeRuntime(root: string, body: string, version = "1.13.8") {
const pkg = join(root, "node_modules", "sherpa-onnx-node");
mkdirSync(pkg, { recursive: true });
writeFileSync(
join(pkg, "package.json"),
JSON.stringify({ name: "sherpa-onnx-node", main: "index.js", version }),
);
writeFileSync(join(pkg, "index.js"), body);
}
describe("installSherpaRuntime", () => {
it.each([
["darwin", "arm64", "sherpa-onnx-darwin-arm64@1.13.8"],
@@ -66,28 +82,23 @@ describe("installSherpaRuntime", () => {
expect(args).toContain(sherpaPlatformPackage());
});
/** A stand-in runtime in `root` whose entry point loads, or throws as a missing library does. */
const LOADS = "module.exports = {};";
const BROKEN = 'throw new Error("libonnxruntime.so: cannot open\\n shared object");';
function fakeRuntime(root: string, body: string) {
const pkg = join(root, "node_modules", "sherpa-onnx-node");
mkdirSync(pkg, { recursive: true });
writeFileSync(join(pkg, "package.json"), '{"name":"sherpa-onnx-node","main":"index.js"}');
writeFileSync(join(pkg, "index.js"), body);
}
it("reinstalls a runtime that is present but does not load", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-sherpa-runtime-"));
try {
fakeRuntime(dir, BROKEN);
expect(sherpaRuntimeLoadError(dir)).toBe("libonnxruntime.so: cannot open shared object");
expect(await sherpaRuntimeLoadError(dir)).toBe(
"libonnxruntime.so: cannot open shared object",
);
const run = vi.fn(async (args: string[]) =>
fakeRuntime(args[args.indexOf("--prefix") + 1]!, LOADS),
);
expect(await installSherpaRuntime({ run, dir })).toBe(true);
expect(await installSherpaRuntime({ run, dir })).toEqual({
installed: true,
runtimePath: join(dir, "node_modules", "sherpa-onnx-node", "index.js"),
});
expect(run).toHaveBeenCalledTimes(1);
expect(sherpaRuntimeLoadError(dir)).toBeNull();
expect(await sherpaRuntimeLoadError(dir)).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
@@ -98,7 +109,10 @@ describe("installSherpaRuntime", () => {
try {
fakeRuntime(dir, LOADS);
const run = vi.fn();
expect(await installSherpaRuntime({ run, dir })).toBe(false);
expect(await installSherpaRuntime({ run, dir })).toEqual({
installed: false,
runtimePath: join(dir, "node_modules", "sherpa-onnx-node", "index.js"),
});
expect(run).not.toHaveBeenCalled();
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -135,11 +149,24 @@ describe("installSherpaRuntime", () => {
},
);
it("reads its own probe timeout as a broken runtime, not a cancel, and says so", () => {
it("reads its own probe timeout as a broken runtime, not a cancel, and says so", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-sherpa-runtime-"));
try {
fakeRuntime(dir, "Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000);");
expect(sherpaRuntimeLoadError(dir, 300)).toBe("loading it timed out after 0.3 s");
expect(await sherpaRuntimeLoadError(dir, 300)).toBe("loading it timed out after 0.3 s");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("reports timeout even when the runtime handles SIGTERM and exits zero", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-sherpa-runtime-"));
try {
fakeRuntime(
dir,
'process.on("SIGTERM", () => process.exit(0)); require("node:net").createServer().listen(0, "127.0.0.1");',
);
expect(await sherpaRuntimeLoadError(dir, 300)).toBe("loading it timed out after 0.3 s");
} finally {
rmSync(dir, { recursive: true, force: true });
}
@@ -152,33 +179,179 @@ describe("installSherpaRuntime", () => {
});
});
describe("sherpaParakeetInstalled", () => {
it("counts a runtime that does not load, so transcribe reports it instead of skipping it", async () => {
const home = mkdtempSync(join(tmpdir(), "hf-sherpa-home-"));
vi.stubEnv("HOME", home);
vi.resetModules();
try {
const sherpa = await import("./sherpa.js");
const manifest = join(sherpa.SHERPA_RUNTIME_DIR, "node_modules", "sherpa-onnx-node");
mkdirSync(manifest, { recursive: true });
writeFileSync(join(manifest, "package.json"), "{}");
mkdirSync(sherpa.PARAKEET_MODEL_DIR, { recursive: true });
for (const [name, bytes] of [
["encoder.int8.onnx", 652_184_281],
["decoder.int8.onnx", 11_845_275],
["joiner.int8.onnx", 6_355_277],
["tokens.txt", 93_939],
] as const) {
writeFileSync(join(sherpa.PARAKEET_MODEL_DIR, name), "");
truncateSync(join(sherpa.PARAKEET_MODEL_DIR, name), bytes);
}
expect(sherpa.sherpaRuntimeLoadError()).not.toBeNull();
expect(sherpa.sherpaParakeetInstalled()).toBe(true);
} finally {
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
}
describe("a pinned Sherpa copy beside the CLI", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "hf-sherpa-beside-"));
});
afterEach(() => {
vi.restoreAllMocks();
rmSync(root, { recursive: true, force: true });
});
function layout(besideBody: string, cachedBody?: string) {
const bundle = join(root, "bundle");
const cache = join(root, "cache");
fakeRuntime(bundle, besideBody);
if (cachedBody !== undefined) fakeRuntime(cache, cachedBody);
const cliUrl = pathToFileURL(join(bundle, "dist", "cli.js")).href;
const pinnedPath = join(bundle, "node_modules", "sherpa-onnx-node", "index.js");
return { cache, cliUrl, pinnedPath };
}
function decoder() {
const parentMarker = join(root, "loaded-in-parent");
return `
if (process.pid === ${process.pid}) require("node:fs").writeFileSync(${JSON.stringify(parentMarker)}, "");
module.exports = {
readWave() { return { sampleRate: 100, samples: new Float32Array(100).fill(0.5) }; },
OfflineRecognizer: class {
createStream() { return { acceptWaveform() {} }; }
decode() {}
getResult() { return { tokens: [" hello"], timestamps: [0], durations: [1] }; }
}
};`;
}
async function transcript(cache: string, cliUrl: string) {
const out = join(root, "out");
mkdirSync(out, { recursive: true });
const onEvent = vi.fn();
await transcribeWithSherpa(join(root, "speech.wav"), out, {
signal: new AbortController().signal,
runtimeDir: cache,
cliUrl,
onEvent,
});
const result = JSON.parse(readFileSync(join(out, "transcript.json"), "utf-8"));
expect(result).toEqual([{ text: "hello", start: 0, end: 1 }]);
const event = { type: "progress", phase: "transcription", model: "parakeet-tdt-0.6b-v3" };
expect(onEvent.mock.calls).toEqual([
[{ ...event, status: "started" }],
[{ ...event, status: "completed" }],
]);
expect(existsSync(join(root, "loaded-in-parent"))).toBe(false);
}
it("loads a pinned adjacent copy without a cache or npm install", async () => {
const { cache, cliUrl, pinnedPath } = layout(LOADS);
const run = vi.fn();
expect(await sherpaRuntimeLoadError(cache, 60_000, cliUrl)).toBeNull();
expect(await installSherpaRuntime({ dir: cache, cliUrl, run })).toEqual({
installed: false,
runtimePath: pinnedPath,
});
expect(run).not.toHaveBeenCalled();
expect(existsSync(cache)).toBe(false);
});
it.skipIf(process.platform === "win32")(
"cancels an in-flight probe without inspecting the cache",
async () => {
const cacheMarker = join(root, "cache-loaded");
const { cache, cliUrl } = layout(
'require("node:net").createServer().listen(0, "127.0.0.1", () => process.kill(process.ppid, "SIGUSR2"));',
`require("node:fs").writeFileSync(${JSON.stringify(cacheMarker)}, ""); module.exports = {};`,
);
const log = vi.spyOn(console, "error").mockImplementation(() => {});
const controller = new AbortController();
const abort = () => controller.abort();
process.once("SIGUSR2", abort);
try {
await expect(
sherpaRuntimeLoadError(cache, 300, cliUrl, controller.signal),
).rejects.toBeInstanceOf(DecodeCancelled);
expect(existsSync(cacheMarker)).toBe(false);
expect(log).not.toHaveBeenCalled();
} finally {
process.removeListener("SIGUSR2", abort);
}
},
);
it("passes the pinned adjacent copy to the real decode worker", async () => {
const { cache, cliUrl } = layout(decoder());
await transcript(cache, cliUrl);
});
it("logs the broken pinned path once and decodes with the healthy cache", async () => {
const { cache, cliUrl, pinnedPath } = layout(BROKEN, decoder());
const log = vi.spyOn(console, "error").mockImplementation(() => {});
await transcript(cache, cliUrl);
expect(log).toHaveBeenCalledTimes(1);
expect(log.mock.calls[0]?.[0]).toContain(pinnedPath);
expect(log.mock.calls[0]?.[0]).toContain("libonnxruntime.so: cannot open shared object");
expect(String(log.mock.calls[0]?.[0])).not.toContain("\n");
});
it("reports an accepted package with a missing entry and uses the healthy cache", async () => {
const { cache, cliUrl, pinnedPath } = layout(LOADS, decoder());
rmSync(pinnedPath);
const log = vi.spyOn(console, "error").mockImplementation(() => {});
await transcript(cache, cliUrl);
expect(log).toHaveBeenCalledTimes(1);
expect(log.mock.calls[0]?.[0]).toContain(dirname(pinnedPath));
expect(log.mock.calls[0]?.[0]).toContain("Cannot find module");
});
it.each([undefined, BROKEN])(
"logs the broken pinned path once and reports repair when no cache loads (%s)",
async (cachedBody) => {
const { cache, cliUrl, pinnedPath } = layout(BROKEN, cachedBody);
const log = vi.spyOn(console, "error").mockImplementation(() => {});
const out = join(root, "out");
mkdirSync(out);
await expect(
transcribeWithSherpa(join(root, "speech.wav"), out, {
signal: new AbortController().signal,
runtimeDir: cache,
cliUrl,
}),
).rejects.toThrow(/hyperframes models install parakeet/);
expect(log).toHaveBeenCalledTimes(1);
expect(log.mock.calls[0]?.[0]).toContain(pinnedPath);
expect(log.mock.calls[0]?.[0]).toContain("libonnxruntime.so: cannot open shared object");
expect(readdirSync(out)).toEqual([]);
},
);
});
describe("sherpaParakeetInstalled", () => {
it.each(["cache", "beside CLI"])(
"counts a broken runtime in %s, so transcribe reports it instead of skipping it",
async (location) => {
const home = mkdtempSync(join(tmpdir(), "hf-sherpa-home-"));
vi.stubEnv("HOME", home);
vi.resetModules();
try {
const sherpa = await import("./sherpa.js");
const bundle = join(home, "bundle");
const cliUrl = pathToFileURL(join(bundle, "dist", "cli.js")).href;
const manifest = join(
location === "cache" ? sherpa.SHERPA_RUNTIME_DIR : bundle,
"node_modules",
"sherpa-onnx-node",
);
mkdirSync(manifest, { recursive: true });
if (location === "cache") writeFileSync(join(manifest, "package.json"), "{}");
else fakeRuntime(bundle, BROKEN);
mkdirSync(sherpa.PARAKEET_MODEL_DIR, { recursive: true });
for (const [name, bytes] of [
["encoder.int8.onnx", 652_184_281],
["decoder.int8.onnx", 11_845_275],
["joiner.int8.onnx", 6_355_277],
["tokens.txt", 93_939],
] as const) {
writeFileSync(join(sherpa.PARAKEET_MODEL_DIR, name), "");
truncateSync(join(sherpa.PARAKEET_MODEL_DIR, name), bytes);
}
expect(sherpa.sherpaParakeetInstalled(cliUrl)).toBe(true);
} finally {
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
}
},
);
});
describe("ensureParakeetModel", () => {
+139 -37
View File
@@ -1,4 +1,4 @@
import { execFile, spawnSync, type ExecFileException } from "node:child_process";
import { execFile, spawn, type ExecFileException } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { createReadStream, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
import { homedir } from "node:os";
@@ -8,6 +8,9 @@ import { downloadToFile } from "../cloud/download.js";
import { stoppedByCancelSignal } from "../utils/renderCancellation.js";
import {
CACHE_DIR,
PINNED_PACKAGES,
pinnedPackageBesideCli,
installedPackagePath,
install,
isInstalled,
runNpm,
@@ -29,7 +32,7 @@ import {
} from "./transcribe.js";
const RUNTIME = "sherpa-onnx-node";
const RUNTIME_VERSION = "1.13.8";
const RUNTIME_VERSION = PINNED_PACKAGES[RUNTIME];
/** Per platform and arch, like the native package inside, so Rosetta never shadows arm64. */
export const SHERPA_RUNTIME_DIR = join(
CACHE_DIR,
@@ -121,36 +124,109 @@ const nativePackageName = (platform: string, arch: string) =>
`sherpa-onnx-${platform === "win32" ? "win" : platform}-${arch}`;
const LOAD_RUNTIME = `try {
require("node:module").createRequire(process.env.HF_SHERPA_MANIFEST)("${RUNTIME}");
require(process.env.HF_SHERPA_RUNTIME_PATH);
} catch (e) {
process.stderr.write(String(e && e.message).replace(/\\s+/g, " "));
process.exitCode = 1;
}`;
/** Loads it in a child as the decode worker does: null when it loads, else the loader's error. */
export function sherpaRuntimeLoadError(
/** Native bindings are probed only in a child, as they are loaded in the decode worker. */
function runtimeLoadError(
runtimePath: string,
timeoutMs: number,
signal?: AbortSignal,
): Promise<string | null> {
if (signal?.aborted)
return Promise.reject(new DecodeCancelled("Parakeet runtime check cancelled"));
return new Promise((resolve, reject) => {
const probe = spawn(process.execPath, ["-e", LOAD_RUNTIME], {
env: { ...process.env, HF_SHERPA_RUNTIME_PATH: runtimePath },
stdio: ["ignore", "ignore", "pipe"],
timeout: timeoutMs,
signal,
});
let stderr = "";
probe.stderr.setEncoding("utf8");
probe.stderr.on("data", (chunk: string) => {
stderr = bounded(stderr + chunk);
});
probe.on("error", (error) => {
if (signal?.aborted) reject(new DecodeCancelled("Parakeet runtime check cancelled"));
else resolve(bounded(error.message));
});
probe.on("close", (code, childSignal) => {
if (
signal?.aborted ||
stoppedByCancelSignal({ code, signal: childSignal, killed: probe.killed })
) {
reject(new DecodeCancelled("Parakeet runtime check cancelled"));
return;
}
if (code === 0 && !probe.killed) {
resolve(null);
return;
}
let reason: string;
if (probe.killed) reason = `loading it timed out after ${timeoutMs / 1000} s`;
else if (childSignal) reason = `loading it crashed (${childSignal})`;
else reason = `it exited ${code} with no output`;
resolve(bounded(stderr.trim() || reason));
});
});
}
type RuntimeSelection =
| { status: "ready"; path: string }
| { status: "unavailable"; error: string };
async function cachedRuntime(
dir: string,
timeoutMs: number,
signal?: AbortSignal,
): Promise<RuntimeSelection> {
let path: string | null;
try {
path = installedPackagePath(dir, RUNTIME);
} catch (err) {
return {
status: "unavailable",
error: bounded(err instanceof Error ? err.message : String(err)),
};
}
if (path === null)
return { status: "unavailable", error: `${RUNTIME} is not installed in ${dir}` };
const error = await runtimeLoadError(path, timeoutMs, signal);
return error === null ? { status: "ready", path } : { status: "unavailable", error };
}
async function selectRuntime(
dir: string,
cliUrl: string,
timeoutMs = 60_000,
signal?: AbortSignal,
): Promise<RuntimeSelection> {
if (signal?.aborted) throw new DecodeCancelled("Parakeet runtime check cancelled");
const copy = pinnedPackageBesideCli(RUNTIME, cliUrl);
if (copy !== null) {
const path = copy.path;
const error = await runtimeLoadError(path, timeoutMs, signal);
if (error === null) return { status: "ready", path };
console.error(
`Parakeet runtime at ${path.replace(/\s+/g, " ")} did not load: ${error.replace(/\s+/g, " ")}`,
);
}
return cachedRuntime(dir, timeoutMs, signal);
}
/** Returns the load failure after checking the pinned copy beside the CLI, then its cache. */
export async function sherpaRuntimeLoadError(
dir = SHERPA_RUNTIME_DIR,
timeoutMs = 60_000,
): string | null {
if (!isInstalled(dir, RUNTIME)) return `${RUNTIME} is not installed in ${dir}`;
const env = { ...process.env, HF_SHERPA_MANIFEST: join(dir, "package.json") };
const probe = spawnSync(process.execPath, ["-e", LOAD_RUNTIME], {
env,
encoding: "utf8",
stdio: ["ignore", "ignore", "pipe"],
timeout: timeoutMs,
});
if (probe.status === 0) return null;
// Ctrl-C reaches the probe too: a cancel, never proof that a working runtime is broken.
if (stoppedByCancelSignal(probe)) throw new DecodeCancelled("Parakeet install cancelled");
const timedOut = (probe.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";
return bounded(
probe.stderr?.trim() ||
(timedOut ? `loading it timed out after ${timeoutMs / 1000} s` : probe.error?.message) ||
(probe.signal
? `loading it crashed (${probe.signal})`
: `it exited ${probe.status} with no output`),
);
cliUrl = import.meta.url,
signal?: AbortSignal,
): Promise<string | null> {
const selected = await selectRuntime(dir, cliUrl, timeoutMs, signal);
return selected.status === "ready" ? null : selected.error;
}
/** The native package, pinned too: the runtime's own optionalDependencies accept any 1.13.x. */
@@ -158,33 +234,41 @@ export function sherpaPlatformPackage(platform = process.platform, arch = proces
return `${nativePackageName(platform, arch)}@${RUNTIME_VERSION}`;
}
/** Installs the runtime unless it already loads; true when it installed. */
/** Installs if needed and returns the healthy entry selected for native child loading. */
export async function installSherpaRuntime({
run = runNpm,
signal,
dir = SHERPA_RUNTIME_DIR,
}: { run?: typeof runNpm; signal?: AbortSignal; dir?: string } = {}): Promise<boolean> {
if (sherpaRuntimeLoadError(dir) === null) return false;
cliUrl = import.meta.url,
}: {
run?: typeof runNpm;
signal?: AbortSignal;
dir?: string;
cliUrl?: string;
} = {}): Promise<{ installed: boolean; runtimePath: string }> {
const present = await selectRuntime(dir, cliUrl, 60_000, signal);
if (present.status === "ready") return { installed: false, runtimePath: present.path };
// install() keeps any dir holding the runtime manifest, so a broken one goes first.
rmSync(dir, { recursive: true, force: true });
const native = sherpaPlatformPackage();
await install(dir, RUNTIME, RUNTIME_VERSION, (args) => run([...args, native], signal));
const stillBroken = sherpaRuntimeLoadError(dir);
if (stillBroken) {
const selected = await cachedRuntime(dir, 60_000, signal);
if (selected.status === "unavailable") {
throw new Error(
`The sherpa-onnx runtime was reinstalled but still does not load (${stillBroken}). Use --engine whisper for now.`,
`The sherpa-onnx runtime was reinstalled but still does not load (${selected.error}). Use --engine whisper for now.`,
);
}
return true;
return { installed: true, runtimePath: selected.path };
}
/**
* Model sizes only (install verified the hashes). The runtime counts once its manifest is there, even
* if broken: transcribe must then fail with the repair, not quietly pick whisper.
*/
export function sherpaParakeetInstalled(): boolean {
export function sherpaParakeetInstalled(cliUrl = import.meta.url): boolean {
return (
isInstalled(SHERPA_RUNTIME_DIR, RUNTIME) &&
(pinnedPackageBesideCli(RUNTIME, cliUrl) !== null ||
isInstalled(SHERPA_RUNTIME_DIR, RUNTIME)) &&
PARAKEET_MODEL_FILES.every(
(f) =>
statSync(join(PARAKEET_MODEL_DIR, f.name), { throwIfNoEntry: false })?.size === f.bytes,
@@ -306,11 +390,15 @@ function failureReason(err: ExecFileException | null, stderr: string): string {
}
/** Decodes in a child process: onnxruntime aborts the whole process on some inputs, uncatchably. */
function decode(wavPath: string, signal: AbortSignal): Promise<SherpaWindow[]> {
function decode(
wavPath: string,
runtimePath: string,
signal: AbortSignal,
): Promise<SherpaWindow[]> {
const sourceMode = import.meta.url.endsWith(".ts");
const worker = new URL(sourceMode ? "./sherpaWorker.ts" : "./sherpaWorker.js", import.meta.url);
const args = [...(sourceMode ? ["--import", "tsx"] : []), fileURLToPath(worker)];
const input = { wavPath, runtimeDir: SHERPA_RUNTIME_DIR, config: recognizerConfig() };
const input = { wavPath, runtimePath, config: recognizerConfig() };
return new Promise((resolve, reject) => {
execFile(
process.execPath,
@@ -356,6 +444,8 @@ export async function transcribeWithSherpa(
options: {
signal: AbortSignal;
onProgress?: (message: string) => void;
runtimeDir?: string;
cliUrl?: string;
onEvent?: (event: TranscribeProgress) => void;
},
): Promise<TranscribeResult> {
@@ -366,7 +456,19 @@ export async function transcribeWithSherpa(
model: PARAKEET_MODEL_LABEL,
status: "started",
});
const windows = await decode(wavPath, options.signal);
if (options.signal.aborted) throw new DecodeCancelled("Transcription cancelled");
const selected = await selectRuntime(
options.runtimeDir ?? SHERPA_RUNTIME_DIR,
options.cliUrl ?? import.meta.url,
60_000,
options.signal,
);
if (selected.status === "unavailable") {
throw new Error(
`Parakeet runtime does not load (${selected.error}). Run hyperframes models install parakeet to repair it, or use --engine whisper.`,
);
}
const windows = await decode(wavPath, selected.path, options.signal);
const result = writeParakeetTranscript(dir, mergeWindowsToWords(windows));
options.onEvent?.({
type: "progress",
+13 -3
View File
@@ -41,7 +41,11 @@ module.exports = {
`;
function runWorker(runtimeDir: string, wavPath: string) {
const input = JSON.stringify({ wavPath, runtimeDir, config: {} });
const input = JSON.stringify({
wavPath,
runtimePath: join(runtimeDir, "node_modules", "sherpa-onnx-node", "index.js"),
config: {},
});
return new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve) => {
execFile(
process.execPath,
@@ -102,7 +106,11 @@ describe("sherpaWorker", () => {
"stops between windows once the CLI that spawned it is gone",
async () => {
const started = join(fakeRuntime(), "started");
const input = JSON.stringify({ wavPath: "long.wav", runtimeDir: root, config: {} });
const input = JSON.stringify({
wavPath: "long.wav",
runtimePath: join(root, "node_modules", "sherpa-onnx-node", "index.js"),
config: {},
});
// The shell waits for the first window, then exits, orphaning a 10-window decode.
const shell =
`"${process.execPath}" --import tsx "${WORKER}" >/dev/null 2>&1 & echo $!; ` +
@@ -136,6 +144,8 @@ describe("sherpaWorker", () => {
root = mkdtempSync(join(tmpdir(), "hf-sherpa-worker-"));
const { code, stderr } = await runWorker(root, "speech.wav");
expect(code).toBe(1);
expect(stderr).toContain(`${SHERPA_ERROR_PREFIX}sherpa-onnx-node is not installed in ${root}`);
expect(stderr).toContain(
`${SHERPA_ERROR_PREFIX}Cannot find module '${join(root, "node_modules", "sherpa-onnx-node", "index.js")}'`,
);
});
});
+3 -4
View File
@@ -1,4 +1,4 @@
import { loadInstalled } from "../utils/optionalPackages.js";
import { createRequire } from "node:module";
import {
droppedSpeechGaps,
silenceCuts,
@@ -22,7 +22,7 @@ interface SherpaOnnx {
};
}
const { wavPath, runtimeDir, config } = JSON.parse(process.env.HYPERFRAMES_PARAKEET_INPUT ?? "{}");
const { wavPath, runtimePath, config } = JSON.parse(process.env.HYPERFRAMES_PARAKEET_INPUT ?? "{}");
const parentPid = process.ppid;
/** Leading silence moves the frame grid; 0.5 s recovered the dropped clause at every length tried. */
@@ -46,8 +46,7 @@ function decodeWindow(
}
try {
const sherpa = loadInstalled(runtimeDir, "sherpa-onnx-node") as SherpaOnnx | null;
if (!sherpa) throw new Error(`sherpa-onnx-node is not installed in ${runtimeDir}`);
const sherpa = createRequire(import.meta.url)(runtimePath) as SherpaOnnx;
const recognizer = new sherpa.OfflineRecognizer(config);
const wave = sherpa.readWave(wavPath);
const cuts = silenceCuts(wave.samples, wave.sampleRate);