Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"build:exe": "node scripts/cli.ts build-exe",
"start": "node dist/bin.mjs",
"typecheck": "tsc --noEmit",
"speech:catalog:sync": "node scripts/sync-speech-catalog.ts",
"speech:catalog:check": "node scripts/sync-speech-catalog.ts --check",
"test": "vp test run",
"record:codex-replay": "bun scripts/record-codex-app-server-replay-fixture.ts",
"record:claude-replay": "bun scripts/record-claude-agent-sdk-replay-fixture.ts",
Expand Down Expand Up @@ -79,6 +81,7 @@
"devDependencies": {
"@effect/graphql-generator": "catalog:",
"@effect/vitest": "catalog:",
"@handy-computer/models": "0.2.3",
"@opencode-ai/sdk": "^1.3.15",
"@t3tools/contracts": "workspace:*",
"@t3tools/provider-testing": "workspace:*",
Expand Down
87 changes: 87 additions & 0 deletions apps/server/scripts/speech-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// @effect-diagnostics nodeBuiltinImport:off - compares the generated bundle directly with its checked-in bytes.
import * as NodeFS from "node:fs";

import { catalog } from "@handy-computer/models";
import { expect, it } from "vite-plus/test";

import manifest from "../package.json" with { type: "json" };
import { generateSpeechCatalog, renderSpeechCatalog } from "./speech-catalog.ts";

const generate = (models = catalog.models) =>
generateSpeechCatalog(
{ ...catalog.data, models },
manifest.devDependencies["@handy-computer/models"],
manifest.dependencies["transcribe-cpp"],
);

it("reproduces the bundled registry byte for byte without upstream requests", async () => {
expect(NodeFS.readFileSync(new URL("../src/speech/catalog.json", import.meta.url), "utf8")).toBe(
await renderSpeechCatalog(
catalog.data,
manifest.devDependencies["@handy-computer/models"],
manifest.dependencies["transcribe-cpp"],
),
);
for (const model of generate().models) {
const source = catalog.models.find((entry) => entry.repository === model.id)!;
expect(model).toMatchObject({
revision: source.revision,
name: source.name,
...source.files[source.quant],
supportsStreaming: source.capabilities.streaming,
supportsLanguageDetection: source.capabilities.languageDetection,
supportsTranslation: source.capabilities.translate,
});
}
});

it("filters roles, explicit NC licenses, future engines, unknown architectures and unsupported files", () => {
const model = catalog.models[0]!;
for (const override of [
{ role: "langid" as const },
{ role: "vad" as const },
{ role: "diarize" as const },
{ license: "cc-by-nc-4.0" },
{ license: "CC-BY-NC-SA-4.0" },
{ license: "cc-by-nc-nd-3.0" },
{ minEngine: "0.4.2" },
{ minEngine: "0.5.0" },
{ family: "future_arch" },
...[
"nested/model.gguf",
"nested\\model.gguf",
"../model.gguf",
"model.bin",
"model.safetensors",
].map((filename) => ({ files: { [model.quant]: { ...model.files[model.quant]!, filename } } })),
]) {
expect(generate([{ ...model, ...override }]).models).toEqual([]);
}
for (const license of ["other", "apache-2.0", "cc-by-4.0", "cc-by-sa-4.0"]) {
expect(generate([{ ...model, license, minEngine: "0.4.1" }]).models).toHaveLength(1);
}
});

it("rejects missing defaults, unpinned or corrupt metadata, collisions, and unreviewed engine/schema changes", () => {
const model = catalog.models[0]!;
const file = model.files[model.quant]!;
for (const override of [
{ files: {} },
{ revision: "main" },
{ repository: "../model" },
{ languages: [] },
...[{ sha256: "bad" }, { size: 0 }, { size: 1.5 }].map((entry) => ({
files: { [model.quant]: { ...file, ...entry } },
})),
]) {
expect(() => generate([{ ...model, ...override }])).toThrow();
}
expect(() => generate([model, model])).toThrow(/Duplicate/);
expect(() => generate([model, { ...model, repository: "handy-computer/another" }])).toThrow(
/Duplicate/,
);
expect(() => generateSpeechCatalog(catalog.data, "0.2.3", "0.4.2")).toThrow(/loader support/);
expect(() =>
generateSpeechCatalog({ ...catalog.data, schema: 3 as 2 }, "0.2.3", "0.4.1"),
).toThrow(/schema/);
});
109 changes: 109 additions & 0 deletions apps/server/scripts/speech-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
canonicalLanguage,
compareEngineVersions,
modelFile,
type CatalogData,
} from "@handy-computer/models";
import { format } from "vite-plus/fmt";

