@drakulavich/kesha-voice-kit 1.3.0 → 1.4.1

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/src/cli/say.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { defineCommand } from "citty";
2
+ import { detectTextLanguageEngine, getEngineBinPath } from "../engine";
3
+ import { log } from "../log";
4
+ import { say, SayError } from "../say";
5
+
6
+ /** Map a detected language code to a default voice id. Unknown / low-confidence → undefined. */
7
+ export function pickVoiceForLang(
8
+ code: string | undefined,
9
+ confidence: number,
10
+ ): string | undefined {
11
+ if (!code || confidence < 0.5) return undefined;
12
+ switch (code) {
13
+ case "en":
14
+ return "en-af_heart";
15
+ case "ru":
16
+ return "ru-denis";
17
+ default:
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ /** Run NLLanguageRecognizer (via engine) on the text and pick a default voice. */
23
+ async function autoRouteVoice(text: string): Promise<string | undefined> {
24
+ if (!text) return undefined;
25
+ const detected = await detectTextLanguageEngine(text);
26
+ return pickVoiceForLang(detected?.code, detected?.confidence ?? 0);
27
+ }
28
+
29
+ /** Resolve the text to synthesize: inline positional, else read from stdin. */
30
+ async function resolveText(inline: string | undefined): Promise<string> {
31
+ if (inline !== undefined && inline.length > 0) return inline;
32
+ const chunks: Uint8Array[] = [];
33
+ for await (const chunk of Bun.stdin.stream()) {
34
+ chunks.push(chunk);
35
+ }
36
+ const total = chunks.reduce((n, c) => n + c.byteLength, 0);
37
+ const merged = new Uint8Array(total);
38
+ let offset = 0;
39
+ for (const c of chunks) {
40
+ merged.set(c, offset);
41
+ offset += c.byteLength;
42
+ }
43
+ return new TextDecoder().decode(merged).trim();
44
+ }
45
+
46
+ export const sayCommand = defineCommand({
47
+ meta: {
48
+ name: "say",
49
+ description: "Synthesize speech from text (TTS). Writes WAV to stdout (or --out file).",
50
+ },
51
+ args: {
52
+ text: { type: "positional", required: false, description: "Text to speak (stdin if omitted)" },
53
+ voice: { type: "string", description: "Voice id, e.g. en-af_heart" },
54
+ lang: { type: "string", description: "BCP 47 language code (default en-us)" },
55
+ out: { type: "string", description: "Write WAV to file instead of stdout" },
56
+ rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
57
+ "list-voices": { type: "boolean", description: "List installed voices and exit" },
58
+ ssml: {
59
+ type: "boolean",
60
+ description: "Parse input as SSML (supports <speak>, <break>; strips unknown tags)",
61
+ },
62
+ verbose: {
63
+ type: "boolean",
64
+ description: "Log TTS synthesis time to stderr",
65
+ default: false,
66
+ },
67
+ debug: {
68
+ type: "boolean",
69
+ description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
70
+ default: false,
71
+ },
72
+ },
73
+ async run({ args }) {
74
+ if (args.debug) log.debugEnabled = true;
75
+ if (args["list-voices"]) {
76
+ // The engine prints the list directly — just relay its stdout + exit code.
77
+ const proc = Bun.spawn([getEngineBinPath(), "say", "--list-voices"], {
78
+ stdout: "inherit",
79
+ stderr: "inherit",
80
+ });
81
+ process.exit(await proc.exited);
82
+ }
83
+
84
+ const inlineText = typeof args.text === "string" ? args.text : undefined;
85
+ const text = await resolveText(inlineText);
86
+ const explicitVoice = typeof args.voice === "string" ? args.voice : undefined;
87
+ const voice = explicitVoice ?? (await autoRouteVoice(text));
88
+
89
+ const opts = {
90
+ text,
91
+ voice,
92
+ lang: typeof args.lang === "string" ? args.lang : undefined,
93
+ out: typeof args.out === "string" ? args.out : undefined,
94
+ rate: args.rate ? Number(args.rate) : undefined,
95
+ ssml: Boolean(args.ssml),
96
+ };
97
+
98
+ try {
99
+ const startedAt = performance.now();
100
+ const wav = await say(opts);
101
+ const ttsTimeMs = Math.round(performance.now() - startedAt);
102
+ if (args.verbose) {
103
+ // stderr — stdout may carry raw WAV bytes when --out is omitted.
104
+ console.error(`TTS time: ${ttsTimeMs}ms`);
105
+ }
106
+ if (!opts.out) {
107
+ process.stdout.write(wav);
108
+ }
109
+ } catch (err) {
110
+ if (err instanceof SayError) {
111
+ log.error(err.stderr.trim() || err.message);
112
+ process.exit(err.exitCode);
113
+ }
114
+ const message = err instanceof Error ? err.message : String(err);
115
+ log.error(message);
116
+ process.exit(4);
117
+ }
118
+ },
119
+ });
@@ -0,0 +1,12 @@
1
+ import { defineCommand } from "citty";
2
+ import { showStatus } from "../status";
3
+
4
+ export const statusCommand = defineCommand({
5
+ meta: {
6
+ name: "status",
7
+ description: "Show backend installation status",
8
+ },
9
+ async run() {
10
+ await showStatus();
11
+ },
12
+ });
package/src/cli.ts CHANGED
@@ -1,468 +1,24 @@
1
1
  #!/usr/bin/env bun
2
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, getEngineBinPath } from "./engine";
8
- import { readStarSeen, shouldShowStarPrompt, writeStarSeen } from "./star";
9
- import type { LangDetectResult } from "./engine";
10
- import { log } from "./log";
11
- import { say, SayError } from "./say";
12
- import { showStatus } from "./status";
13
- import { suggestCommand } from "./suggest-command";
14
-
15
- export function detectLanguage(text: string): string {
16
- if (!text) return "";
17
- return detect(text);
18
- }
19
-
20
- export function checkLanguageMismatch(expected: string | undefined, detected: string): string | null {
21
- if (!expected || !detected || expected === detected) return null;
22
- return `warning: expected language "${expected}" but detected "${detected}"`;
23
- }
24
-
25
- interface InstallCommandArgs {
26
- coreml: boolean;
27
- onnx: boolean;
28
- "no-cache": boolean;
29
- tts: boolean;
30
- }
31
-
32
- interface MainCommandArgs {
33
- _: string[];
34
- json: boolean;
35
- verbose: boolean;
36
- debug: boolean;
37
- format?: string;
38
- lang?: string;
39
- }
40
-
41
- const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
42
-
43
- function resolveBackendFlag(coreml: boolean, onnx: boolean): string | undefined {
44
- if (coreml && onnx) {
45
- log.error('Choose only one backend: "--coreml" or "--onnx".');
46
- process.exit(1);
47
- }
48
- if (coreml) return "coreml";
49
- if (onnx) return "onnx";
50
- return undefined;
51
- }
52
-
53
- async function askForStar() {
54
- // Gate on major-or-minor bump only — patch releases and re-installs of the
55
- // same version shouldn't nag. First-ever install (no marker) still prompts.
56
- const currentVersion = typeof pkg.version === "string" ? pkg.version : null;
57
- if (!currentVersion) return;
58
- const binPath = getEngineBinPath();
59
- const seen = readStarSeen(binPath);
60
- if (!shouldShowStarPrompt(currentVersion, seen)) {
61
- return;
62
- }
63
- // Record the version up front so a single run never prompts twice, even
64
- // if the gh subprocess below throws.
65
- try {
66
- writeStarSeen(binPath, currentVersion);
67
- } catch {
68
- // Non-fatal — falling through to the prompt is still OK, just means we
69
- // may nag again on the next install if the write failed for IO reasons.
70
- }
71
-
72
- const gh = Bun.which("gh");
73
- if (!gh) {
74
- log.info("\nIf you enjoy Kesha Voice Kit, consider starring the repo:");
75
- log.info(" https://github.com/drakulavich/kesha-voice-kit");
76
- return;
77
- }
78
- const authCheck = Bun.spawnSync([gh, "auth", "status"], { stdout: "ignore", stderr: "ignore" });
79
- if (authCheck.exitCode !== 0) return;
80
- const starred = Bun.spawnSync([gh, "api", "user/starred/drakulavich/kesha-voice-kit"], { stdout: "ignore", stderr: "ignore" });
81
- if (starred.exitCode === 0) return; // already starred
82
- log.info("\n⭐ If you enjoy Kesha Voice Kit, star it on GitHub:");
83
- log.info(" https://github.com/drakulavich/kesha-voice-kit");
84
- log.info(' Or run: gh api -X PUT /user/starred/drakulavich/kesha-voice-kit');
85
- }
86
-
87
- async function performInstall(noCache: boolean, backend?: string, tts = false) {
88
- try {
89
- await downloadEngine(noCache, backend, { tts });
90
- await askForStar();
91
- } catch (err: unknown) {
92
- const message = err instanceof Error ? err.message : String(err);
93
- log.error(message);
94
- process.exit(1);
95
- }
96
- }
97
-
98
- export const installCommand = defineCommand({
99
- meta: {
100
- name: "install",
101
- description: "Download inference engine and models",
102
- },
103
- args: {
104
- coreml: {
105
- type: "boolean",
106
- description: "Force CoreML backend (macOS arm64)",
107
- default: false,
108
- },
109
- onnx: {
110
- type: "boolean",
111
- description: "Force ONNX backend",
112
- default: false,
113
- },
114
- "no-cache": {
115
- type: "boolean",
116
- description: "Re-download even if cached",
117
- default: false,
118
- },
119
- tts: {
120
- type: "boolean",
121
- description: "Also install TTS models (Kokoro EN + Piper RU, ~390MB, requires espeak-ng on PATH)",
122
- default: false,
123
- },
124
- },
125
- async run({ args }: { args: InstallCommandArgs }) {
126
- const backend = resolveBackendFlag(args.coreml, args.onnx);
127
- await performInstall(args["no-cache"], backend, args.tts);
128
- },
129
- });
130
-
131
- export const sayCommand = defineCommand({
132
- meta: {
133
- name: "say",
134
- description: "Synthesize speech from text (TTS). Writes WAV to stdout (or --out file).",
135
- },
136
- args: {
137
- text: { type: "positional", required: false, description: "Text to speak (stdin if omitted)" },
138
- voice: { type: "string", description: "Voice id, e.g. en-af_heart" },
139
- lang: { type: "string", description: "espeak language code (default en-us)" },
140
- out: { type: "string", description: "Write WAV to file instead of stdout" },
141
- rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
142
- "list-voices": { type: "boolean", description: "List installed voices and exit" },
143
- ssml: {
144
- type: "boolean",
145
- description: "Parse input as SSML (supports <speak>, <break>; strips unknown tags)",
146
- },
147
- verbose: {
148
- type: "boolean",
149
- description: "Log TTS synthesis time to stderr",
150
- default: false,
151
- },
152
- debug: {
153
- type: "boolean",
154
- description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
155
- default: false,
156
- },
157
- },
158
- async run({ args }) {
159
- if (args.debug) log.debugEnabled = true;
160
- if (args["list-voices"]) {
161
- // The engine prints the list directly — just relay its stdout + exit code.
162
- const { getEngineBinPath } = await import("./engine");
163
- const proc = Bun.spawn([getEngineBinPath(), "say", "--list-voices"], {
164
- stdout: "inherit",
165
- stderr: "inherit",
166
- });
167
- process.exit(await proc.exited);
168
- }
169
-
170
- const inlineText = typeof args.text === "string" ? args.text : undefined;
171
- const text = await resolveText(inlineText);
172
- const explicitVoice = typeof args.voice === "string" ? args.voice : undefined;
173
- const voice = explicitVoice ?? (await autoRouteVoice(text));
174
-
175
- const opts = {
176
- text,
177
- voice,
178
- lang: typeof args.lang === "string" ? args.lang : undefined,
179
- out: typeof args.out === "string" ? args.out : undefined,
180
- rate: args.rate ? Number(args.rate) : undefined,
181
- ssml: Boolean(args.ssml),
182
- };
183
-
184
- try {
185
- const startedAt = performance.now();
186
- const wav = await say(opts);
187
- const ttsTimeMs = Math.round(performance.now() - startedAt);
188
- if (args.verbose) {
189
- // stderr — stdout may carry raw WAV bytes when --out is omitted.
190
- console.error(`TTS time: ${ttsTimeMs}ms`);
191
- }
192
- if (!opts.out) {
193
- process.stdout.write(wav);
194
- }
195
- } catch (err) {
196
- if (err instanceof SayError) {
197
- log.error(err.stderr.trim() || err.message);
198
- process.exit(err.exitCode);
199
- }
200
- const message = err instanceof Error ? err.message : String(err);
201
- log.error(message);
202
- process.exit(4);
203
- }
204
- },
205
- });
206
-
207
- /** Resolve the text to synthesize: inline positional, else read from stdin. */
208
- async function resolveText(inline: string | undefined): Promise<string> {
209
- if (inline !== undefined && inline.length > 0) return inline;
210
- const chunks: Uint8Array[] = [];
211
- for await (const chunk of Bun.stdin.stream()) {
212
- chunks.push(chunk);
213
- }
214
- const total = chunks.reduce((n, c) => n + c.byteLength, 0);
215
- const merged = new Uint8Array(total);
216
- let offset = 0;
217
- for (const c of chunks) {
218
- merged.set(c, offset);
219
- offset += c.byteLength;
220
- }
221
- return new TextDecoder().decode(merged).trim();
222
- }
223
-
224
- /** Map a detected language code to a default voice id. Unknown / low-confidence → undefined. */
225
- export function pickVoiceForLang(
226
- code: string | undefined,
227
- confidence: number,
228
- ): string | undefined {
229
- if (!code || confidence < 0.5) return undefined;
230
- switch (code) {
231
- case "en":
232
- return "en-af_heart";
233
- case "ru":
234
- return "ru-denis";
235
- default:
236
- return undefined;
237
- }
238
- }
239
-
240
- /** Run NLLanguageRecognizer (via engine) on the text and pick a default voice. */
241
- async function autoRouteVoice(text: string): Promise<string | undefined> {
242
- if (!text) return undefined;
243
- const detected = await detectTextLanguageEngine(text);
244
- return pickVoiceForLang(detected?.code, detected?.confidence ?? 0);
245
- }
246
-
247
- export const statusCommand = defineCommand({
248
- meta: {
249
- name: "status",
250
- description: "Show backend installation status",
251
- },
252
- async run() {
253
- await showStatus();
254
- },
255
- });
256
-
257
- export const mainCommand = defineCommand({
258
- meta: {
259
- name: "kesha",
260
- version: pkg.version,
261
- description:
262
- "Kesha Voice Kit — open-source voice toolkit for Apple Silicon.\n" +
263
- " Run 'kesha install [--no-cache]' to download engine and models.\n" +
264
- " Run 'kesha status' to inspect installed backend.",
265
- },
266
- args: {
267
- json: {
268
- type: "boolean",
269
- description: "Output results as JSON",
270
- default: false,
271
- },
272
- verbose: {
273
- type: "boolean",
274
- description: "Show language detection details",
275
- default: false,
276
- },
277
- format: {
278
- type: "string",
279
- description: "Output format: transcript (enriched text with lang/confidence)",
280
- },
281
- lang: {
282
- type: "string",
283
- description: "Expected language code (ISO 639-1), warn if mismatch",
284
- },
285
- debug: {
286
- type: "boolean",
287
- description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
288
- default: false,
289
- },
290
- },
291
- async run({ args }: { args: MainCommandArgs }) {
292
- if (args.debug) log.debugEnabled = true;
293
- const files = args._;
294
-
295
- if (files.length === 0) {
296
- log.info("Usage: kesha <audio_file> [audio_file ...]\n kesha install [--no-cache]\n kesha status");
297
- process.exit(1);
298
- }
299
-
300
- let hasError = false;
301
- const results: TranscribeResult[] = [];
302
-
303
- const wantsLangId = !!(args.lang || args.verbose || args.json || args.format === "transcript" || args.format === "json");
304
-
305
- for (const file of files) {
306
- const startedAt = performance.now();
307
- try {
308
- // Run audio lang-id and transcription concurrently
309
- const [audioResult, text] = await Promise.all([
310
- wantsLangId ? detectAudioLanguageEngine(file) : Promise.resolve(null),
311
- transcribe(file),
312
- ]);
313
-
314
- let audioLanguage: LangDetectResult | undefined;
315
- if (audioResult && audioResult.code) {
316
- audioLanguage = audioResult;
317
- }
318
-
319
- if (audioLanguage && args.lang && audioLanguage.confidence > 0.8) {
320
- const mismatch = checkLanguageMismatch(args.lang, audioLanguage.code);
321
- if (mismatch) log.warn(`${file}: ${mismatch} (from audio)`);
322
- }
323
-
324
- const tinyldLang = wantsLangId ? detectLanguage(text) : "";
325
- let textLanguage: LangDetectResult | undefined;
326
-
327
- if (wantsLangId) {
328
- const engineTextResult = await detectTextLanguageEngine(text);
329
- if (engineTextResult && engineTextResult.code) {
330
- textLanguage = engineTextResult;
331
- }
332
- }
333
-
334
- const lang = textLanguage?.code || tinyldLang;
335
-
336
- const mismatchWarning = checkLanguageMismatch(args.lang, lang);
337
- if (mismatchWarning) log.warn(`${file}: ${mismatchWarning}`);
338
-
339
- results.push({
340
- file,
341
- text,
342
- lang,
343
- audioLanguage,
344
- textLanguage: textLanguage ?? (tinyldLang ? { code: tinyldLang, confidence: 0 } : undefined),
345
- sttTimeMs: Math.round(performance.now() - startedAt),
346
- });
347
- } catch (err: unknown) {
348
- hasError = true;
349
- const message = err instanceof Error ? err.message : String(err);
350
- log.error(`${file}: ${message}`);
351
- }
352
- }
353
-
354
- if (args.json || args.format === "json") {
355
- process.stdout.write(formatJsonOutput(results));
356
- } else if (args.format === "transcript") {
357
- process.stdout.write(formatTranscriptOutput(results));
358
- } else if (args.verbose) {
359
- process.stdout.write(formatVerboseOutput(results));
360
- } else {
361
- process.stdout.write(formatTextOutput(results));
362
- }
363
-
364
- if (hasError) process.exit(1);
365
- },
366
- });
367
-
368
- const SUBCOMMANDS = ["install", "status", "say"];
369
-
370
- export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
371
- const [firstArg, ...restArgs] = rawArgs;
372
-
373
- if (firstArg === "install") {
374
- await runMain(installCommand, { rawArgs: restArgs });
375
- return;
376
- }
377
-
378
- if (firstArg === "status") {
379
- await runMain(statusCommand, { rawArgs: restArgs });
380
- return;
381
- }
382
-
383
- if (firstArg === "say") {
384
- await runMain(sayCommand, { rawArgs: restArgs });
385
- return;
386
- }
387
-
388
- // Check for unknown subcommands (non-flag, non-file-path args)
389
- if (firstArg && !firstArg.startsWith("-") && !firstArg.includes(".") && !firstArg.includes("/")) {
390
- const suggestion = suggestCommand(firstArg, SUBCOMMANDS);
391
- if (suggestion && suggestion !== firstArg) {
392
- log.error(`unknown command '${firstArg}'`);
393
- log.warn(`(Did you mean ${suggestion}?)`);
394
- process.exit(1);
395
- }
396
- }
397
-
398
- await runMain(mainCommand, { rawArgs });
399
- }
400
-
401
- export type TranscribeResult = {
402
- file: string;
403
- text: string;
404
- lang: string;
405
- audioLanguage?: LangDetectResult;
406
- textLanguage?: LangDetectResult;
407
- /** Wall-clock time around the engine subprocess calls for this file, ms. See #139. */
408
- sttTimeMs?: number;
409
- };
410
-
411
- export function formatTextOutput(results: TranscribeResult[]): string {
412
- if (results.length === 1) {
413
- return results[0].text + "\n";
414
- }
415
- return results
416
- .map((r, i) => (i > 0 ? "\n" : "") + `=== ${r.file} ===\n${r.text}\n`)
417
- .join("");
418
- }
419
-
420
- export function formatVerboseOutput(results: TranscribeResult[]): string {
421
- return results
422
- .map((r, i) => {
423
- const lines: string[] = [];
424
- if (results.length > 1) {
425
- if (i > 0) lines.push("");
426
- lines.push(`=== ${r.file} ===`);
427
- }
428
- if (r.audioLanguage) {
429
- lines.push(`Audio language: ${r.audioLanguage.code} (confidence: ${r.audioLanguage.confidence.toFixed(2)})`);
430
- }
431
- const textLang = r.textLanguage ?? (r.lang ? { code: r.lang, confidence: 0 } : null);
432
- if (textLang) {
433
- const confStr = textLang.confidence > 0 ? ` (confidence: ${textLang.confidence.toFixed(2)})` : "";
434
- lines.push(`Text language: ${textLang.code}${confStr}`);
435
- }
436
- if (r.sttTimeMs !== undefined) {
437
- lines.push(`STT time: ${r.sttTimeMs}ms`);
438
- }
439
- lines.push("---");
440
- lines.push(r.text);
441
- return lines.join("\n");
442
- })
443
- .join("\n") + "\n";
444
- }
445
-
446
- export function formatTranscriptOutput(results: TranscribeResult[]): string {
447
- return results
448
- .map((r, i) => {
449
- const lines: string[] = [];
450
- if (results.length > 1) {
451
- if (i > 0) lines.push("");
452
- lines.push(`=== ${r.file} ===`);
453
- }
454
- lines.push(r.text);
455
- const lang = r.textLanguage?.code || r.audioLanguage?.code || r.lang;
456
- const confidence = r.textLanguage?.confidence ?? r.audioLanguage?.confidence;
457
- if (lang) lines.push(`[lang: ${lang}${confidence != null ? `, confidence: ${confidence.toFixed(2)}` : ""}]`);
458
- return lines.join("\n");
459
- })
460
- .join("\n") + "\n";
461
- }
462
-
463
- export function formatJsonOutput(results: TranscribeResult[]): string {
464
- return JSON.stringify(results, null, 2) + "\n";
465
- }
3
+ // Thin re-export shim. The CLI subcommands live in ./cli/*.ts since #180;
4
+ // tests and lib.ts keep importing from "./cli" via these re-exports so the
5
+ // public surface is unchanged.
6
+ export { installCommand } from "./cli/install";
7
+ export { sayCommand, pickVoiceForLang } from "./cli/say";
8
+ export { statusCommand } from "./cli/status";
9
+ export { mainCommand, detectLanguage, checkLanguageMismatch } from "./cli/main";
10
+ export { runCli } from "./cli/dispatch";
11
+
12
+ export type { TranscribeResult } from "./types";
13
+ export {
14
+ formatJsonOutput,
15
+ formatTextOutput,
16
+ formatTranscriptOutput,
17
+ formatVerboseOutput,
18
+ } from "./format";
19
+ export { formatToonOutput } from "./toon";
20
+
21
+ import { runCli } from "./cli/dispatch";
466
22
 
467
23
  if (import.meta.main) {
468
24
  await runCli();