@drakulavich/kesha-voice-kit 1.3.0 → 1.4.2

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.
@@ -76,14 +76,29 @@ async function downloadAVSpeechSidecar(binPath: string, engineVersion: string):
76
76
  return;
77
77
  }
78
78
 
79
- await streamResponseToFile(res, sidecarPath, "say-avspeech sidecar");
80
- chmodSync(sidecarPath, 0o755);
81
- log.success("AVSpeech sidecar installed (macOS voices available).");
79
+ // Keep the best-effort contract: streamResponseToFile throws on an empty
80
+ // body and can fail mid-stream, and chmodSync can throw EPERM. Without
81
+ // this catch a stream/chmod failure would propagate through the tail
82
+ // `await sidecarPromise` in downloadEngine — converting a successful
83
+ // engine install into a thrown exception after log.success already
84
+ // announced it, which is exactly the regression the fetch/404 branches
85
+ // above protect against.
86
+ try {
87
+ await streamResponseToFile(res, sidecarPath, "say-avspeech sidecar");
88
+ chmodSync(sidecarPath, 0o755);
89
+ log.success("AVSpeech sidecar installed (macOS voices available).");
90
+ } catch (e) {
91
+ log.warn(
92
+ `AVSpeech sidecar install failed (${e instanceof Error ? e.message : e}); macos-* voices unavailable.`,
93
+ );
94
+ }
82
95
  }
83
96
 
84
97
  export interface InstallOptions {
85
- /** Also install Kokoro TTS models. Requires espeak-ng on PATH. */
98
+ /** Also install Kokoro + Piper + ONNX G2P TTS models. */
86
99
  tts?: boolean;
100
+ /** Also install Silero VAD model for long-audio preprocessing. */
101
+ vad?: boolean;
87
102
  }
88
103
 