// ASR architectures registered in transcribe.cpp v0.4.1's src/transcribe-arch.cpp.
const ASR_FAMILIES = new Set([
"parakeet",
"cohere_asr",
"canary",
"qwen3_asr",
"moss",
"voxtral",
"voxtral_realtime",
"canary_qwen",
"whisper",
"moonshine",
"moonshine_streaming",
"sensevoice",
"funasr_nano",
"gigaam",
"granite_speech",
"granite_speech_nar",
"granite5_ctc",
"medasr",
]);

// Preserve Handy's onboarding badges; curation alone also includes specialist models.
const RECOMMENDED = new Set([
"parakeet-unified-en-0.6b",
"nemotron-3.5-asr-streaming-0.6b",
"canary-180m-flash",
"cohere-transcribe-03-2026",
"whisper-medium",
]);

export function generateSpeechCatalog(
data: CatalogData,
sourceVersion: string,
engineVersion: string,
) {
if (data.schema !== 2) throw new Error("Unsupported speech catalog schema.");
if (engineVersion !== "0.4.1")
throw new Error("Review speech catalog loader support for this engine.");
const ids = new Set<string>();
const filenames = new Set<string>();
const models = data.models.flatMap((model) => {
if (
model.role !== "asr" ||
/^cc-by-nc(?:-sa|-nd)?-\d+\.\d+$/i.test(model.license.trim()) ||
!ASR_FAMILIES.has(model.family) ||
compareEngineVersions(model.minEngine ?? data.transcribeCpp, engineVersion) > 0
)
return [];

const file = modelFile(model);
// The store and native loader accept one root-level GGUF per model.
if (!/^[^/\\:]+\.gguf$/.test(file.filename) || file.filename.startsWith(".")) return [];
if (
!/^\w[\w.-]*\/\w[\w.-]*$/.test(model.repository) ||
!/^[a-f0-9]{40}$/.test(model.revision) ||
!/^[a-f0-9]{64}$/.test(file.sha256) ||
!Number.isSafeInteger(file.size) ||
file.size <= 0 ||
model.languages.length === 0
)
throw new Error(`Invalid speech catalog metadata: ${model.id}`);
if (ids.has(model.repository) || filenames.has(file.filename)) {
throw new Error(`Duplicate speech catalog model or filename: ${model.id}`);
}
ids.add(model.repository);
filenames.add(file.filename);
return [
{
id: model.repository,
revision: model.revision,
name: model.name,
description: model.description || `${model.name} speech-to-text.`,
filename: file.filename,
size: file.size,
sha256: file.sha256,
license: model.license,
// T3 selects base languages; the engine's prompt dictionary accepts their short aliases.
languages: [...new Set(model.languages.map(canonicalLanguage))],
recommended: RECOMMENDED.has(model.id),
supportsStreaming: model.capabilities.streaming,
supportsLanguageDetection: model.capabilities.languageDetection,
supportsTranslation: model.capabilities.translate,
},
];
});
return { source: "@handy-computer/models", sourceVersion, engineVersion, models };
}

export async function renderSpeechCatalog(
data: CatalogData,
sourceVersion: string,
engineVersion: string,
) {
const result = await format(
"catalog.json",
JSON.stringify(generateSpeechCatalog(data, sourceVersion, engineVersion)),
);
if (result.errors.length > 0) throw new Error("Could not format speech catalog.");
return result.code;
}
35 changes: 35 additions & 0 deletions apps/server/scripts/sync-speech-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// @effect-diagnostics nodeBuiltinImport:off - standalone catalog generation uses synchronous Node I/O.
// @effect-diagnostics globalConsole:off - standalone maintenance command output.
import * as NodeFS from "node:fs";
import * as NodeModule from "node:module";
import * as NodePath from "node:path";

import { catalog } from "@handy-computer/models";
import * as Schema from "effect/Schema";

import manifest from "../package.json" with { type: "json" };
import { renderSpeechCatalog } from "./speech-catalog.ts";

