Skip to content
Source
Sign inSign up

speech-catalog.ts

140 lines4.6 KB

1import {
2 accuracyGrade,
3 canonicalLanguage,
4 compareEngineVersions,
5 BENCHMARK_DICTATION_SECONDS,
6 languageIdentity,
7 modelFile,
8 type CatalogData,
9} from "@handy-computer/models";
10import { format } from "vite-plus/fmt";
11​
12// ASR architectures registered in transcribe.cpp v0.4.1's src/transcribe-arch.cpp.
13const ASR_FAMILIES = new Set([
14 "parakeet",
15 "cohere_asr",
16 "canary",
17 "qwen3_asr",
18 "moss",
19 "voxtral",
20 "voxtral_realtime",
21 "canary_qwen",
22 "whisper",
23 "moonshine",
24 "moonshine_streaming",
25 "sensevoice",
26 "funasr_nano",
27 "gigaam",
28 "granite_speech",
29 "granite_speech_nar",
30 "granite5_ctc",
31 "medasr",
32]);
33​
34// Preserve Handy's onboarding badges; curation alone also includes specialist models.
35const RECOMMENDED = new Set([
36 "parakeet-unified-en-0.6b",
37 "nemotron-3.5-asr-streaming-0.6b",
38 "canary-180m-flash",
39 "cohere-transcribe-03-2026",
40 "whisper-medium",
41]);
42​
43// Handy labels these weights MIT; Moonshine lists them under its Community License at https://huggingface.co/moonshine-ai/moonshine-models.
44const COMMUNITY_LICENSED_MOONSHINE = /^moonshine-(?:(?:base|tiny)-(?:ar|ja|ko|uk|vi|zh)|base-es)$/;
45​
46export function generateSpeechCatalog(
47 data: CatalogData,
48 sourceVersion: string,
49 engineVersion: string,
50) {
51 if (data.schema !== 2) throw new Error("Unsupported speech catalog schema.");
52 if (engineVersion !== "0.4.1")
53 throw new Error("Review speech catalog loader support for this engine.");
54 const ids = new Set<string>();
55 const filenames = new Set<string>();
56 const models = data.models.flatMap((model) => {
57 if (
58 model.role !== "asr" ||
59 /(?:^|[^a-z0-9])nc(?:$|[^a-z0-9])/i.test(model.license) ||
60 COMMUNITY_LICENSED_MOONSHINE.test(model.id) ||
61 !ASR_FAMILIES.has(model.family) ||
62 compareEngineVersions(model.minEngine ?? data.transcribeCpp, engineVersion) > 0
63 )
64 return [];
65​
66 const file = modelFile(model);
67 // The store and native loader accept one root-level GGUF per model.
68 if (!/^[^/\\:]+\.gguf$/.test(file.filename) || file.filename.startsWith(".")) return [];
69 if (
70 !/^\w[\w.-]*\/\w[\w.-]*$/.test(model.repository) ||
71 !/^[a-f0-9]{40}$/.test(model.revision) ||
72 !/^[a-f0-9]{64}$/.test(file.sha256) ||
73 !Number.isSafeInteger(file.size) ||
74 file.size <= 0 ||
75 model.languages.length === 0
76 )
77 throw new Error(`Invalid speech catalog metadata: ${model.id}`);
78 if (ids.has(model.repository) || filenames.has(file.filename)) {
79 throw new Error(`Duplicate speech catalog model or filename: ${model.id}`);
80 }
81 ids.add(model.repository);
82 filenames.add(file.filename);
83 const languages = [...new Set(model.languages.map(canonicalLanguage))];
84 const performance = data.results[model.id]?.performance;
85 const secondsPer30s = (realtimeFactor: number | undefined) =>
86 realtimeFactor ? Number((BENCHMARK_DICTATION_SECONDS / realtimeFactor).toFixed(2)) : null;
87 const accuracy = Object.fromEntries(
88 languages.flatMap((language) => {
89 const cell = data.results[model.id]?.accuracy[languageIdentity(language)];
90 return cell
91 ? [
92 [
93 language,
94 { grade: accuracyGrade(cell.error).label, errorPercent: cell.error },
95 ] as const,
96 ]
97 : [];
98 }),
99 );
100 return [
101 {
102 id: model.repository,
103 revision: model.revision,
104 name: model.name,
105 description: model.description || `${model.name} speech-to-text.`,
106 filename: file.filename,
107 size: file.size,
108 sha256: file.sha256,
109 license: model.license,
110 // T3 selects base languages; the engine's prompt dictionary accepts their short aliases.
111 languages,
112 benchmark: {
113 accuracy,
114 secondsPer30s: {
115 cpu: secondsPer30s(performance?.cpu),
116 accelerated: secondsPer30s(performance?.accelerated),
117 },
118 },
119 recommended: RECOMMENDED.has(model.id),
120 supportsStreaming: model.capabilities.streaming,
121 supportsLanguageDetection: model.capabilities.languageDetection,
122 supportsTranslation: model.capabilities.translate,
123 },
124 ];
125 });
126 return { source: "@handy-computer/models", sourceVersion, engineVersion, models };
128​
129export async function renderSpeechCatalog(
130 data: CatalogData,
131 sourceVersion: string,
132 engineVersion: string,
133) {
134 const result = await format(
135 "catalog.json",
136 JSON.stringify(generateSpeechCatalog(data, sourceVersion, engineVersion)),
137 );
138 if (result.errors.length > 0) throw new Error("Could not format speech catalog.");
139 return result.code;