@drakulavich/kesha-voice-kit 1.0.0-beta.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 drakulavich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # Kesha Voice Kit
2
+
3
+ [![CI](https://github.com/drakulavich/kesha-voice-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/drakulavich/kesha-voice-kit/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/@drakulavich/kesha-voice-kit)](https://www.npmjs.com/package/@drakulavich/kesha-voice-kit)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
+ [![Bun](https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun)](https://bun.sh)
7
+
8
+ **Open-source voice toolkit for Apple Silicon.** A collection of small, fast, open-source audio models — packaged as CLI tools and an [OpenClaw](https://github.com/nicekid1/OpenClaw) skill for LLM agents.
9
+
10
+ - **Speech-to-text** — 25 languages, ~18x faster than Whisper on Apple Silicon
11
+ - **Language detection** — 107 languages from audio, text language via NLLanguageRecognizer
12
+ - **Rust engine** — single 20MB binary, no ffmpeg, no Python, no native Node addons
13
+ - **OpenClaw-ready** — plug into your LLM agent as a voice processing skill
14
+
15
+ ## Quick Start
16
+
17
+ ```bash
18
+ bun install -g @drakulavich/kesha-voice-kit
19
+ kesha install # downloads engine + models
20
+ kesha audio.ogg # transcript to stdout
21
+ ```
22
+
23
+ ## OpenClaw Integration
24
+
25
+ Kesha Voice Kit is built as a skill for [OpenClaw](https://github.com/nicekid1/OpenClaw) — give your LLM agent ears. No API keys, everything runs locally on your machine.
26
+
27
+ ### Voice message processing
28
+
29
+ ```json
30
+ {
31
+ "tools": {
32
+ "media": {
33
+ "audio": {
34
+ "enabled": true,
35
+ "models": [
36
+ {
37
+ "type": "cli",
38
+ "command": "kesha",
39
+ "args": ["{{MediaPath}}"],
40
+ "timeoutSeconds": 120
41
+ }
42
+ ],
43
+ "echoTranscript": false
44
+ }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ Your agent receives a voice message in Telegram/WhatsApp/Slack. OpenClaw pipes it through Kesha, feeds the transcript to the LLM. The user speaks, the agent understands.
51
+
52
+ ### Language-aware processing
53
+
54
+ ```json
55
+ {
56
+ "type": "cli",
57
+ "command": "kesha",
58
+ "args": ["--json", "{{MediaPath}}"]
59
+ }
60
+ ```
61
+
62
+ JSON output includes detected language — your agent knows what language the user spoke and can respond accordingly:
63
+
64
+ ```json
65
+ [{
66
+ "file": "voice.ogg",
67
+ "text": "Привет, как дела?",
68
+ "lang": "ru",
69
+ "textLanguage": { "code": "ru", "confidence": 0.99 }
70
+ }]
71
+ ```
72
+
73
+ ## CLI Tools
74
+
75
+ ```bash
76
+ kesha install # download engine and models
77
+ kesha audio.ogg # transcribe
78
+ kesha --json audio.ogg # JSON output with language info
79
+ kesha --verbose audio.ogg # show language detection details
80
+ kesha --lang en audio.ogg # warn if detected language differs
81
+ kesha status # show installed backend info
82
+ ```
83
+
84
+ Stdout: transcript. Stderr: errors. Pipe-friendly.
85
+
86
+ **Also available as `parakeet` command** (backward-compatible alias).
87
+
88
+ ## What's Inside
89
+
90
+ Kesha Voice Kit bundles open-source models optimized for on-device inference:
91
+
92
+ | Model | Task | Size | Source |
93
+ |---|---|---|---|
94
+ | NVIDIA Parakeet TDT 0.6B v3 | Speech-to-text | ~86MB | [HuggingFace](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3) |
95
+ | SpeechBrain ECAPA-TDNN | Audio language detection | ~86MB | [HuggingFace](https://huggingface.co/speechbrain/lang-id-voxlingua107-ecapa) |
96
+ | Apple NLLanguageRecognizer | Text language detection | built-in | macOS system framework |
97
+
98
+ All models run through `kesha-engine` — a Rust binary using [FluidAudio](https://github.com/FluidInference/FluidAudio) (CoreML) on Apple Silicon and [ort](https://github.com/pykeio/ort) (ONNX Runtime) on other platforms.
99
+
100
+ ## Performance
101
+
102
+ > **~18x faster than Whisper** on Apple Silicon
103
+
104
+ <details>
105
+ <summary>MacBook Pro M3 Pro — 10 Russian voice messages</summary>
106
+
107
+ ```
108
+ faster-whisper (CPU): 35.3s ██████████████████████████████████████
109
+ Kesha (CoreML): 1.9s ██
110
+ ```
111
+
112
+ | | faster-whisper | Kesha | Speedup |
113
+ |---|---|---|---|
114
+ | Apple Silicon (CoreML) | 35.3s | **1.9s** | **~18x** |
115
+ | Linux CI (ONNX) | 79.2s | **45.4s** | **~1.7x** |
116
+
117
+ </details>
118
+
119
+ ## Supported Audio Formats
120
+
121
+ Built-in audio decoding via [symphonia](https://github.com/pdeljanov/Symphonia) — no ffmpeg required:
122
+
123
+ | Format | Extension |
124
+ |---|---|
125
+ | WAV | `.wav` |
126
+ | MP3 | `.mp3` |
127
+ | OGG Vorbis/Opus | `.ogg`, `.opus` |
128
+ | FLAC | `.flac` |
129
+ | AAC / M4A | `.aac`, `.m4a` |
130
+
131
+ ## Supported Languages
132
+
133
+ **Speech-to-text (25):** :bulgaria: Bulgarian, :croatia: Croatian, :czech_republic: Czech, :denmark: Danish, :netherlands: Dutch, :gb: English, :estonia: Estonian, :finland: Finnish, :fr: French, :de: German, :greece: Greek, :hungary: Hungarian, :it: Italian, :latvia: Latvian, :lithuania: Lithuanian, :malta: Maltese, :poland: Polish, :portugal: Portuguese, :romania: Romanian, :ru: Russian, :slovakia: Slovak, :slovenia: Slovenian, :es: Spanish, :sweden: Swedish, :ukraine: Ukrainian
134
+
135
+ **Audio language detection (107):** Full list at [speechbrain/lang-id-voxlingua107-ecapa](https://huggingface.co/speechbrain/lang-id-voxlingua107-ecapa)
136
+
137
+ ## Architecture
138
+
139
+ ```
140
+ kesha audio.ogg
141
+ → kesha-engine (Rust binary)
142
+ ├── Apple Silicon? → FluidAudio (CoreML / Neural Engine)
143
+ └── Other? → ort (ONNX Runtime / CPU)
144
+ → transcript to stdout
145
+ ```
146
+
147
+ ## Programmatic API
148
+
149
+ ```typescript
150
+ import { transcribe, downloadModel } from "@drakulavich/kesha-voice-kit/core";
151
+
152
+ await downloadModel(); // install engine + models
153
+ const text = await transcribe("audio.ogg"); // transcribe
154
+ ```
155
+
156
+ ## Requirements
157
+
158
+ - [Bun](https://bun.sh) >= 1.3
159
+ - macOS arm64, Linux x64, or Windows x64
160
+ - ~200MB disk (engine + models)
161
+
162
+ ## Contributing
163
+
164
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
165
+
166
+ ## License
167
+
168
+ Made with 💛🩵 Published under MIT License.
package/bin/kesha.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ import { runCli } from "../src/cli.ts";
3
+
4
+ await runCli();
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@drakulavich/kesha-voice-kit",
3
+ "version": "1.0.0-beta.5",
4
+ "description": "Open-source voice toolkit for Apple Silicon. Speech-to-text, language detection. 25 languages.",
5
+ "type": "module",
6
+ "bin": {
7
+ "kesha": "bin/kesha.js",
8
+ "parakeet": "bin/kesha.js"
9
+ },
10
+ "exports": {
11
+ ".": "./src/cli.ts",
12
+ "./core": "./src/lib.ts"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "src/",
17
+ "package.json",
18
+ "tsconfig.json",
19
+ "LICENSE",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "test": "bun test",
24
+ "test:unit": "bun test tests/unit/",
25
+ "test:integration": "bun test tests/integration/"
26
+ },
27
+ "keywords": [
28
+ "asr",
29
+ "speech-to-text",
30
+ "transcription",
31
+ "voice",
32
+ "kesha",
33
+ "multilingual",
34
+ "apple-silicon",
35
+ "coreml",
36
+ "bun",
37
+ "cli"
38
+ ],
39
+ "author": "drakulavich",
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/drakulavich/kesha-voice-kit.git"
44
+ },
45
+ "homepage": "https://github.com/drakulavich/kesha-voice-kit#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/drakulavich/kesha-voice-kit/issues"
48
+ },
49
+ "engines": {
50
+ "bun": ">=1.3.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/bun": "latest",
54
+ "fast-xml-parser": "^5.5.10",
55
+ "typescript": "^6.0.2"
56
+ },
57
+ "dependencies": {
58
+ "citty": "^0.2.2",
59
+ "picocolors": "^1.1.1",
60
+ "tinyld": "^1.3.4"
61
+ }
62
+ }
@@ -0,0 +1,92 @@
1
+ export interface BenchmarkSystemInfo {
2
+ os: string;
3
+ arch: string;
4
+ chip: string;
5
+ ram: string;
6
+ backend: string;
7
+ }
8
+
9
+ export interface BenchmarkResult {
10
+ time: number;
11
+ text: string;
12
+ }
13
+
14
+ export interface BenchmarkSummary {
15
+ whisper_total: number;
16
+ parakeet_total: number;
17
+ speedup: number;
18
+ }
19
+
20
+ export interface BenchmarkReport {
21
+ markdown: string;
22
+ summary: BenchmarkSummary;
23
+ }
24
+
25
+ function roundToTenths(value: number): number {
26
+ return Math.round(value * 10) / 10;
27
+ }
28
+
29
+ export function createBenchmarkSummary(
30
+ whisperResults: BenchmarkResult[],
31
+ parakeetResults: BenchmarkResult[],
32
+ ): BenchmarkSummary {
33
+ if (whisperResults.length !== parakeetResults.length) {
34
+ throw new Error(
35
+ `Benchmark result count mismatch: faster-whisper=${whisperResults.length}, parakeet=${parakeetResults.length}`,
36
+ );
37
+ }
38
+
39
+ const whisperTotal = roundToTenths(
40
+ whisperResults.reduce((total, result) => total + result.time, 0),
41
+ );
42
+ const parakeetTotal = roundToTenths(
43
+ parakeetResults.reduce((total, result) => total + result.time, 0),
44
+ );
45
+
46
+ return {
47
+ whisper_total: whisperTotal,
48
+ parakeet_total: parakeetTotal,
49
+ speedup: parakeetTotal > 0 ? roundToTenths(whisperTotal / parakeetTotal) : 0,
50
+ };
51
+ }
52
+
53
+ export function renderBenchmarkReport(args: {
54
+ date: string;
55
+ version: string;
56
+ system: BenchmarkSystemInfo;
57
+ whisperResults: BenchmarkResult[];
58
+ parakeetResults: BenchmarkResult[];
59
+ }): BenchmarkReport {
60
+ const { date, version, system, whisperResults, parakeetResults } = args;
61
+ const summary = createBenchmarkSummary(whisperResults, parakeetResults);
62
+
63
+ const lines: string[] = [
64
+ "",
65
+ `**Date:** ${date}`,
66
+ `**Version:** v${version}`,
67
+ `**Runner:** ${system.os} ${system.arch} (${system.chip}, ${system.ram} RAM)`,
68
+ `**Backend:** ${system.backend}`,
69
+ "",
70
+ `| # | faster-whisper | Kesha (${system.backend}) | faster-whisper Transcript | Kesha Transcript |`,
71
+ "|---|---------|----------|--------------------|---------------------|",
72
+ ];
73
+
74
+ for (let i = 0; i < whisperResults.length; i++) {
75
+ const whisper = whisperResults[i];
76
+ const parakeet = parakeetResults[i];
77
+ lines.push(
78
+ `| ${i + 1} | ${whisper.time}s | ${parakeet.time}s | ${whisper.text} | ${parakeet.text} |`,
79
+ );
80
+ }
81
+
82
+ lines.push(
83
+ `| **Total** | **${summary.whisper_total}s** | **${summary.parakeet_total}s** | | |`,
84
+ );
85
+ lines.push("");
86
+ lines.push(`**Kesha is ~${summary.speedup}x faster.**`);
87
+
88
+ return {
89
+ markdown: lines.join("\n"),
90
+ summary,
91
+ };
92
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { defineCommand, runMain } from "citty";
4
+ import { detect } from "tinyld";
5
+ import { transcribe } from "./lib";
6
+ import { downloadEngine } from "./engine-install";
7
+ import { detectAudioLanguageEngine, detectTextLanguageEngine } from "./engine";
8
+ import type { LangDetectResult } from "./engine";
9
+ import { log } from "./log";
10
+ import { showStatus } from "./status";
11
+
12
+ export function detectLanguage(text: string): string {
13
+ if (!text) return "";
14
+ return detect(text);
15
+ }
16
+
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
+ }
21
+
22
+ interface InstallCommandArgs {
23
+ "no-cache": boolean;
24
+ }
25
+
26
+ interface MainCommandArgs {
27
+ _: string[];
28
+ json: boolean;
29
+ verbose: boolean;
30
+ lang?: string;
31
+ }
32
+
33
+ const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
34
+
35
+ async function performInstall(noCache: boolean) {
36
+ try {
37
+ await downloadEngine(noCache);
38
+ } catch (err: unknown) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ log.error(message);
41
+ process.exit(1);
42
+ }
43
+ }
44
+
45
+ export const installCommand = defineCommand({
46
+ meta: {
47
+ name: "install",
48
+ description: "Download inference engine and models",
49
+ },
50
+ args: {
51
+ "no-cache": {
52
+ type: "boolean",
53
+ description: "Re-download even if cached",
54
+ default: false,
55
+ },
56
+ },
57
+ async run({ args }: { args: InstallCommandArgs }) {
58
+ await performInstall(args["no-cache"]);
59
+ },
60
+ });
61
+
62
+ export const statusCommand = defineCommand({
63
+ meta: {
64
+ name: "status",
65
+ description: "Show backend installation status",
66
+ },
67
+ async run() {
68
+ await showStatus();
69
+ },
70
+ });
71
+
72
+ export const mainCommand = defineCommand({
73
+ meta: {
74
+ name: "kesha",
75
+ version: pkg.version,
76
+ description:
77
+ "Kesha Voice Kit — open-source voice toolkit for Apple Silicon.\n" +
78
+ " Run 'kesha install [--no-cache]' to download engine and models.\n" +
79
+ " Run 'kesha status' to inspect installed backend.",
80
+ },
81
+ args: {
82
+ json: {
83
+ type: "boolean",
84
+ description: "Output results as JSON",
85
+ default: false,
86
+ },
87
+ verbose: {
88
+ type: "boolean",
89
+ description: "Show language detection details",
90
+ default: false,
91
+ },
92
+ lang: {
93
+ type: "string",
94
+ description: "Expected language code (ISO 639-1), warn if mismatch",
95
+ },
96
+ },
97
+ async run({ args }: { args: MainCommandArgs }) {
98
+ const files = args._;
99
+
100
+ if (files.length === 0) {
101
+ log.info("Usage: kesha <audio_file> [audio_file ...]\n kesha install [--no-cache]\n kesha status");
102
+ process.exit(1);
103
+ }
104
+
105
+ let hasError = false;
106
+ const results: TranscribeResult[] = [];
107
+
108
+ const wantsLangId = !!(args.lang || args.verbose || args.json);
109
+
110
+ for (const file of files) {
111
+ try {
112
+ let audioLanguage: LangDetectResult | undefined;
113
+ if (wantsLangId) {
114
+ const audioResult = await detectAudioLanguageEngine(file);
115
+ if (audioResult && audioResult.code) {
116
+ audioLanguage = audioResult;
117
+ }
118
+ }
119
+
120
+ if (audioLanguage && args.lang && audioLanguage.confidence > 0.8) {
121
+ const mismatch = checkLanguageMismatch(args.lang, audioLanguage.code);
122
+ if (mismatch) log.warn(`${file}: ${mismatch} (from audio)`);
123
+ }
124
+
125
+ const text = await transcribe(file);
126
+
127
+ const tinyldLang = detectLanguage(text);
128
+ let textLanguage: LangDetectResult | undefined;
129
+
130
+ if (wantsLangId) {
131
+ const engineTextResult = await detectTextLanguageEngine(text);
132
+ if (engineTextResult && engineTextResult.code) {
133
+ textLanguage = engineTextResult;
134
+ }
135
+ }
136
+
137
+ const lang = textLanguage?.code || tinyldLang;
138
+
139
+ const mismatchWarning = checkLanguageMismatch(args.lang, lang);
140
+ if (mismatchWarning) log.warn(`${file}: ${mismatchWarning}`);
141
+
142
+ results.push({
143
+ file,
144
+ text,
145
+ lang,
146
+ audioLanguage,
147
+ textLanguage: textLanguage ?? (tinyldLang ? { code: tinyldLang, confidence: 0 } : undefined),
148
+ });
149
+ } catch (err: unknown) {
150
+ hasError = true;
151
+ const message = err instanceof Error ? err.message : String(err);
152
+ log.error(`${file}: ${message}`);
153
+ }
154
+ }
155
+
156
+ if (args.json) {
157
+ process.stdout.write(formatJsonOutput(results));
158
+ } else if (args.verbose) {
159
+ process.stdout.write(formatVerboseOutput(results));
160
+ } else {
161
+ process.stdout.write(formatTextOutput(results));
162
+ }
163
+
164
+ if (hasError) process.exit(1);
165
+ },
166
+ });
167
+
168
+ export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
169
+ const [firstArg, ...restArgs] = rawArgs;
170
+
171
+ if (firstArg === "install") {
172
+ await runMain(installCommand, { rawArgs: restArgs });
173
+ return;
174
+ }
175
+
176
+ if (firstArg === "status") {
177
+ await runMain(statusCommand, { rawArgs: restArgs });
178
+ return;
179
+ }
180
+
181
+ await runMain(mainCommand, { rawArgs });
182
+ }
183
+
184
+ export type TranscribeResult = {
185
+ file: string;
186
+ text: string;
187
+ lang: string;
188
+ audioLanguage?: LangDetectResult;
189
+ textLanguage?: LangDetectResult;
190
+ };
191
+
192
+ export function formatTextOutput(results: TranscribeResult[]): string {
193
+ if (results.length === 1) {
194
+ return results[0].text + "\n";
195
+ }
196
+ return results
197
+ .map((r, i) => (i > 0 ? "\n" : "") + `=== ${r.file} ===\n${r.text}\n`)
198
+ .join("");
199
+ }
200
+
201
+ export function formatVerboseOutput(results: TranscribeResult[]): string {
202
+ return results
203
+ .map((r, i) => {
204
+ const lines: string[] = [];
205
+ if (results.length > 1) {
206
+ if (i > 0) lines.push("");
207
+ lines.push(`=== ${r.file} ===`);
208
+ }
209
+ if (r.audioLanguage) {
210
+ lines.push(`Audio language: ${r.audioLanguage.code} (confidence: ${r.audioLanguage.confidence.toFixed(2)})`);
211
+ }
212
+ const textLang = r.textLanguage ?? (r.lang ? { code: r.lang, confidence: 0 } : null);
213
+ if (textLang) {
214
+ const confStr = textLang.confidence > 0 ? ` (confidence: ${textLang.confidence.toFixed(2)})` : "";
215
+ lines.push(`Text language: ${textLang.code}${confStr}`);
216
+ }
217
+ lines.push("---");
218
+ lines.push(r.text);
219
+ return lines.join("\n");
220
+ })
221
+ .join("\n") + "\n";
222
+ }
223
+
224
+ export function formatJsonOutput(results: TranscribeResult[]): string {
225
+ return JSON.stringify(results, null, 2) + "\n";
226
+ }
227
+
228
+ if (import.meta.main) {
229
+ await runCli();
230
+ }
@@ -0,0 +1,70 @@
1
+ import { dirname } from "path";
2
+ import { existsSync, mkdirSync, chmodSync } from "fs";
3
+ import { getEngineBinPath } from "./engine";
4
+ import { log } from "./log";
5
+ import { streamResponseToFile } from "./progress";
6
+
7
+ const GITHUB_REPO = "drakulavich/kesha-voice-kit";
8
+
9
+ function getEngineBinaryName(): string {
10
+ const platform = process.platform;
11
+ const arch = process.arch;
12
+
13
+ if (platform === "darwin" && arch === "arm64") return "kesha-engine-darwin-arm64";
14
+ if (platform === "linux" && arch === "x64") return "kesha-engine-linux-x64";
15
+ if (platform === "win32" && arch === "x64") return "kesha-engine-windows-x64.exe";
16
+
17
+ throw new Error(`Unsupported platform: ${platform} ${arch}`);
18
+ }
19
+
20
+ export async function downloadEngine(noCache = false): Promise<string> {
21
+ const binPath = getEngineBinPath();
22
+
23
+ if (!noCache && existsSync(binPath)) {
24
+ log.success("Engine binary already installed.");
25
+ } else {
26
+ const binaryName = getEngineBinaryName();
27
+ const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
28
+ const version = typeof pkg.version === "string" ? pkg.version : "unknown";
29
+ const url = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${binaryName}`;
30
+
31
+ mkdirSync(dirname(binPath), { recursive: true });
32
+
33
+ let res: Response;
34
+ try {
35
+ res = await fetch(url, { redirect: "follow" });
36
+ } catch (e) {
37
+ throw new Error(
38
+ `Failed to fetch engine binary: ${e instanceof Error ? e.message : e}\n Fix: Check your network connection and try again`,
39
+ );
40
+ }
41
+
42
+ if (!res.ok) {
43
+ throw new Error(
44
+ `Failed to download engine binary (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
45
+ );
46
+ }
47
+
48
+ await streamResponseToFile(res, binPath, "kesha-engine binary");
49
+ chmodSync(binPath, 0o755);
50
+ log.success("Engine binary downloaded.");
51
+ }
52
+
53
+ log.progress("Installing models...");
54
+ const proc = Bun.spawnSync([binPath, "install", ...(noCache ? ["--no-cache"] : [])], {
55
+ stdout: "pipe",
56
+ stderr: "pipe",
57
+ });
58
+
59
+ if (proc.stderr.toString()) {
60
+ process.stderr.write(proc.stderr.toString());
61
+ }
62
+
63
+ if (proc.exitCode !== 0) {
64
+ const detail = proc.stderr.toString().trim();
65
+ throw new Error(detail ? `Failed to install models: ${detail}` : "Failed to install models");
66
+ }
67
+
68
+ log.success("Backend installed successfully.");
69
+ return binPath;
70
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { join } from "path";
2
+ import { homedir } from "os";
3
+ import { existsSync } from "fs";
4
+
5
+ export interface LangDetectResult {
6
+ code: string;
7
+ confidence: number;
8
+ }
9
+
10
+ export function getEngineBinPath(): string {
11
+ return join(homedir(), ".cache", "kesha", "engine", "bin", "kesha-engine");
12
+ }
13
+
14
+ export function isEngineInstalled(): boolean {
15
+ return existsSync(getEngineBinPath());
16
+ }
17
+
18
+ async function runEngine(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
19
+ const binPath = getEngineBinPath();
20
+ const proc = Bun.spawn([binPath, ...args], {
21
+ stdout: "pipe",
22
+ stderr: "pipe",
23
+ });
24
+
25
+ const [stdout, stderr, exitCode] = await Promise.all([
26
+ new Response(proc.stdout).text(),
27
+ new Response(proc.stderr).text(),
28
+ proc.exited,
29
+ ]);
30
+
31
+ return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
32
+ }
33
+
34
+ export async function transcribeEngine(audioPath: string): Promise<string> {
35
+ const { stdout, stderr, exitCode } = await runEngine(["transcribe", audioPath]);
36
+ if (exitCode !== 0) {
37
+ throw new Error(stderr || `kesha-engine exited with code ${exitCode}`);
38
+ }
39
+ return stdout;
40
+ }
41
+
42
+ export function parseLangResult(stdout: string): LangDetectResult | null {
43
+ try {
44
+ const parsed = JSON.parse(stdout);
45
+ if (typeof parsed.code !== "string" || typeof parsed.confidence !== "number") {
46
+ return null;
47
+ }
48
+ return { code: parsed.code, confidence: parsed.confidence };
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ export async function detectAudioLanguageEngine(audioPath: string): Promise<LangDetectResult | null> {
55
+ if (!isEngineInstalled()) return null;
56
+ const { stdout, exitCode } = await runEngine(["detect-lang", audioPath]);
57
+ if (exitCode !== 0) return null;
58
+ return parseLangResult(stdout);
59
+ }
60
+
61
+ export async function detectTextLanguageEngine(text: string): Promise<LangDetectResult | null> {
62
+ if (!isEngineInstalled()) return null;
63
+ const { stdout, exitCode } = await runEngine(["detect-text-lang", text]);
64
+ if (exitCode !== 0) return null;
65
+ return parseLangResult(stdout);
66
+ }
67
+
68
+ export interface EngineCapabilities {
69
+ protocolVersion: number;
70
+ backend: string;
71
+ features: string[];
72
+ }
73
+
74
+ export async function getEngineCapabilities(): Promise<EngineCapabilities | null> {
75
+ if (!isEngineInstalled()) return null;
76
+ const { stdout, exitCode } = await runEngine(["--capabilities-json"]);
77
+ if (exitCode !== 0) return null;
78
+ try {
79
+ return JSON.parse(stdout) as EngineCapabilities;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
package/src/lang-id.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type { LangDetectResult } from "./engine";
2
+ export { detectAudioLanguageEngine as detectAudioLanguage } from "./engine";
package/src/lib.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { existsSync } from "fs";
2
+ import { transcribe as internalTranscribe, type TranscribeOptions } from "./transcribe";
3
+ import { downloadEngine } from "./engine-install";
4
+
5
+ export type { TranscribeOptions };
6
+ export { downloadEngine as downloadModel };
7
+
8
+ export async function transcribe(
9
+ audioPath: string,
10
+ options: TranscribeOptions = {},
11
+ ): Promise<string> {
12
+ if (!existsSync(audioPath)) {
13
+ throw new Error(`File not found: ${audioPath}`);
14
+ }
15
+
16
+ return internalTranscribe(audioPath, { ...options, silent: true });
17
+ }
package/src/log.ts ADDED
@@ -0,0 +1,9 @@
1
+ import pc from "picocolors";
2
+
3
+ export const log = {
4
+ info: (msg: string) => console.log(msg),
5
+ success: (msg: string) => console.log(pc.green(msg)),
6
+ progress: (msg: string) => console.log(pc.cyan(msg)),
7
+ warn: (msg: string) => console.error(pc.yellow(msg)),
8
+ error: (msg: string) => console.error(pc.red(msg)),
9
+ };
package/src/models.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { isEngineInstalled } from "./engine";
2
+ export { downloadEngine } from "./engine-install";
3
+
4
+ export function isModelInstalled(): boolean {
5
+ return isEngineInstalled();
6
+ }
@@ -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,43 @@
1
+ import { isEngineInstalled, getEngineBinPath, getEngineCapabilities } from "./engine";
2
+ import { log } from "./log";
3
+ import pc from "picocolors";
4
+
5
+ export function formatStatusLine(
6
+ label: string,
7
+ path: string | null,
8
+ installed: boolean,
9
+ missingLabel = "not installed",
10
+ ): string {
11
+ const status = installed ? pc.green("✓") : pc.red(`✗ ${missingLabel}`);
12
+ const pathStr = path ?? "";
13
+ const padding = " ".repeat(Math.max(1, 50 - label.length - pathStr.length));
14
+ return ` ${label}:${pathStr ? ` ${pathStr}` : ""}${padding}${status}`;
15
+ }
16
+
17
+ export async function showStatus(): Promise<void> {
18
+ const binPath = getEngineBinPath();
19
+ const installed = isEngineInstalled();
20
+
21
+ log.info("Engine:");
22
+ log.info(formatStatusLine("Binary", installed ? binPath : null, installed));
23
+
24
+ if (installed) {
25
+ const caps = await getEngineCapabilities();
26
+ if (caps) {
27
+ log.info(formatStatusLine("Backend", caps.backend, true));
28
+ log.info(formatStatusLine("Protocol", `v${caps.protocolVersion}`, true));
29
+ log.info(formatStatusLine("Features", caps.features.join(", "), true));
30
+ } else {
31
+ log.info(formatStatusLine("Capabilities", null, false, "probe failed"));
32
+ }
33
+ }
34
+ log.info("");
35
+
36
+ log.info(formatStatusLine("Runtime", `Bun ${Bun.version}`, true));
37
+ log.info(formatStatusLine("Platform", `${process.platform} ${process.arch}`, true));
38
+ log.info("");
39
+
40
+ if (!installed) {
41
+ log.warn('Run "kesha install" to download the engine and models.');
42
+ }
43
+ }
@@ -0,0 +1,20 @@
1
+ import { isEngineInstalled, transcribeEngine } from "./engine";
2
+
3
+ export interface TranscribeOptions {
4
+ silent?: boolean;
5
+ }
6
+
7
+ export async function transcribe(audioPath: string, _opts: TranscribeOptions = {}): Promise<string> {
8
+ if (!isEngineInstalled()) {
9
+ throw new Error(
10
+ "Error: No transcription backend is installed\n\n" +
11
+ "╔══════════════════════════════════════════════════════════╗\n" +
12
+ "║ Please run the following command to get started: ║\n" +
13
+ "║ ║\n" +
14
+ "║ bunx @drakulavich/kesha-voice-kit install ║\n" +
15
+ "╚══════════════════════════════════════════════════════════╝",
16
+ );
17
+ }
18
+
19
+ return transcribeEngine(audioPath);
20
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "types": ["bun-types"],
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "./dist",
10
+ "rootDir": "."
11
+ },
12
+ "include": ["src/**/*.ts", "tests/**/*.ts"]
13
+ }