FN-8578: pin verified Parakeet v3 model asset
Enable secure downloads of the upstream Parakeet v3 voice model. - Pin the archive URL, filename, SHA-256, expected files, and nested extraction layout. - Accept the archive's BSD tar timestamp format while preserving safe extraction checks. - Cover download gates and installation behavior, and document the verified model asset. Files changed: .changeset/fn-8578-parakeet-v3-asset-pin.md | 7 ++++ docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 8 ++-- .../src/stt/__tests__/model-manager.test.ts | 46 ++++++++++++++++++++++ .../dashboard/src/stt/__tests__/voice-stt.test.ts | 33 +++++++++++++++- packages/dashboard/src/stt/model-manager.ts | 13 +++--- packages/dashboard/src/stt/types.ts | 18 +++++++-- 7 files changed, 114 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-8578 Fusion-Task-Lineage: ab397190-fa0c-491e-b0f8-0ab6641be95a Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8578-parakeet-v3-asset-pin.md
Normal file
7
.changeset/fn-8578-parakeet-v3-asset-pin.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Enable Parakeet v3 voice model downloads with a verified upstream checksum.
|
||||
category: feature
|
||||
dev: Pins sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2 at SHA-256 5793d0fd397c5778d2cf2126994d58e9d56b1be7c04d13c7a15bb1b4eafb16bf; strips its archive prefix and accepts its BSD year-only tar timestamps.
|
||||
@@ -40,7 +40,7 @@ Settings form changes save automatically after a short pause. The footer no long
|
||||
|
||||
## Voice Input
|
||||
|
||||
**Settings → Voice Input** is visible in both Basic and Advanced settings. Voice mode is off by default; enabling it is an explicit project preference. The same section shows the locally managed Parakeet v3 model and lets an operator download or remove it. Download progress is polled only while the model is downloading. When sherpa-onnx is unavailable, Settings preserves any saved enabled preference but presents voice mode as backend-enforced disabled with an explanation. If status cannot be determined, the section fails closed: voice mode stays disabled and model actions are not shown until status is available.
|
||||
**Settings → Voice Input** is visible in both Basic and Advanced settings. Voice mode is off by default; enabling it is an explicit project preference. The same section shows the locally managed Parakeet v3 model and lets an operator download or remove it. Its upstream `sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2` archive is about 465 MB and Fusion verifies its pinned SHA-256 before installing it; an unpinned or mismatched download is refused. Download progress is polled only while the model is downloading. When sherpa-onnx is unavailable, Settings preserves any saved enabled preference but presents voice mode as backend-enforced disabled with an explanation. If status cannot be determined, the section fails closed: voice mode stays disabled and model actions are not shown until status is available.
|
||||
|
||||
## Reset Settings
|
||||
|
||||
|
||||
@@ -53,9 +53,11 @@ See [Signals Connectors](./signals-connectors.md) for setup, signing, payload, a
|
||||
while disabled. `model` defaults to registry identifier `"parakeet-v3"` and `language` to `"en"`;
|
||||
unsupported values are rejected and never become URLs or paths. The optional sherpa runtime and
|
||||
user-scoped cache degrade to unavailable safely. Downloads are on demand and require a pinned SHA-256;
|
||||
unpinned assets refuse download. Status polling reports `queued`/`downloading`; deleting fences an
|
||||
in-flight download. Voice chunks alone allow 2 MiB JSON, use 16 kHz mono PCM, and sessions retain
|
||||
closed tombstones for 60 seconds (size-cap responses stay 413 before eviction).
|
||||
unpinned assets refuse download. The default asset is upstream `sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2`
|
||||
(~465 MB), verified against its pinned SHA-256 before installation. Status polling reports
|
||||
`queued`/`downloading`; deleting fences an in-flight download. Voice chunks alone allow 2 MiB JSON,
|
||||
use 16 kHz mono PCM, and sessions retain closed tombstones for 60 seconds (size-cap responses stay
|
||||
413 before eviction).
|
||||
|
||||
## Global Settings
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ const deferred = <T = void>() => { let resolve!: (value: T) => void; const promi
|
||||
const safeListing = "-rw-r--r-- root/root 6 2026-01-01 00:00 tokens.txt";
|
||||
|
||||
describe("voice model manager", () => {
|
||||
it.each([
|
||||
"-rw-r--r-- root/root 6 2026-01-01 00:00 tokens.txt",
|
||||
"-rw-r--r-- root wheel 6 Aug 16 12:34 2025 tokens.txt",
|
||||
"-rw-r--r-- 0 runner staff 93939 Aug 16 2025 tokens.txt",
|
||||
])("accepts supported tar timestamp formats: %s", (listing) => expect(parseSafeTarListing(listing)).toEqual({ safe: true }));
|
||||
|
||||
it.each([
|
||||
"lrwxrwxrwx root/root 0 2026-01-01 00:00 model -> /tmp/x",
|
||||
"hrwxrwxrwx root/root 0 2026-01-01 00:00 model hard link to other",
|
||||
@@ -33,6 +39,46 @@ describe("voice model manager", () => {
|
||||
expect(JSON.parse(await readFile(join(cacheDir, "model", "manifest.json"), "utf8"))).toMatchObject({ sha256: digest });
|
||||
});
|
||||
|
||||
it("promotes a stripped nested archive fixture with model files at the install root", async () => {
|
||||
const fixture = Buffer.from("nested Parakeet archive fixture");
|
||||
const nestedAsset = {
|
||||
url: "https://example.invalid/parakeet-nested.tar.bz2",
|
||||
filename: "parakeet-nested.tar.bz2",
|
||||
sha256: createHash("sha256").update(fixture).digest("hex"),
|
||||
expectedFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"],
|
||||
stripComponents: 1,
|
||||
};
|
||||
const cacheDir = await mkdtemp(join(tmpdir(), "voice-nested-model-"));
|
||||
const manager = createVoiceModelManager({
|
||||
cacheDir,
|
||||
asset: nestedAsset,
|
||||
fetch: vi.fn(async () => new Response(fixture)) as typeof fetch,
|
||||
listArchive: async () => [
|
||||
"drwxr-xr-x 0 runner staff 0 Aug 16 2025 sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/",
|
||||
"-rw-r--r-- 0 runner staff 1 Aug 16 2025 sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/encoder.int8.onnx",
|
||||
].join("\n"),
|
||||
// The extract seam represents tar invoked with this fixture asset's strip-components value.
|
||||
extract: async (_archive, staging) => Promise.all(nestedAsset.expectedFiles.map((file) => writeFile(join(staging, file), "model"))).then(() => undefined),
|
||||
});
|
||||
|
||||
await expect(manager.download()).resolves.toMatchObject({ status: "installed", checksumVerified: true });
|
||||
for (const expected of nestedAsset.expectedFiles) await expect(readFile(join(cacheDir, "model", expected), "utf8")).resolves.toBe("model");
|
||||
});
|
||||
|
||||
it("reports a nested archive missing an expected file as incomplete", async () => {
|
||||
const cacheDir = await mkdtemp(join(tmpdir(), "voice-incomplete-model-"));
|
||||
const incompleteAsset = { ...asset, expectedFiles: ["tokens.txt", "encoder.int8.onnx"], stripComponents: 1 };
|
||||
const manager = createVoiceModelManager({
|
||||
cacheDir,
|
||||
asset: incompleteAsset,
|
||||
fetch: vi.fn(async () => response()) as typeof fetch,
|
||||
listArchive: async () => "-rw-r--r-- 0 runner staff 6 Aug 16 2025 sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/tokens.txt",
|
||||
extract: async (_archive, staging) => writeFile(join(staging, "tokens.txt"), "tokens"),
|
||||
});
|
||||
|
||||
await expect(manager.download()).resolves.toMatchObject({ status: "error", errorReason: "incomplete-install" });
|
||||
});
|
||||
|
||||
it("reports a corrupt final model directory as incomplete instead of absent", async () => {
|
||||
const cacheDir = await mkdtemp(join(tmpdir(), "voice-model-"));
|
||||
await mkdir(join(cacheDir, "model"));
|
||||
|
||||
@@ -1,15 +1,46 @@
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createVoiceModelManager } from "../model-manager.js";
|
||||
import { createParakeetService } from "../parakeet-service.js";
|
||||
import { resolveVoiceLanguage, resolveVoiceModelId } from "../types.js";
|
||||
import { PARAKEET_V3_ASSET, resolveVoiceLanguage, resolveVoiceModelId } from "../types.js";
|
||||
|
||||
describe("voice STT graceful degradation", () => {
|
||||
it("pins a downloadable Parakeet v3 archive with its verified model files", () => {
|
||||
expect(PARAKEET_V3_ASSET).toMatchObject({
|
||||
filename: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2",
|
||||
url: expect.stringContaining("/releases/download/"),
|
||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
expectedFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"],
|
||||
stripComponents: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the pinned registry asset's pre-network download gate", async () => {
|
||||
const fetch = vi.fn(async () => { throw new Error("network must not be reached by this gate test"); });
|
||||
const manager = createVoiceModelManager({ cacheDir: await mkdtemp(join(tmpdir(), "voice-registry-")), fetch: fetch as typeof globalThis.fetch });
|
||||
|
||||
await expect(manager.getState()).resolves.toMatchObject({ status: "not-installed" });
|
||||
expect(manager.scheduleDownload()).toMatchObject({ accepted: true });
|
||||
});
|
||||
|
||||
it("refuses an unpinned archive synchronously without fetching", () => {
|
||||
const fetch = vi.fn();
|
||||
const manager = createVoiceModelManager({ cacheDir: "/unused", fetch, asset: { url: "https://example.test/model", filename: "model.tar", sha256: null, expectedFiles: [] } });
|
||||
expect(manager.scheduleDownload()).toMatchObject({ accepted: false, state: { status: "error", errorReason: "checksum-unpinned" } });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
it("reports a byte mismatch without installing an archive", async () => {
|
||||
const manager = createVoiceModelManager({
|
||||
cacheDir: await mkdtemp(join(tmpdir(), "voice-mismatch-")),
|
||||
asset: { url: "https://example.test/model", filename: "model.tar", sha256: "0".repeat(64), expectedFiles: [] },
|
||||
fetch: vi.fn(async () => new Response(Buffer.from("wrong bytes"))) as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
await expect(manager.download()).resolves.toMatchObject({ status: "error", errorReason: "checksum-mismatch", checksumVerified: false });
|
||||
});
|
||||
|
||||
it("keeps unknown model identifiers and languages out of runtime configuration", () => {
|
||||
expect(resolveVoiceModelId(undefined)).toEqual({ id: "parakeet-v3" });
|
||||
expect(resolveVoiceModelId("../unsafe")).toEqual({ unsupported: "../unsafe" });
|
||||
|
||||
@@ -32,10 +32,13 @@ export function parseSafeTarListing(listing: string): { safe: true } | { safe: f
|
||||
for (const line of listing.split("\n").filter(Boolean)) {
|
||||
const type = line[0];
|
||||
if (type !== "-" && type !== "d") return { safe: false, reason: "unsafe entry type" };
|
||||
// GNU uses YYYY-MM-DD HH:MM while BSD tar uses Mon DD HH:MM YYYY. Do not
|
||||
// guess a column offset: owner/group fields vary, and guessing could turn an
|
||||
// absolute pathname into a harmless-looking date suffix.
|
||||
const timestamp = /\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+\d{2}:\d{2}(?::\d{2})?\s+\d{4}\s+/.exec(line);
|
||||
/*
|
||||
* FNXC:VoiceInput 2026-07-25-00:00:
|
||||
* The pinned Parakeet archive uses BSD `Mon DD YYYY` metadata, in addition to GNU and
|
||||
* BSD-with-clock forms. Do not guess a column offset: owner/group fields vary, and guessing
|
||||
* could turn an absolute pathname into a harmless-looking date suffix.
|
||||
*/
|
||||
const timestamp = /\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+(?:\d{2}:\d{2}(?::\d{2})?\s+\d{4}|\d{4})\s+/.exec(line);
|
||||
if (!timestamp || timestamp.index === undefined) return { safe: false, reason: "unparseable tar metadata" };
|
||||
const path = line.slice(timestamp.index + timestamp[0].length);
|
||||
if (!path || /\s(?:->|link to|hard link to)\s/.test(path) || path.startsWith("/") || path.split(/[\\/]+/).includes("..")) return { safe: false, reason: "unsafe entry path" };
|
||||
@@ -95,7 +98,7 @@ export function createVoiceModelManager(options: VoiceModelManagerOptions = {}):
|
||||
: runTar(["-tvf", archive], signal);
|
||||
const extractArchive = async (archive: string, staging: string, signal: AbortSignal) => {
|
||||
if (options.extract) return options.extract(archive, staging, signal);
|
||||
await runTar(["--no-same-owner", "-xf", archive, "-C", staging], signal);
|
||||
await runTar(["--no-same-owner", "-x", `--strip-components=${asset.stripComponents ?? 0}`, "-f", archive, "-C", staging], signal);
|
||||
};
|
||||
const move = options.rename ?? rename;
|
||||
|
||||
|
||||
@@ -6,9 +6,21 @@ export type VoiceModelId = "parakeet-v3";
|
||||
export const DEFAULT_VOICE_MODEL_ID: VoiceModelId = "parakeet-v3";
|
||||
export const SUPPORTED_VOICE_LANGUAGES = ["en"] as const;
|
||||
export const DEFAULT_VOICE_LANGUAGE = "en";
|
||||
export interface VoiceModelAsset { url: string; filename: string; sha256: string | null; expectedFiles: string[]; }
|
||||
/** The upstream release page has no published digest for a concrete v3 archive yet. */
|
||||
export const PARAKEET_V3_ASSET: VoiceModelAsset = { url: "https://github.com/k2-fsa/sherpa-onnx/releases/tag/asr-models", filename: "upstream-pending-verification", sha256: null, expectedFiles: [] };
|
||||
export interface VoiceModelAsset { url: string; filename: string; sha256: string | null; expectedFiles: string[]; stripComponents?: number; }
|
||||
/**
|
||||
* FNXC:VoiceInput 2026-07-25-00:00:
|
||||
* Pin sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2 to the locally verified upstream
|
||||
* SHA-256 because GitHub's release API publishes no digest for this asset. The archive nests
|
||||
* one top-level directory, so extraction strips one component while the checksum gate remains
|
||||
* mandatory before any download can be installed.
|
||||
*/
|
||||
export const PARAKEET_V3_ASSET: VoiceModelAsset = {
|
||||
url: "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2",
|
||||
filename: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2",
|
||||
sha256: "5793d0fd397c5778d2cf2126994d58e9d56b1be7c04d13c7a15bb1b4eafb16bf",
|
||||
expectedFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"],
|
||||
stripComponents: 1,
|
||||
};
|
||||
export const VOICE_MODEL_REGISTRY: Record<VoiceModelId, VoiceModelAsset> = { "parakeet-v3": PARAKEET_V3_ASSET };
|
||||
export function resolveVoiceModelId(raw?: string): { id: VoiceModelId } | { unsupported: string } { return raw === undefined || raw === DEFAULT_VOICE_MODEL_ID ? { id: DEFAULT_VOICE_MODEL_ID } : { unsupported: raw }; }
|
||||
export function resolveVoiceLanguage(raw?: string): { language: string } | { unsupported: string } { return raw === undefined || raw === DEFAULT_VOICE_LANGUAGE ? { language: DEFAULT_VOICE_LANGUAGE } : { unsupported: raw }; }
|
||||
|
||||
Reference in New Issue
Block a user