@drakulavich/parakeet-cli 0.7.4 → 0.8.0
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/README.md +1 -1
- package/bin/parakeet.js +3 -1
- package/package.json +5 -3
- package/src/audio.ts +4 -1
- package/src/cli.ts +145 -46
- package/src/coreml-install.ts +5 -3
- package/src/lib.ts +1 -1
- package/src/onnx-install.ts +11 -19
- package/src/progress.ts +80 -0
- package/src/status.ts +125 -0
- package/src/transcribe.ts +10 -1
- package/src/__tests__/audio.test.ts +0 -36
- package/src/__tests__/benchmark-report.test.ts +0 -67
- package/src/__tests__/coreml-install.test.ts +0 -281
- package/src/__tests__/coreml.test.ts +0 -24
- package/src/__tests__/decoder.test.ts +0 -50
- package/src/__tests__/lib.test.ts +0 -8
- package/src/__tests__/tokenizer.test.ts +0 -41
package/README.md
CHANGED
package/bin/parakeet.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/parakeet-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Fast local speech-to-text CLI. CoreML on Apple Silicon, ONNX on CPU. 25 languages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
],
|
|
21
21
|
"scripts": {
|
|
22
22
|
"test": "bun test",
|
|
23
|
-
"test:unit": "bun test
|
|
23
|
+
"test:unit": "bun test tests/unit/",
|
|
24
24
|
"test:integration": "bun test tests/integration/"
|
|
25
25
|
},
|
|
26
26
|
"keywords": [
|
|
@@ -53,7 +53,9 @@
|
|
|
53
53
|
"typescript": "^6.0.2"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
+
"citty": "^0.2.2",
|
|
56
57
|
"onnxruntime-node": "^1.24.0",
|
|
57
|
-
"picocolors": "^1.1.1"
|
|
58
|
+
"picocolors": "^1.1.1",
|
|
59
|
+
"tinyld": "^1.3.4"
|
|
58
60
|
}
|
|
59
61
|
}
|
package/src/audio.ts
CHANGED
|
@@ -55,7 +55,10 @@ async function convertAudioWithFfmpeg(
|
|
|
55
55
|
]);
|
|
56
56
|
|
|
57
57
|
if (exitCode !== 0) {
|
|
58
|
-
|
|
58
|
+
const lastLine = stderr.trim().split("\n").pop() ?? "unknown error";
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Audio conversion failed: ${lastLine}\n File: ${inputPath}\n Fix: Ensure the file is a valid audio format. Run "ffmpeg -i ${inputPath}" to diagnose.`,
|
|
61
|
+
);
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
return tmpPath;
|
package/src/cli.ts
CHANGED
|
@@ -1,60 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
+
import { defineCommand, runMain } from "citty";
|
|
4
|
+
import { detect } from "tinyld";
|
|
3
5
|
import { transcribe } from "./lib";
|
|
4
6
|
import { downloadModel } from "./onnx-install";
|
|
5
7
|
import { downloadCoreML } from "./coreml-install";
|
|
6
8
|
import { isMacArm64 } from "./coreml";
|
|
7
9
|
import { log } from "./log";
|
|
10
|
+
import { showStatus } from "./status";
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
|
|
14
|
-
console.log(pkg.version);
|
|
15
|
-
process.exit(0);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const positional = args.filter((a) => !a.startsWith("--"));
|
|
19
|
-
|
|
20
|
-
if (positional[0] === "install") {
|
|
21
|
-
const noCache = args.includes("--no-cache");
|
|
22
|
-
const forceCoreML = args.includes("--coreml");
|
|
23
|
-
const forceOnnx = args.includes("--onnx");
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
if (forceCoreML) {
|
|
27
|
-
if (!isMacArm64()) {
|
|
28
|
-
log.error("CoreML backend is only available on macOS Apple Silicon.");
|
|
29
|
-
process.exit(1);
|
|
30
|
-
}
|
|
31
|
-
await downloadCoreML(noCache);
|
|
32
|
-
} else if (forceOnnx) {
|
|
33
|
-
await downloadModel(noCache);
|
|
34
|
-
} else if (isMacArm64()) {
|
|
35
|
-
await downloadCoreML(noCache);
|
|
36
|
-
} else {
|
|
37
|
-
await downloadModel(noCache);
|
|
38
|
-
}
|
|
39
|
-
} catch (err: unknown) {
|
|
40
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
41
|
-
log.error(message);
|
|
42
|
-
process.exit(1);
|
|
43
|
-
}
|
|
44
|
-
process.exit(0);
|
|
45
|
-
}
|
|
12
|
+
export function detectLanguage(text: string): string {
|
|
13
|
+
if (!text) return "";
|
|
14
|
+
return detect(text);
|
|
15
|
+
}
|
|
46
16
|
|
|
47
|
-
|
|
17
|
+
export function checkLanguageMismatch(expected: string | undefined, detected: string): string | null {
|
|
18
|
+
if (!expected || !detected || expected === detected) return null;
|
|
19
|
+
return `warning: expected language "${expected}" but detected "${detected}"`;
|
|
20
|
+
}
|
|
48
21
|
|
|
49
|
-
|
|
50
|
-
log.info("Usage: parakeet [--version] <audio_file>");
|
|
51
|
-
log.info(" parakeet install [--coreml | --onnx] [--no-cache]");
|
|
52
|
-
process.exit(1);
|
|
53
|
-
}
|
|
22
|
+
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
|
|
54
23
|
|
|
24
|
+
async function performInstall(options: { coreml: boolean; onnx: boolean; noCache: boolean }) {
|
|
25
|
+
const { coreml, onnx, noCache } = options;
|
|
55
26
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
27
|
+
if (coreml) {
|
|
28
|
+
if (!isMacArm64()) {
|
|
29
|
+
log.error("CoreML backend is only available on macOS Apple Silicon.");
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
await downloadCoreML(noCache);
|
|
33
|
+
} else if (onnx) {
|
|
34
|
+
await downloadModel(noCache);
|
|
35
|
+
} else if (isMacArm64()) {
|
|
36
|
+
await downloadCoreML(noCache);
|
|
37
|
+
} else {
|
|
38
|
+
await downloadModel(noCache);
|
|
39
|
+
}
|
|
58
40
|
} catch (err: unknown) {
|
|
59
41
|
const message = err instanceof Error ? err.message : String(err);
|
|
60
42
|
log.error(message);
|
|
@@ -62,4 +44,121 @@ async function main(): Promise<void> {
|
|
|
62
44
|
}
|
|
63
45
|
}
|
|
64
46
|
|
|
65
|
-
|
|
47
|
+
export const installCommand = defineCommand({
|
|
48
|
+
meta: {
|
|
49
|
+
name: "install",
|
|
50
|
+
description: "Download speech-to-text models",
|
|
51
|
+
},
|
|
52
|
+
args: {
|
|
53
|
+
coreml: {
|
|
54
|
+
type: "boolean",
|
|
55
|
+
description: "Force CoreML backend (macOS arm64)",
|
|
56
|
+
default: false,
|
|
57
|
+
},
|
|
58
|
+
onnx: {
|
|
59
|
+
type: "boolean",
|
|
60
|
+
description: "Force ONNX backend",
|
|
61
|
+
default: false,
|
|
62
|
+
},
|
|
63
|
+
"no-cache": {
|
|
64
|
+
type: "boolean",
|
|
65
|
+
description: "Re-download even if cached",
|
|
66
|
+
default: false,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
async run({ args }) {
|
|
70
|
+
await performInstall({ coreml: args.coreml, onnx: args.onnx, noCache: args["no-cache"] });
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const mainCommand = defineCommand({
|
|
75
|
+
meta: {
|
|
76
|
+
name: "parakeet",
|
|
77
|
+
version: pkg.version,
|
|
78
|
+
description:
|
|
79
|
+
"Fast local speech-to-text. 25 languages. CoreML on Apple Silicon, ONNX on CPU.\n" +
|
|
80
|
+
" Run 'parakeet install [--coreml | --onnx] [--no-cache]' to download models.",
|
|
81
|
+
},
|
|
82
|
+
args: {
|
|
83
|
+
json: {
|
|
84
|
+
type: "boolean",
|
|
85
|
+
description: "Output results as JSON",
|
|
86
|
+
default: false,
|
|
87
|
+
},
|
|
88
|
+
lang: {
|
|
89
|
+
type: "string",
|
|
90
|
+
description: "Expected language code (ISO 639-1), warn if mismatch",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
async run({ args }) {
|
|
94
|
+
const positional = args._ as string[];
|
|
95
|
+
|
|
96
|
+
// Manual subcommand routing: "parakeet install [flags]"
|
|
97
|
+
if (positional[0] === "install") {
|
|
98
|
+
const argv = process.argv;
|
|
99
|
+
const coreml = argv.includes("--coreml");
|
|
100
|
+
const onnx = argv.includes("--onnx");
|
|
101
|
+
const noCache = argv.includes("--no-cache");
|
|
102
|
+
await performInstall({ coreml, onnx, noCache });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (positional[0] === "status") {
|
|
107
|
+
await showStatus();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const files = positional;
|
|
112
|
+
|
|
113
|
+
if (files.length === 0) {
|
|
114
|
+
log.info("Usage: parakeet <audio_file> [audio_file ...]\n parakeet install [--coreml | --onnx] [--no-cache]\n parakeet status");
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let hasError = false;
|
|
119
|
+
const results: TranscribeResult[] = [];
|
|
120
|
+
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
try {
|
|
123
|
+
const text = await transcribe(file);
|
|
124
|
+
const lang = detectLanguage(text);
|
|
125
|
+
|
|
126
|
+
const mismatchWarning = checkLanguageMismatch(args.lang, lang);
|
|
127
|
+
if (mismatchWarning) log.warn(`${file}: ${mismatchWarning}`);
|
|
128
|
+
|
|
129
|
+
results.push({ file, text, lang });
|
|
130
|
+
} catch (err: unknown) {
|
|
131
|
+
hasError = true;
|
|
132
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
133
|
+
log.error(`${file}: ${message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (args.json) {
|
|
138
|
+
process.stdout.write(formatJsonOutput(results));
|
|
139
|
+
} else {
|
|
140
|
+
process.stdout.write(formatTextOutput(results));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (hasError) process.exit(1);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export type TranscribeResult = { file: string; text: string; lang: string };
|
|
148
|
+
|
|
149
|
+
export function formatTextOutput(results: TranscribeResult[]): string {
|
|
150
|
+
if (results.length === 1) {
|
|
151
|
+
return results[0].text + "\n";
|
|
152
|
+
}
|
|
153
|
+
return results
|
|
154
|
+
.map((r, i) => (i > 0 ? "\n" : "") + `=== ${r.file} ===\n${r.text}\n`)
|
|
155
|
+
.join("");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function formatJsonOutput(results: TranscribeResult[]): string {
|
|
159
|
+
return JSON.stringify(results, null, 2) + "\n";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (import.meta.main) {
|
|
163
|
+
runMain(mainCommand);
|
|
164
|
+
}
|
package/src/coreml-install.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from "os";
|
|
|
3
3
|
import { existsSync, mkdirSync, chmodSync } from "fs";
|
|
4
4
|
import { getCoreMLBinPath } from "./coreml";
|
|
5
5
|
import { log } from "./log";
|
|
6
|
+
import { streamResponseToFile } from "./progress";
|
|
6
7
|
|
|
7
8
|
const COREML_BINARY_NAME = "parakeet-coreml-darwin-arm64";
|
|
8
9
|
const GITHUB_REPO = "drakulavich/parakeet-cli";
|
|
@@ -176,7 +177,9 @@ async function fetchCoreMLBinary(): Promise<Response> {
|
|
|
176
177
|
res = await fetch(versionUrl, { redirect: "follow" });
|
|
177
178
|
|
|
178
179
|
if (!res.ok) {
|
|
179
|
-
throw new Error(
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Failed to download CoreML binary (HTTP ${res.status})\n No release found matching ${COREML_BINARY_NAME}\n Fix: Check https://github.com/drakulavich/parakeet-cli/releases for available versions\n Or install the ONNX backend instead: parakeet install --onnx`,
|
|
182
|
+
);
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
return res;
|
|
@@ -232,10 +235,9 @@ export async function downloadCoreML(
|
|
|
232
235
|
}
|
|
233
236
|
|
|
234
237
|
if (plan.downloadBinary) {
|
|
235
|
-
log.progress("Downloading parakeet-coreml binary...");
|
|
236
238
|
const res = await fetchCoreMLBinary();
|
|
237
239
|
mkdirSync(dirname(binPath), { recursive: true });
|
|
238
|
-
await
|
|
240
|
+
await streamResponseToFile(res, binPath, "parakeet-coreml binary");
|
|
239
241
|
chmodSync(binPath, 0o755);
|
|
240
242
|
}
|
|
241
243
|
|
package/src/lib.ts
CHANGED
package/src/onnx-install.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { join } from "path";
|
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { existsSync, mkdirSync } from "fs";
|
|
4
4
|
import { log } from "./log";
|
|
5
|
+
import { streamResponseToFile } from "./progress";
|
|
5
6
|
|
|
6
7
|
export const HF_REPO = "istupakov/parakeet-tdt-0.6b-v3-onnx";
|
|
7
8
|
|
|
@@ -61,36 +62,27 @@ export async function downloadModel(noCache = false, modelDir?: string): Promise
|
|
|
61
62
|
|
|
62
63
|
if (!noCache && existsSync(dest)) continue;
|
|
63
64
|
|
|
64
|
-
log.progress(`Downloading ${file}...`);
|
|
65
|
-
|
|
66
65
|
let res: Response;
|
|
67
66
|
try {
|
|
68
67
|
res = await fetch(url, { redirect: "follow" });
|
|
69
68
|
} catch (e) {
|
|
70
|
-
throw new Error(
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Failed to fetch ${file}: ${e instanceof Error ? e.message : e}\n Fix: Check your network connection and try again`,
|
|
71
|
+
);
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
if (!res.ok) {
|
|
74
|
-
throw new Error(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (!res.body) {
|
|
78
|
-
throw new Error(`empty response body for ${file}`);
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Failed to download ${file}: HTTP ${res.status}\n Fix: Check your network connection or try again with --no-cache`,
|
|
77
|
+
);
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
const
|
|
82
|
-
let bytes = 0;
|
|
83
|
-
try {
|
|
84
|
-
for await (const chunk of res.body) {
|
|
85
|
-
writer.write(chunk);
|
|
86
|
-
bytes += chunk.length;
|
|
87
|
-
}
|
|
88
|
-
} finally {
|
|
89
|
-
writer.end();
|
|
90
|
-
}
|
|
80
|
+
const bytes = await streamResponseToFile(res, dest, file);
|
|
91
81
|
|
|
92
82
|
if (bytes === 0) {
|
|
93
|
-
throw new Error(
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Downloaded 0 bytes for ${file}\n Fix: Try again — the server may be temporarily unavailable`,
|
|
85
|
+
);
|
|
94
86
|
}
|
|
95
87
|
}
|
|
96
88
|
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { log } from "./log";
|
|
2
|
+
|
|
3
|
+
const BAR_WIDTH = 20;
|
|
4
|
+
|
|
5
|
+
export function formatBytes(bytes: number): string {
|
|
6
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatProgressBar(label: string, downloaded: number, total: number): string {
|
|
10
|
+
const pct = total <= 0 ? 0 : Math.min(100, Math.floor((downloaded / total) * 100));
|
|
11
|
+
const filled = Math.round((pct / 100) * BAR_WIDTH);
|
|
12
|
+
const empty = BAR_WIDTH - filled;
|
|
13
|
+
const bar = "█".repeat(filled) + "░".repeat(empty);
|
|
14
|
+
return `${label} [${bar}] ${pct}% ${formatBytes(downloaded)}/${formatBytes(total)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function streamResponseToFile(
|
|
18
|
+
res: Response,
|
|
19
|
+
destPath: string,
|
|
20
|
+
label: string,
|
|
21
|
+
): Promise<number> {
|
|
22
|
+
if (!res.body) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Download failed: empty response for ${label}\n Fix: Try again — the server may be temporarily unavailable`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const totalBytes = Number(res.headers.get("content-length") || 0);
|
|
29
|
+
const progress = createProgressBar(label, totalBytes);
|
|
30
|
+
|
|
31
|
+
const writer = Bun.file(destPath).writer();
|
|
32
|
+
let bytes = 0;
|
|
33
|
+
try {
|
|
34
|
+
for await (const chunk of res.body) {
|
|
35
|
+
writer.write(chunk);
|
|
36
|
+
bytes += chunk.length;
|
|
37
|
+
progress.update(chunk.length);
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
writer.end();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
progress.finish();
|
|
44
|
+
return bytes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createProgressBar(label: string, totalBytes: number): {
|
|
48
|
+
update(downloadedBytes: number): void;
|
|
49
|
+
finish(): void;
|
|
50
|
+
} {
|
|
51
|
+
const isTTY = process.stderr.isTTY;
|
|
52
|
+
|
|
53
|
+
if (!isTTY || totalBytes <= 0) {
|
|
54
|
+
const sizeInfo = totalBytes > 0 ? ` (${formatBytes(totalBytes)})` : "";
|
|
55
|
+
log.progress(`Downloading ${label}${sizeInfo}...`);
|
|
56
|
+
return {
|
|
57
|
+
update() {},
|
|
58
|
+
finish() {
|
|
59
|
+
log.success(`Downloaded ${label} ✓`);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let current = 0;
|
|
65
|
+
let lastPct = -1;
|
|
66
|
+
return {
|
|
67
|
+
update(downloadedBytes: number) {
|
|
68
|
+
current += downloadedBytes;
|
|
69
|
+
const pct = totalBytes > 0 ? Math.floor((current / totalBytes) * 100) : 0;
|
|
70
|
+
if (pct === lastPct) return;
|
|
71
|
+
lastPct = pct;
|
|
72
|
+
const line = formatProgressBar(label, current, totalBytes);
|
|
73
|
+
process.stderr.write(`\r${line}`);
|
|
74
|
+
},
|
|
75
|
+
finish() {
|
|
76
|
+
const line = formatProgressBar(label, totalBytes, totalBytes);
|
|
77
|
+
process.stderr.write(`\r${line}\n`);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { isMacArm64, getCoreMLBinPath } from "./coreml";
|
|
2
|
+
import { isModelCached, getModelDir } from "./onnx-install";
|
|
3
|
+
import { getCoreMLInstallState, getCoreMLInstallStatus, getCoreMLSupportDir, type CoreMLInstallState } from "./coreml-install";
|
|
4
|
+
import { log } from "./log";
|
|
5
|
+
import pc from "picocolors";
|
|
6
|
+
|
|
7
|
+
export function formatStatusLine(
|
|
8
|
+
label: string,
|
|
9
|
+
path: string | null,
|
|
10
|
+
installed: boolean,
|
|
11
|
+
missingLabel = "not installed",
|
|
12
|
+
): string {
|
|
13
|
+
const status = installed ? pc.green("✓") : pc.red(`✗ ${missingLabel}`);
|
|
14
|
+
const pathStr = path ?? "";
|
|
15
|
+
const padding = " ".repeat(Math.max(1, 50 - label.length - pathStr.length));
|
|
16
|
+
return ` ${label}:${pathStr ? ` ${pathStr}` : ""}${padding}${status}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StatusInfo {
|
|
20
|
+
onnx: boolean;
|
|
21
|
+
coreml: CoreMLInstallState | "n/a";
|
|
22
|
+
ffmpeg: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function collectSuggestions(info: StatusInfo): string[] {
|
|
26
|
+
const suggestions: string[] = [];
|
|
27
|
+
|
|
28
|
+
if (info.coreml === "missing" || info.coreml === "stale-binary") {
|
|
29
|
+
suggestions.push(`Run "parakeet install --coreml" to install the CoreML backend.`);
|
|
30
|
+
} else if (info.coreml === "binary-only") {
|
|
31
|
+
suggestions.push(`Run "parakeet install --coreml" to download CoreML models.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!info.onnx) {
|
|
35
|
+
suggestions.push(`Run "parakeet install --onnx" to install the ONNX backend.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!info.ffmpeg) {
|
|
39
|
+
suggestions.push(`Install ffmpeg for ONNX audio conversion (see "parakeet install" output for instructions).`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return suggestions;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StatusDeps {
|
|
46
|
+
isMacArm64: () => boolean;
|
|
47
|
+
getCoreMLBinPath: () => string;
|
|
48
|
+
getCoreMLState: (binPath: string) => CoreMLInstallState;
|
|
49
|
+
getCoreMLSupportDir: () => string;
|
|
50
|
+
isModelCached: () => boolean;
|
|
51
|
+
getModelDir: () => string;
|
|
52
|
+
whichFfmpeg: () => string | null;
|
|
53
|
+
bunVersion: string;
|
|
54
|
+
platform: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function defaultDeps(): StatusDeps {
|
|
58
|
+
return {
|
|
59
|
+
isMacArm64,
|
|
60
|
+
getCoreMLBinPath,
|
|
61
|
+
getCoreMLState: (binPath) => getCoreMLInstallState({
|
|
62
|
+
binPath,
|
|
63
|
+
verifyReady: (path) => getCoreMLInstallStatus(path),
|
|
64
|
+
}),
|
|
65
|
+
getCoreMLSupportDir,
|
|
66
|
+
isModelCached,
|
|
67
|
+
getModelDir,
|
|
68
|
+
whichFfmpeg: () => Bun.which("ffmpeg"),
|
|
69
|
+
bunVersion: Bun.version,
|
|
70
|
+
platform: `${process.platform} ${process.arch}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function showStatus(deps?: Partial<StatusDeps>): Promise<void> {
|
|
75
|
+
const d = { ...defaultDeps(), ...deps };
|
|
76
|
+
|
|
77
|
+
const isMac = d.isMacArm64();
|
|
78
|
+
|
|
79
|
+
// CoreML status
|
|
80
|
+
let coremlState: CoreMLInstallState | "n/a" = "n/a";
|
|
81
|
+
if (isMac) {
|
|
82
|
+
const binPath = d.getCoreMLBinPath();
|
|
83
|
+
try {
|
|
84
|
+
coremlState = d.getCoreMLState(binPath);
|
|
85
|
+
} catch {
|
|
86
|
+
coremlState = "missing";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
log.info("CoreML (macOS Apple Silicon):");
|
|
90
|
+
const binInstalled = coremlState !== "missing";
|
|
91
|
+
log.info(formatStatusLine("Binary", binInstalled ? binPath : null, binInstalled));
|
|
92
|
+
|
|
93
|
+
const modelsInstalled = coremlState === "ready";
|
|
94
|
+
const modelDir = d.getCoreMLSupportDir();
|
|
95
|
+
log.info(formatStatusLine("Models", modelsInstalled ? modelDir : null, modelsInstalled));
|
|
96
|
+
log.info("");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ONNX status
|
|
100
|
+
const modelDir = d.getModelDir();
|
|
101
|
+
const onnxInstalled = d.isModelCached();
|
|
102
|
+
log.info("ONNX:");
|
|
103
|
+
log.info(formatStatusLine("Models", onnxInstalled ? modelDir : null, onnxInstalled));
|
|
104
|
+
log.info("");
|
|
105
|
+
|
|
106
|
+
// ffmpeg
|
|
107
|
+
const ffmpegPath = d.whichFfmpeg();
|
|
108
|
+
log.info(formatStatusLine("ffmpeg", ffmpegPath, !!ffmpegPath, "not found"));
|
|
109
|
+
|
|
110
|
+
// Runtime info
|
|
111
|
+
log.info(formatStatusLine("Runtime", `Bun ${d.bunVersion}`, true));
|
|
112
|
+
log.info(formatStatusLine("Platform", d.platform, true));
|
|
113
|
+
log.info("");
|
|
114
|
+
|
|
115
|
+
// Suggestions
|
|
116
|
+
const suggestions = collectSuggestions({
|
|
117
|
+
onnx: onnxInstalled,
|
|
118
|
+
coreml: coremlState,
|
|
119
|
+
ffmpeg: !!ffmpegPath,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
for (const suggestion of suggestions) {
|
|
123
|
+
log.warn(suggestion);
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/transcribe.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { requireModel, isModelCached, installHintError } from "./onnx-install";
|
|
2
|
-
import { isCoreMLInstalled, transcribeCoreML } from "./coreml";
|
|
2
|
+
import { isCoreMLInstalled, transcribeCoreML, isMacArm64 } from "./coreml";
|
|
3
|
+
import { log } from "./log";
|
|
3
4
|
import { convertToFloat32PCM } from "./audio";
|
|
4
5
|
import { initPreprocessor, preprocess } from "./preprocess";
|
|
5
6
|
import { initEncoder, encode } from "./encoder";
|
|
@@ -28,6 +29,7 @@ const DECODER_HIDDEN = 640;
|
|
|
28
29
|
export interface TranscribeOptions {
|
|
29
30
|
beamWidth?: number;
|
|
30
31
|
modelDir?: string;
|
|
32
|
+
silent?: boolean;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
// Minimum 0.1s of audio at 16kHz to produce meaningful output
|
|
@@ -38,6 +40,10 @@ export async function transcribe(audioPath: string, opts: TranscribeOptions = {}
|
|
|
38
40
|
return transcribeCoreML(audioPath);
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
if (!opts.silent && isMacArm64()) {
|
|
44
|
+
log.warn("CoreML backend unavailable, falling back to ONNX");
|
|
45
|
+
}
|
|
46
|
+
|
|
41
47
|
if (isModelCached(opts.modelDir)) {
|
|
42
48
|
return transcribeOnnx(audioPath, opts);
|
|
43
49
|
}
|
|
@@ -49,6 +55,9 @@ async function transcribeOnnx(audioPath: string, opts: TranscribeOptions): Promi
|
|
|
49
55
|
const audio = await convertToFloat32PCM(audioPath);
|
|
50
56
|
|
|
51
57
|
if (audio.length < MIN_AUDIO_SAMPLES) {
|
|
58
|
+
if (!opts.silent) {
|
|
59
|
+
log.warn(`Audio too short (< 0.1s), skipping: ${audioPath}`);
|
|
60
|
+
}
|
|
52
61
|
return "";
|
|
53
62
|
}
|
|
54
63
|
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { getFfmpegInstallHint, assertFfmpegExists, resetFfmpegCheck } from "../audio";
|
|
3
|
-
|
|
4
|
-
describe("getFfmpegInstallHint", () => {
|
|
5
|
-
test("returns a non-empty string", () => {
|
|
6
|
-
const hint = getFfmpegInstallHint();
|
|
7
|
-
expect(hint).toBeTruthy();
|
|
8
|
-
expect(typeof hint).toBe("string");
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
test("contains install keyword", () => {
|
|
12
|
-
const hint = getFfmpegInstallHint();
|
|
13
|
-
expect(hint).toMatch(/install|ffmpeg\.org/i);
|
|
14
|
-
});
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
describe("assertFfmpegExists", () => {
|
|
18
|
-
test("includes install hint when ffmpeg is missing", () => {
|
|
19
|
-
// Save and override Bun.which to simulate missing ffmpeg
|
|
20
|
-
const originalWhich = Bun.which;
|
|
21
|
-
Bun.which = ((cmd: string) => {
|
|
22
|
-
if (cmd === "ffmpeg") return null;
|
|
23
|
-
return originalWhich(cmd);
|
|
24
|
-
}) as typeof Bun.which;
|
|
25
|
-
|
|
26
|
-
// Reset the cached check so assertFfmpegExists re-checks
|
|
27
|
-
resetFfmpegCheck();
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
expect(() => assertFfmpegExists()).toThrow(/Install it:/);
|
|
31
|
-
} finally {
|
|
32
|
-
Bun.which = originalWhich;
|
|
33
|
-
resetFfmpegCheck();
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
});
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
createBenchmarkSummary,
|
|
4
|
-
renderBenchmarkReport,
|
|
5
|
-
type BenchmarkSystemInfo,
|
|
6
|
-
} from "../benchmark-report";
|
|
7
|
-
|
|
8
|
-
const system: BenchmarkSystemInfo = {
|
|
9
|
-
os: "Darwin",
|
|
10
|
-
arch: "arm64",
|
|
11
|
-
chip: "Apple M3 Pro",
|
|
12
|
-
ram: "18 GB",
|
|
13
|
-
backend: "CoreML",
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
describe("benchmark-report", () => {
|
|
17
|
-
test("createBenchmarkSummary computes totals and speedup", () => {
|
|
18
|
-
expect(
|
|
19
|
-
createBenchmarkSummary(
|
|
20
|
-
[
|
|
21
|
-
{ time: 2.34, text: "a" },
|
|
22
|
-
{ time: 1.11, text: "b" },
|
|
23
|
-
],
|
|
24
|
-
[
|
|
25
|
-
{ time: 1.0, text: "a" },
|
|
26
|
-
{ time: 0.5, text: "b" },
|
|
27
|
-
],
|
|
28
|
-
),
|
|
29
|
-
).toEqual({
|
|
30
|
-
whisper_total: 3.5,
|
|
31
|
-
parakeet_total: 1.5,
|
|
32
|
-
speedup: 2.3,
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("createBenchmarkSummary rejects mismatched result counts", () => {
|
|
37
|
-
expect(() =>
|
|
38
|
-
createBenchmarkSummary(
|
|
39
|
-
[{ time: 1, text: "a" }],
|
|
40
|
-
[
|
|
41
|
-
{ time: 1, text: "a" },
|
|
42
|
-
{ time: 2, text: "b" },
|
|
43
|
-
],
|
|
44
|
-
),
|
|
45
|
-
).toThrow("Benchmark result count mismatch");
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("renderBenchmarkReport produces markdown with totals", () => {
|
|
49
|
-
const report = renderBenchmarkReport({
|
|
50
|
-
date: "2026-04-08",
|
|
51
|
-
version: "0.7.0",
|
|
52
|
-
system,
|
|
53
|
-
whisperResults: [{ time: 3.2, text: "hello" }],
|
|
54
|
-
parakeetResults: [{ time: 1.6, text: "hello" }],
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
expect(report.summary).toEqual({
|
|
58
|
-
whisper_total: 3.2,
|
|
59
|
-
parakeet_total: 1.6,
|
|
60
|
-
speedup: 2,
|
|
61
|
-
});
|
|
62
|
-
expect(report.markdown).toContain("**Date:** 2026-04-08");
|
|
63
|
-
expect(report.markdown).toContain("**Runner:** Darwin arm64 (Apple M3 Pro, 18 GB RAM)");
|
|
64
|
-
expect(report.markdown).toContain("| **Total** | **3.2s** | **1.6s** | | |");
|
|
65
|
-
expect(report.markdown).toContain("**Parakeet is ~2x faster.**");
|
|
66
|
-
});
|
|
67
|
-
});
|
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
classifyCoreMLInstallProbe,
|
|
4
|
-
createCoreMLBinaryRunner,
|
|
5
|
-
ensureCoreMLModels,
|
|
6
|
-
getCoreMLDownloadURL,
|
|
7
|
-
getCoreMLInstallState,
|
|
8
|
-
getCoreMLInstallStatus,
|
|
9
|
-
getCoreMLSupportDir,
|
|
10
|
-
parseCoreMLBinaryCapabilities,
|
|
11
|
-
planCoreMLInstall,
|
|
12
|
-
type CoreMLBinaryCommandResult,
|
|
13
|
-
type CoreMLBinaryRunner,
|
|
14
|
-
} from "../coreml-install";
|
|
15
|
-
import { join } from "path";
|
|
16
|
-
import { homedir } from "os";
|
|
17
|
-
|
|
18
|
-
describe("coreml-install", () => {
|
|
19
|
-
test("getCoreMLSupportDir returns correct cache path", () => {
|
|
20
|
-
expect(getCoreMLSupportDir()).toBe(
|
|
21
|
-
join(homedir(), ".cache", "parakeet", "coreml"),
|
|
22
|
-
);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("getCoreMLDownloadURL includes version and correct filename", () => {
|
|
26
|
-
const url = getCoreMLDownloadURL("0.5.0");
|
|
27
|
-
expect(url).toBe(
|
|
28
|
-
"https://github.com/drakulavich/parakeet-cli/releases/download/v0.5.0/parakeet-coreml-darwin-arm64",
|
|
29
|
-
);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("getCoreMLInstallState returns missing when binary is absent", () => {
|
|
33
|
-
const state = getCoreMLInstallState({
|
|
34
|
-
binPath: "/tmp/parakeet-coreml",
|
|
35
|
-
exists: () => false,
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
expect(state).toBe("missing");
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("getCoreMLInstallState returns binary-only when readiness check fails", () => {
|
|
42
|
-
const state = getCoreMLInstallState({
|
|
43
|
-
binPath: "/tmp/parakeet-coreml",
|
|
44
|
-
exists: () => true,
|
|
45
|
-
verifyReady: () => "binary-only",
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
expect(state).toBe("binary-only");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("getCoreMLInstallState returns ready when readiness check passes", () => {
|
|
52
|
-
const state = getCoreMLInstallState({
|
|
53
|
-
binPath: "/tmp/parakeet-coreml",
|
|
54
|
-
exists: () => true,
|
|
55
|
-
verifyReady: () => "ready",
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
expect(state).toBe("ready");
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("getCoreMLInstallState returns stale-binary when cached binary is too old", () => {
|
|
62
|
-
const state = getCoreMLInstallState({
|
|
63
|
-
binPath: "/tmp/parakeet-coreml",
|
|
64
|
-
exists: () => true,
|
|
65
|
-
verifyReady: () => "stale-binary",
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
expect(state).toBe("stale-binary");
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("getCoreMLInstallState defaults to binary-only when no readiness checker is provided", () => {
|
|
72
|
-
const state = getCoreMLInstallState({
|
|
73
|
-
binPath: "/tmp/parakeet-coreml",
|
|
74
|
-
exists: () => true,
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
expect(state).toBe("binary-only");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("planCoreMLInstall skips work when install is ready", () => {
|
|
81
|
-
expect(planCoreMLInstall("ready")).toEqual({
|
|
82
|
-
downloadBinary: false,
|
|
83
|
-
downloadModels: false,
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("planCoreMLInstall downloads only models when binary already exists", () => {
|
|
88
|
-
expect(planCoreMLInstall("binary-only")).toEqual({
|
|
89
|
-
downloadBinary: false,
|
|
90
|
-
downloadModels: true,
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("planCoreMLInstall forces both downloads with no-cache", () => {
|
|
95
|
-
expect(planCoreMLInstall("ready", true)).toEqual({
|
|
96
|
-
downloadBinary: true,
|
|
97
|
-
downloadModels: true,
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("planCoreMLInstall refreshes stale cached binaries", () => {
|
|
102
|
-
expect(planCoreMLInstall("stale-binary")).toEqual({
|
|
103
|
-
downloadBinary: true,
|
|
104
|
-
downloadModels: true,
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("parseCoreMLBinaryCapabilities accepts the current protocol payload", () => {
|
|
109
|
-
expect(
|
|
110
|
-
parseCoreMLBinaryCapabilities(
|
|
111
|
-
JSON.stringify({
|
|
112
|
-
protocolVersion: 1,
|
|
113
|
-
installState: "ready",
|
|
114
|
-
supportedCommands: {
|
|
115
|
-
checkInstall: true,
|
|
116
|
-
downloadOnly: true,
|
|
117
|
-
},
|
|
118
|
-
}),
|
|
119
|
-
),
|
|
120
|
-
).toEqual({
|
|
121
|
-
protocolVersion: 1,
|
|
122
|
-
installState: "ready",
|
|
123
|
-
supportedCommands: {
|
|
124
|
-
checkInstall: true,
|
|
125
|
-
downloadOnly: true,
|
|
126
|
-
},
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("parseCoreMLBinaryCapabilities rejects malformed payloads", () => {
|
|
131
|
-
expect(
|
|
132
|
-
parseCoreMLBinaryCapabilities("{invalid"),
|
|
133
|
-
).toBeNull();
|
|
134
|
-
expect(
|
|
135
|
-
parseCoreMLBinaryCapabilities(
|
|
136
|
-
JSON.stringify({
|
|
137
|
-
protocolVersion: 2,
|
|
138
|
-
installState: "ready",
|
|
139
|
-
supportedCommands: {
|
|
140
|
-
checkInstall: true,
|
|
141
|
-
downloadOnly: true,
|
|
142
|
-
},
|
|
143
|
-
}),
|
|
144
|
-
),
|
|
145
|
-
).toBeNull();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("classifyCoreMLInstallProbe classifies capabilities responses", () => {
|
|
149
|
-
expect(
|
|
150
|
-
classifyCoreMLInstallProbe(1, ""),
|
|
151
|
-
).toBe("stale-binary");
|
|
152
|
-
expect(
|
|
153
|
-
classifyCoreMLInstallProbe(
|
|
154
|
-
0,
|
|
155
|
-
JSON.stringify({
|
|
156
|
-
protocolVersion: 1,
|
|
157
|
-
installState: "models-missing",
|
|
158
|
-
supportedCommands: {
|
|
159
|
-
checkInstall: true,
|
|
160
|
-
downloadOnly: true,
|
|
161
|
-
},
|
|
162
|
-
}),
|
|
163
|
-
),
|
|
164
|
-
).toBe("binary-only");
|
|
165
|
-
expect(
|
|
166
|
-
classifyCoreMLInstallProbe(
|
|
167
|
-
0,
|
|
168
|
-
JSON.stringify({
|
|
169
|
-
protocolVersion: 1,
|
|
170
|
-
installState: "ready",
|
|
171
|
-
supportedCommands: {
|
|
172
|
-
checkInstall: true,
|
|
173
|
-
downloadOnly: true,
|
|
174
|
-
},
|
|
175
|
-
}),
|
|
176
|
-
),
|
|
177
|
-
).toBe("ready");
|
|
178
|
-
expect(
|
|
179
|
-
classifyCoreMLInstallProbe(0, "{\"protocolVersion\":999}"),
|
|
180
|
-
).toBe("stale-binary");
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("createCoreMLBinaryRunner runs the expected commands", () => {
|
|
184
|
-
const calls: string[][] = [];
|
|
185
|
-
const runner = createCoreMLBinaryRunner((cmd) => {
|
|
186
|
-
calls.push(Array.isArray(cmd) ? cmd : cmd.cmd);
|
|
187
|
-
return {
|
|
188
|
-
exitCode: 0,
|
|
189
|
-
stdout: Buffer.from("{}"),
|
|
190
|
-
stderr: Buffer.from(""),
|
|
191
|
-
} as ReturnType<typeof Bun.spawnSync>;
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
runner.probeCapabilities("/tmp/parakeet-coreml");
|
|
195
|
-
runner.downloadModels("/tmp/parakeet-coreml");
|
|
196
|
-
|
|
197
|
-
expect(calls).toEqual([
|
|
198
|
-
["/tmp/parakeet-coreml", "--capabilities-json"],
|
|
199
|
-
["/tmp/parakeet-coreml", "--download-only"],
|
|
200
|
-
]);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
test("getCoreMLInstallStatus delegates to the runner probe", () => {
|
|
204
|
-
const runner: CoreMLBinaryRunner = {
|
|
205
|
-
probeCapabilities() {
|
|
206
|
-
return {
|
|
207
|
-
exitCode: 0,
|
|
208
|
-
stdout: JSON.stringify({
|
|
209
|
-
protocolVersion: 1,
|
|
210
|
-
installState: "models-missing",
|
|
211
|
-
supportedCommands: {
|
|
212
|
-
checkInstall: true,
|
|
213
|
-
downloadOnly: true,
|
|
214
|
-
},
|
|
215
|
-
}),
|
|
216
|
-
stderr: "",
|
|
217
|
-
};
|
|
218
|
-
},
|
|
219
|
-
downloadModels() {
|
|
220
|
-
throw new Error("not used");
|
|
221
|
-
},
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
expect(getCoreMLInstallStatus("/tmp/parakeet-coreml", runner)).toBe("binary-only");
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test("ensureCoreMLModels streams command output through the writer", async () => {
|
|
228
|
-
const writes = {
|
|
229
|
-
stdout: [] as string[],
|
|
230
|
-
stderr: [] as string[],
|
|
231
|
-
};
|
|
232
|
-
const runner: CoreMLBinaryRunner = {
|
|
233
|
-
probeCapabilities() {
|
|
234
|
-
throw new Error("not used");
|
|
235
|
-
},
|
|
236
|
-
downloadModels() {
|
|
237
|
-
return {
|
|
238
|
-
exitCode: 0,
|
|
239
|
-
stdout: "downloaded\n",
|
|
240
|
-
stderr: "progress\n",
|
|
241
|
-
};
|
|
242
|
-
},
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
await ensureCoreMLModels("/tmp/parakeet-coreml", runner, {
|
|
246
|
-
stdout(message) {
|
|
247
|
-
writes.stdout.push(message);
|
|
248
|
-
},
|
|
249
|
-
stderr(message) {
|
|
250
|
-
writes.stderr.push(message);
|
|
251
|
-
},
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
expect(writes).toEqual({
|
|
255
|
-
stdout: ["downloaded\n"],
|
|
256
|
-
stderr: ["progress\n"],
|
|
257
|
-
});
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
test("ensureCoreMLModels throws a contextual error when download fails", async () => {
|
|
261
|
-
const runner: CoreMLBinaryRunner = {
|
|
262
|
-
probeCapabilities() {
|
|
263
|
-
throw new Error("not used");
|
|
264
|
-
},
|
|
265
|
-
downloadModels() {
|
|
266
|
-
return {
|
|
267
|
-
exitCode: 2,
|
|
268
|
-
stdout: "",
|
|
269
|
-
stderr: "download failed",
|
|
270
|
-
};
|
|
271
|
-
},
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
await expect(
|
|
275
|
-
ensureCoreMLModels("/tmp/parakeet-coreml", runner, {
|
|
276
|
-
stdout() {},
|
|
277
|
-
stderr() {},
|
|
278
|
-
}),
|
|
279
|
-
).rejects.toThrow("Failed to download CoreML models: download failed");
|
|
280
|
-
});
|
|
281
|
-
});
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
shouldRetryCoreMLWithWav,
|
|
4
|
-
} from "../coreml";
|
|
5
|
-
|
|
6
|
-
describe("coreml", () => {
|
|
7
|
-
test("retries non-wav files on CoreAudio decode errors", () => {
|
|
8
|
-
expect(
|
|
9
|
-
shouldRetryCoreMLWithWav(
|
|
10
|
-
"fixtures/hello-english.oga",
|
|
11
|
-
new Error("Error: The operation couldn’t be completed. (com.apple.coreaudio.avfaudio error 1718449215.)"),
|
|
12
|
-
),
|
|
13
|
-
).toBe(true);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test("does not retry wav files on CoreAudio decode errors", () => {
|
|
17
|
-
expect(
|
|
18
|
-
shouldRetryCoreMLWithWav(
|
|
19
|
-
"fixtures/silence.wav",
|
|
20
|
-
new Error("Error: The operation couldn’t be completed. (com.apple.coreaudio.avfaudio error 1718449215.)"),
|
|
21
|
-
),
|
|
22
|
-
).toBe(false);
|
|
23
|
-
});
|
|
24
|
-
});
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { beamDecode, type DecoderSession } from "../decoder";
|
|
3
|
-
|
|
4
|
-
function mockSession(responses: Array<{ tokenLogits: number[]; durationLogits: number[] }>): DecoderSession {
|
|
5
|
-
let callIndex = 0;
|
|
6
|
-
return {
|
|
7
|
-
async decode(_encoderFrame, _targets, _targetLength, _state1, _state2) {
|
|
8
|
-
const resp = responses[Math.min(callIndex++, responses.length - 1)];
|
|
9
|
-
const output = new Float32Array([...resp.tokenLogits, ...resp.durationLogits]);
|
|
10
|
-
const state1 = new Float32Array(1);
|
|
11
|
-
const state2 = new Float32Array(1);
|
|
12
|
-
return { output, state1, state2 };
|
|
13
|
-
},
|
|
14
|
-
vocabSize: responses[0]?.tokenLogits.length ?? 4,
|
|
15
|
-
blankId: (responses[0]?.tokenLogits.length ?? 4) - 1,
|
|
16
|
-
stateDims: { layers: 1, hidden: 1 },
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe("decoder", () => {
|
|
21
|
-
test("emits non-blank tokens", async () => {
|
|
22
|
-
const session = mockSession([
|
|
23
|
-
{ tokenLogits: [10, 0, 0, -10], durationLogits: [10, 0] },
|
|
24
|
-
{ tokenLogits: [0, 10, 0, -10], durationLogits: [10, 0] },
|
|
25
|
-
{ tokenLogits: [0, 0, 0, 10], durationLogits: [10, 0] },
|
|
26
|
-
]);
|
|
27
|
-
const encoderData = new Float32Array(3);
|
|
28
|
-
const tokens = await beamDecode(session, 3, encoderData, 1, 1);
|
|
29
|
-
expect(tokens).toEqual([0, 1]);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("respects duration skipping", async () => {
|
|
33
|
-
const session = mockSession([
|
|
34
|
-
{ tokenLogits: [10, 0, 0, -10], durationLogits: [0, 0, 10] },
|
|
35
|
-
{ tokenLogits: [0, 10, 0, -10], durationLogits: [10, 0, 0] },
|
|
36
|
-
{ tokenLogits: [0, 0, 0, 10], durationLogits: [10, 0, 0] },
|
|
37
|
-
]);
|
|
38
|
-
const encoderData = new Float32Array(5);
|
|
39
|
-
const tokens = await beamDecode(session, 5, encoderData, 1, 1);
|
|
40
|
-
expect(tokens).toEqual([0, 1]);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("returns empty for zero-length encoder output", async () => {
|
|
44
|
-
const session = mockSession([
|
|
45
|
-
{ tokenLogits: [0, 0, 0, 10], durationLogits: [10, 0] },
|
|
46
|
-
]);
|
|
47
|
-
const tokens = await beamDecode(session, 0, new Float32Array(0), 1);
|
|
48
|
-
expect(tokens).toEqual([]);
|
|
49
|
-
});
|
|
50
|
-
});
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { Tokenizer } from "../tokenizer";
|
|
3
|
-
|
|
4
|
-
describe("tokenizer", () => {
|
|
5
|
-
test("loads vocab from file", async () => {
|
|
6
|
-
const tok = await Tokenizer.fromFile("fixtures/test-vocab.txt");
|
|
7
|
-
expect(tok.vocabSize).toBe(6);
|
|
8
|
-
expect(tok.blankId).toBe(5);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
test("detokenizes token IDs to text", async () => {
|
|
12
|
-
const tok = await Tokenizer.fromFile("fixtures/test-vocab.txt");
|
|
13
|
-
const text = tok.detokenize([0, 1]);
|
|
14
|
-
expect(text).toBe("hello world");
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test("handles blank tokens by skipping them", async () => {
|
|
18
|
-
const tok = await Tokenizer.fromFile("fixtures/test-vocab.txt");
|
|
19
|
-
const text = tok.detokenize([0, 5, 1]);
|
|
20
|
-
expect(text).toBe("hello world");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("handles empty token list", async () => {
|
|
24
|
-
const tok = await Tokenizer.fromFile("fixtures/test-vocab.txt");
|
|
25
|
-
const text = tok.detokenize([]);
|
|
26
|
-
expect(text).toBe("");
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("handles only blank tokens", async () => {
|
|
30
|
-
const tok = await Tokenizer.fromFile("fixtures/test-vocab.txt");
|
|
31
|
-
const text = tok.detokenize([5, 5, 5]);
|
|
32
|
-
expect(text).toBe("");
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test("joins subword tokens correctly", async () => {
|
|
36
|
-
const tok = await Tokenizer.fromFile("fixtures/test-vocab.txt");
|
|
37
|
-
const text = tok.detokenize([3, 4]);
|
|
38
|
-
expect(text).toBe("cats");
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
});
|