const require = NodeModule.createRequire(import.meta.url);
const source = require.resolve("@handy-computer/models/catalog.json");
const sourceManifest = Schema.decodeSync(
Schema.fromJsonString(Schema.Struct({ version: Schema.String })),
)(NodeFS.readFileSync(NodePath.join(NodePath.dirname(source), "package.json"), "utf8"));
if (sourceManifest.version !== manifest.devDependencies["@handy-computer/models"]) {
throw new Error("Install the exact pinned @handy-computer/models version before generating.");
}
const target = new URL("../src/speech/catalog.json", import.meta.url);
const expected = await renderSpeechCatalog(
catalog.data,
sourceManifest.version,
manifest.dependencies["transcribe-cpp"],
);
if (process.argv.includes("--check")) {
if (NodeFS.readFileSync(target, "utf8") !== expected) {
throw new Error("Speech catalog differs; run vp run speech:catalog:sync in apps/server.");
}
console.log("Bundled speech catalog matches the pinned source.");
} else {
NodeFS.writeFileSync(target, expected);
console.log(`Generated speech catalog from @handy-computer/models@${sourceManifest.version}.`);
}
29 changes: 17 additions & 12 deletions apps/server/src/speech/SpeechModelStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as Stream from "effect/Stream";
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";

import * as ServerConfig from "../config.ts";
import type { SpeechModel } from "./model.ts";
import { getSpeechModel, type SpeechModel } from "./model.ts";
import * as SpeechModelStore from "./SpeechModelStore.ts";

const bytes = Uint8Array.from({ length: 64 }, (_, index) => (index * 7) % 256);
Expand All @@ -22,20 +22,10 @@ const SPLIT = 20;
const harness = Effect.gen(function* () {
const digest = yield* Digest.Digest;
const model: SpeechModel = {
id: "test/model",
revision: "rev",
name: "Test",
description: "test",
...getSpeechModel("handy-computer/whisper-tiny-gguf")!,
filename: "model.gguf",
size: bytes.length,
sha256: Hex.encode(yield* digest.digest(Digest.sha256, bytes).pipe(Effect.orDie)),
languages: ["en"],
accuracy: 1,
speed: 1,
recommended: false,
supportsStreaming: false,
supportsLanguageDetection: false,
supportsTranslation: false,
};
const config = yield* ServerConfig.ServerConfig;
const path = yield* Path.Path;
Expand Down Expand Up @@ -119,6 +109,10 @@ it.effect("resumes an interrupted transfer, replaces a corrupt same-size file, a
undefined,
`bytes=${SPLIT}-`,
]);
expect(h.requests.map((request) => request.url)).toEqual([
"https://huggingface.co/handy-computer/whisper-tiny-gguf/resolve/6687f30c99641ee265df421e582354adbc8848fc/model.gguf",
"https://huggingface.co/handy-computer/whisper-tiny-gguf/resolve/6687f30c99641ee265df421e582354adbc8848fc/model.gguf",
]);
expect(progress.at(0)).toBe(0);
expect(progress.at(-1)).toBe(bytes.length);
expect([...(yield* h.fs.readFile(installed))]).toEqual([...bytes]);
Expand All @@ -131,6 +125,17 @@ it.effect("resumes an interrupted transfer, replaces a corrupt same-size file, a
}).pipe(Effect.provide(dependencies)),
);

it.effect("rejects a same-size download whose SHA-256 differs from the catalog", () =>
Effect.gen(function* () {
const h = yield* harness;
h.bodies.current = () => Stream.succeed(new Uint8Array(bytes.length));
const result = yield* Effect.result(h.store.download(h.model));
expect(result).toMatchObject({ _tag: "Failure", failure: { reason: "verification" } });
expect(yield* h.fs.readDirectory(h.directory)).toEqual(["model.gguf"]);
expect(yield* h.store.installedPath(h.model)).toBeUndefined();
}).pipe(Effect.provide(dependencies)),
);

it.effect("cancelling a download removes the partial file and keeps the existing model", () =>
Effect.gen(function* () {
const h = yield* harness;
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/speech/SpeechService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,27 @@ it.effect("prepares a batch model before audio arrives and reuses it for transcr
}).pipe(Effect.provide(layer)),
);

it.effect("rejects model mutations outside the offered registry", () =>
Effect.gen(function* () {
const speech = yield* SpeechService.SpeechService;
for (const id of [
"handy-computer/canary-1b-gguf",
"handy-computer/granite-speech-5.0-470m-turboctc-nc-gguf",
"unknown-model",
]) {
for (const mutation of [speech.downloadModel, speech.selectModel, speech.removeModel]) {
const result = yield* Effect.result(mutation(id));
expect(Result.isFailure(result) && result.failure).toMatchObject({
_tag: "SpeechModelNotFoundError",
modelId: id,
});
}
}
expect(download).not.toHaveBeenCalled();
expect((yield* speech.models).models.find((model) => model.active)?.id).toBe("test-model");
}).pipe(Effect.provide(layer)),
);

it.effect("keeps a detached download exclusive until the transfer finishes", () =>
Effect.gen(function* () {
const speech = yield* SpeechService.SpeechService;
Expand Down
Loading
Loading