89
104
  export async function downloadEngine(
@@ -128,16 +143,32 @@ export async function downloadEngine(
128
143
 
129
144
  mkdirSync(dirname(binPath), { recursive: true });
130
145
 
146
+ // Kick off the sidecar fetch concurrently with the engine fetch. Both
147
+ // target github.com release assets on independent paths with no data
148
+ // dependency, so overlapping the HTTP round-trips saves ~15-30s on a
149
+ // cold install. Sidecar is best-effort (404 on older engines, warn +
150
+ // continue) so a failure doesn't cascade into the engine path.
151
+ const sidecarPromise = downloadAVSpeechSidecar(binPath, engineVersion);
152
+
131
153
  let res: Response;
132
154
  try {
133
155
  res = await fetch(url, { redirect: "follow" });
134
156
  } catch (e) {
157
+ // Attach a no-op rejection handler to the sidecar promise as
158
+ // defense-in-depth. Today it can't reject (downloadAVSpeechSidecar
159
+ // catches all of its own errors), but if that contract ever drifts
160
+ // a bare dangling promise would surface as an unhandledRejection
161
+ // while we're throwing the engine error. Not waiting — the engine
162
+ // error is what the user needs to see now; sidecar's own logs
163
+ // will print whenever they finish.
164
+ sidecarPromise.catch(() => {});
135
165
  throw new Error(
136
166
  `Failed to fetch engine binary: ${e instanceof Error ? e.message : e}\n Fix: Check your network connection and try again`,
137
167
  );
138
168
  }
139
169
 
140
170
  if (!res.ok) {
171
+ sidecarPromise.catch(() => {});
141
172
  throw new Error(
142
173
  `Failed to download engine binary (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
143
174
  );
@@ -147,7 +178,7 @@ export async function downloadEngine(
147
178
  chmodSync(binPath, 0o755);
148
179
  writeInstalledEngineVersion(binPath, engineVersion);
149
180
  log.success(`Engine binary downloaded (v${engineVersion}).`);
150
- await downloadAVSpeechSidecar(binPath, engineVersion);
181
+ await sidecarPromise;
151
182
  }
152
183
 
153
184
  if (backend) {
@@ -164,6 +195,7 @@ export async function downloadEngine(
164
195
  "install",
165
196
  ...(noCache ? ["--no-cache"] : []),
166
197
  ...(options.tts ? ["--tts"] : []),
198
+ ...(options.vad ? ["--vad"] : []),
167
199
  ];
168
200
  const proc = Bun.spawnSync([binPath, ...installArgs], {
169
201
  stdout: "pipe",
package/src/engine.ts CHANGED
@@ -42,8 +42,25 @@ async function runEngine(args: string[]): Promise<{ stdout: string; stderr: stri
42
42
  return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
43
43
  }
44
44
 
45
- export async function transcribeEngine(audioPath: string): Promise<string> {
46
- const { stdout, stderr, exitCode } = await runEngine(["transcribe", audioPath]);
45
+ /** VAD preprocessing selector.
46
+ * - `"auto"` (default): engine decides — VAD when audio ≥ 120 s and model installed
47
+ * - `"on"`: force VAD (requires `kesha install --vad`)
48
+ * - `"off"`: force full-file pass regardless of duration or install state
49
+ */
50
+ export type VadMode = "auto" | "on" | "off";
51
+
52
+ export interface TranscribeEngineOptions {
53
+ vad?: VadMode;
54
+ }
55
+
56
+ export async function transcribeEngine(
57
+ audioPath: string,
58
+ opts: TranscribeEngineOptions = {},
59
+ ): Promise<string> {
60
+ const args = ["transcribe", audioPath];
61
+ if (opts.vad === "on") args.push("--vad");
62
+ else if (opts.vad === "off") args.push("--no-vad");
63
+ const { stdout, stderr, exitCode } = await runEngine(args);
47
64
  if (exitCode !== 0) {
48
65
  throw new Error(stderr || `kesha-engine exited with code ${exitCode}`);
49
66
  }
package/src/format.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { TranscribeResult } from "./types";
2
+
3
+ export function formatTextOutput(results: TranscribeResult[]): string {
4
+ if (results.length === 1) {
5
+ return results[0].text + "\n";
6
+ }
7
+ return results
8
+ .map((r, i) => (i > 0 ? "\n" : "") + `=== ${r.file} ===\n${r.text}\n`)
9
+ .join("");
10
+ }
11
+
12
+ export function formatVerboseOutput(results: TranscribeResult[]): string {
13
+ return results
14
+ .map((r, i) => {
15
+ const lines: string[] = [];
16
+ if (results.length > 1) {
17
+ if (i > 0) lines.push("");
18
+ lines.push(`=== ${r.file} ===`);
19
+ }
20
+ if (r.audioLanguage) {
21
+ lines.push(`Audio language: ${r.audioLanguage.code} (confidence: ${r.audioLanguage.confidence.toFixed(2)})`);
22
+ }
23
+ const textLang = r.textLanguage ?? (r.lang ? { code: r.lang, confidence: 0 } : null);
24
+ if (textLang) {
25
+ const confStr = textLang.confidence > 0 ? ` (confidence: ${textLang.confidence.toFixed(2)})` : "";
26
+ lines.push(`Text language: ${textLang.code}${confStr}`);
27
+ }
28
+ if (r.sttTimeMs !== undefined) {
29
+ lines.push(`STT time: ${r.sttTimeMs}ms`);
30
+ }
31
+ lines.push("---");
32
+ lines.push(r.text);
33
+ return lines.join("\n");
34
+ })
35
+ .join("\n") + "\n";
36
+ }
37
+
38
+ export function formatTranscriptOutput(results: TranscribeResult[]): string {
39
+ return results
40
+ .map((r, i) => {
41
+ const lines: string[] = [];
42
+ if (results.length > 1) {
43
+ if (i > 0) lines.push("");
44
+ lines.push(`=== ${r.file} ===`);
45
+ }
46
+ lines.push(r.text);
47
+ const lang = r.textLanguage?.code || r.audioLanguage?.code || r.lang;
48
+ const confidence = r.textLanguage?.confidence ?? r.audioLanguage?.confidence;
49
+ if (lang) lines.push(`[lang: ${lang}${confidence != null ? `, confidence: ${confidence.toFixed(2)}` : ""}]`);
50
+ return lines.join("\n");
51
+ })
52
+ .join("\n") + "\n";
53
+ }
54
+
55
+ export function formatJsonOutput(results: TranscribeResult[]): string {
56
+ return JSON.stringify(results, null, 2) + "\n";
57
+ }
package/src/lib.ts CHANGED
@@ -6,6 +6,20 @@ export type { TranscribeOptions };
6
6
  export { downloadEngine as downloadModel };
7
7
  export { say, type SayOptions, SayError } from "./say";
8
8
 
9
+ /**
10
+ * Encode a `TranscribeResult[]` as TOON (#138). Same data shape as the
11
+ * `--json` / `--toon` CLI output; the CLI reads from stdin of a transcribe
12
+ * run, this helper is for programmatic callers that already have the array.
13
+ */
14
+ export { formatToonOutput as toToon } from "./toon";
15
+
16
+ /**
17
+ * Output shape returned by `kesha --json` and the input shape expected by
18
+ * `toToon`. Lives in `./types` (since #179) so the public API stops
19
+ * reaching into the CLI-layer file.
20
+ */
21
+ export type { TranscribeResult } from "./types";
22
+
9
23
  /** Install Kokoro TTS models. Shorthand for `downloadModel({ tts: true })`. */
10
24
  export async function downloadTts(noCache = false): Promise<void> {
11
25
  await downloadEngine(noCache, undefined, { tts: true });
package/src/say.ts CHANGED
@@ -10,7 +10,7 @@ export interface SayOptions {
10
10
  text?: string;
11
11
  /** Voice id, e.g. `en-af_heart`. Defaults to engine default. */
12
12
  voice?: string;
13
- /** Override the voice's default espeak language code. */
13
+ /** Override the voice's default BCP 47 language code (e.g. `en-us`, `ru`). */
14
14
  lang?: string;
15
15
  /** Write audio to this path instead of returning bytes. */
16
16
  out?: string;
package/src/status.ts CHANGED
@@ -1,10 +1,37 @@
1
- import { readdirSync } from "fs";
1
+ import { readdirSync, statSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
4
  import { isEngineInstalled, getEngineBinPath, getEngineCapabilities } from "./engine";
5
5
  import { log } from "./log";
6
6
  import pc from "picocolors";
7
7
 
8
+ function humanBytes(bytes: number): string {
9
+ if (bytes < 1024) return `${bytes} B`;
10
+ const units = ["KB", "MB", "GB"];
11
+ let n = bytes / 1024;
12
+ let i = 0;
13
+ while (n >= 1024 && i < units.length - 1) {
14
+ n /= 1024;
15
+ i++;
16
+ }
17
+ return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
18
+ }
19
+
20
+ function dirSizeBytes(path: string): number {
21
+ let total = 0;
22
+ try {
23
+ const st = statSync(path);
24
+ if (st.isFile()) return st.size;
25
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
26
+ const p = join(path, entry.name);
27
+ total += entry.isDirectory() ? dirSizeBytes(p) : statSync(p).size;
28
+ }
29
+ } catch {
30
+ /* missing path — component not installed */
31
+ }
32
+ return total;
33
+ }
34
+
8
35
  export function formatStatusLine(
9
36
  label: string,
10
37
  path: string | null,
@@ -38,6 +65,10 @@ export async function showStatus(): Promise<void> {
38
65
 
39
66
  log.info(formatStatusLine("Runtime", `Bun ${Bun.version}`, true));
40
67
  log.info(formatStatusLine("Platform", `${process.platform} ${process.arch}`, true));
68
+ const mirror = activeModelMirror();
69
+ if (mirror) {
70
+ log.info(formatStatusLine("Mirror", mirror, true));
71
+ }
41
72
  log.info("");
42
73
 
43
74
  if (installed) {
@@ -49,6 +80,8 @@ export async function showStatus(): Promise<void> {
49
80
  }
50
81
  log.info("");
51
82
  }
83
+
84
+ showDiskUsage(binPath);
52
85
  }
53
86
 
54
87
  if (!installed) {
@@ -56,10 +89,78 @@ export async function showStatus(): Promise<void> {
56
89
  }
57
90
  }
58
91
 
92
+ function showDiskUsage(binPath: string): void {
93
+ const cache = kesheCacheDir();
94
+ // Engine binary lives under `<cache>/engine/bin/` (managed by the TS CLI's
95
+ // engine-install) while all models live under `<cache>/models/` (managed by
96
+ // the Rust engine). Point at `engine/` (two levels up from the binary) so
97
+ // any future sibling files under that root (metadata, hooks, etc.) are
98
+ // counted too.
99
+ const engineDir = join(binPath, "..", "..");
100
+
101
+ const components: Array<{ label: string; path: string }> = [
102
+ { label: "Engine", path: engineDir },
103
+ { label: "ASR (Parakeet)", path: join(cache, "models/parakeet-tdt-v3") },
104
+ { label: "Language ID", path: join(cache, "models/lang-id-ecapa") },
105
+ { label: "VAD (Silero)", path: join(cache, "models/silero-vad") },
106
+ { label: "TTS (Kokoro)", path: join(cache, "models/kokoro-82m") },
107
+ { label: "TTS (Piper)", path: join(cache, "models/piper-ru") },
108
+ { label: "TTS (G2P)", path: join(cache, "models/g2p") },
109
+ ];
110
+
111
+ const rows: Array<{ label: string; size: number }> = [];
112
+ for (const c of components) {
113
+ const size = dirSizeBytes(c.path);
114
+ if (size > 0) rows.push({ label: c.label, size });
115
+ }
116
+
117
+ if (rows.length === 0) return;
118
+
119
+ // Total counts everything under both the model cache root AND the engine
120
+ // dir (which may live outside the cache when `KESHA_ENGINE_BIN` overrides
121
+ // the default layout). That way the number matches what the `rm -rf` hint
122
+ // below would actually free, including any temp downloads or future
123
+ // components not in the per-row list.
124
+ const componentTotal = rows.reduce((n, r) => n + r.size, 0);
125
+ const cacheTotal = dirSizeBytes(cache);
126
+ const engineOutsideCache = engineDir.startsWith(cache)
127
+ ? 0
128
+ : dirSizeBytes(engineDir);
129
+ const total = cacheTotal + engineOutsideCache;
130
+
131
+ log.info(`Disk usage (${cache}):`);
132
+ const labelWidth = Math.max(...rows.map((r) => r.label.length), "Total".length);
133
+ for (const r of rows) {
134
+ const pad = " ".repeat(labelWidth - r.label.length + 2);
135
+ log.info(` ${r.label}:${pad}${humanBytes(r.size)}`);
136
+ }
137
+ const totalPad = " ".repeat(labelWidth - "Total".length + 2);
138
+ log.info(` ${pc.bold("Total")}:${totalPad}${pc.bold(humanBytes(total))}`);
139
+ if (total > componentTotal) {
140
+ const other = total - componentTotal;
141
+ log.info(pc.dim(` (includes ${humanBytes(other)} of other cache files)`));
142
+ }
143
+ log.info("");
144
+ log.info(pc.dim(` To reset cache: rm -rf ${cache} — next \`kesha install\` re-downloads.`));
145
+ log.info("");
146
+ }
147
+
59
148
  function kesheCacheDir(): string {
60
149
  return process.env.KESHA_CACHE_DIR ?? join(homedir(), ".cache", "kesha");
61
150
  }
62
151
 
152
+ /**
153
+ * Read the effective `KESHA_MODEL_MIRROR` base URL (#121). Returns null when
154
+ * unset, empty, or whitespace. Matches the Rust side's `model_mirror()` in
155
+ * `rust/src/models.rs` — keeping them in lockstep lets `kesha status`
156
+ * surface the exact URL the engine will hit on the next `kesha install`.
157
+ */
158
+ export function activeModelMirror(): string | null {
159
+ const raw = process.env.KESHA_MODEL_MIRROR ?? "";
160
+ const trimmed = raw.trim().replace(/\/+$/, "");
161
+ return trimmed.length > 0 ? trimmed : null;
162
+ }
163
+
63
164
  function listInstalledVoices(): string[] {
64
165
  const cache = kesheCacheDir();
65
166
  const voices: string[] = [];
package/src/toon.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { encode as encodeToon } from "@toon-format/toon";
2
+
3
+ /**
4
+ * TOON (#138) encoding of an array of transcription results. Same data shape
5
+ * as `formatJsonOutput`; lossless round-trip through `@toon-format/toon`'s
6
+ * `decode()`.
7
+ *
8
+ * Generic so this module stays independent of `TranscribeResult` (defined in
9
+ * `cli.ts`) and doesn't introduce a runtime cycle between `lib.ts` and
10
+ * `cli.ts`. Callers that have the concrete `TranscribeResult[]` type will
11
+ * still get a type-checked call via structural compatibility.
12
+ */
13
+ export function formatToonOutput<T extends object>(results: T[]): string {
14
+ return encodeToon(results) + "\n";
15
+ }
package/src/transcribe.ts CHANGED
@@ -1,10 +1,14 @@
1
- import { isEngineInstalled, transcribeEngine } from "./engine";
1
+ import { isEngineInstalled, transcribeEngine, type VadMode } from "./engine";
2
+
3
+ export type { VadMode };
2
4
 
3
5
  export interface TranscribeOptions {
4
6
  silent?: boolean;
7
+ /** Silero VAD preprocessing selector. Defaults to `"auto"`. */
8
+ vad?: VadMode;
5
9
  }
6
10
 
7
- export async function transcribe(audioPath: string, _opts: TranscribeOptions = {}): Promise<string> {
11
+ export async function transcribe(audioPath: string, opts: TranscribeOptions = {}): Promise<string> {
8
12
  if (!isEngineInstalled()) {
9
13
  throw new Error(
10
14
  "Error: No transcription backend is installed\n\n" +
@@ -16,5 +20,5 @@ export async function transcribe(audioPath: string, _opts: TranscribeOptions = {
16
20
  );
17
21
  }
18
22
 
19
- return transcribeEngine(audioPath);
23
+ return transcribeEngine(audioPath, { vad: opts.vad });
20
24
  }
package/src/types.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { LangDetectResult } from "./engine";
2
+
3
+ /**
4
+ * One row of `kesha --json` / `kesha --toon` output. Canonical output shape
5
+ * for programmatic callers of `transcribe(...)` + `toToon(...)` in the
6
+ * public API (`@drakulavich/kesha-voice-kit/core`).
7
+ */
8
+ export type TranscribeResult = {
9
+ file: string;
10
+ text: string;
11
+ lang: string;
12
+ audioLanguage?: LangDetectResult;
13
+ textLanguage?: LangDetectResult;
14
+ /** Wall-clock time around the engine subprocess calls for this file, ms. See #139. */
15
+ sttTimeMs?: number;
16
+ };
17
+
18
+ export type { LangDetectResult };
package/src/models.ts DELETED
@@ -1,6 +0,0 @@
1
- import { isEngineInstalled } from "./engine";
2
- export { downloadEngine } from "./engine-install";
3
-
4
- export function isModelInstalled(): boolean {
5
- return isEngineInstalled();
6
- }