@drakulavich/kesha-voice-kit 1.2.2 → 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.
@@ -1,4 +1,4 @@
1
- import { dirname } from "path";
1
+ import { dirname, join } from "path";
2
2
  import { existsSync, mkdirSync, chmodSync, readFileSync, writeFileSync } from "fs";
3
3
  import { getEngineBinPath, getEngineCapabilities } from "./engine";
4
4
  import { log } from "./log";
@@ -41,9 +41,64 @@ function getEngineBinaryName(): string {
41
41
  throw new Error(`Unsupported platform: ${platform} ${arch}`);
42
42
  }
43
43
 
44
+ /**
45
+ * Fetch the AVSpeechSynthesizer sidecar (#141) and place it next to the
46
+ * engine binary on darwin-arm64. The Rust side (`avspeech::helper_path`)
47
+ * looks for a `say-avspeech` file adjacent to the running executable, so
48
+ * the filename on disk is always `say-avspeech` regardless of the release
49
+ * asset name.
50
+ *
51
+ * Best-effort: 404s (older engine versions predate the sidecar) and
52
+ * network errors log a warning and return — macos-* voices simply won't
53
+ * be available, which is a graceful degradation. The user keeps Kokoro +
54
+ * Piper.
55
+ */
56
+ async function downloadAVSpeechSidecar(binPath: string, engineVersion: string): Promise<void> {
57
+ if (process.platform !== "darwin" || process.arch !== "arm64") return;
58
+
59
+ const sidecarPath = join(dirname(binPath), "say-avspeech");
60
+ const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/say-avspeech-darwin-arm64`;
61
+
62
+ let res: Response;
63
+ try {
64
+ res = await fetch(url, { redirect: "follow" });
65
+ } catch (e) {
66
+ log.warn(
67
+ `Could not fetch AVSpeech sidecar (${e instanceof Error ? e.message : e}); macos-* voices unavailable.`,
68
+ );
69
+ return;
70
+ }
71
+
72
+ if (!res.ok) {
73
+ log.warn(
74
+ `AVSpeech sidecar not in release v${engineVersion} (HTTP ${res.status}); macos-* voices unavailable.`,
75
+ );
76
+ return;
77
+ }
78
+
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
+ }
95
+ }
96
+
44
97
  export interface InstallOptions {
45
- /** Also install Kokoro TTS models. Requires espeak-ng on PATH. */
98
+ /** Also install Kokoro + Piper + ONNX G2P TTS models. */
46
99
  tts?: boolean;
100
+ /** Also install Silero VAD model for long-audio preprocessing. */
101
+ vad?: boolean;
47
102
  }
48
103
 
49
104
  export async function downloadEngine(
@@ -69,6 +124,13 @@ export async function downloadEngine(
69
124
 
70
125
  if (cacheValid) {
71
126
  log.success(`Engine binary already installed (v${engineVersion}).`);
127
+ // Cover the upgrade path from pre-#141 engines that never had a
128
+ // sidecar: if the cached engine is current but the sibling sidecar
129
+ // is missing, fetch it now so macos-* voices start working.
130
+ const sidecarPath = join(dirname(binPath), "say-avspeech");
131
+ if (!existsSync(sidecarPath)) {
132
+ await downloadAVSpeechSidecar(binPath, engineVersion);
133
+ }
72
134
  } else {
73
135
  // Log why we're downloading — helps diagnose surprising re-downloads.
74
136
  if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
@@ -81,16 +143,32 @@ export async function downloadEngine(
81
143
 
82
144
  mkdirSync(dirname(binPath), { recursive: true });
83
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
+
84
153
  let res: Response;
85
154
  try {
86
155
  res = await fetch(url, { redirect: "follow" });
87
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(() => {});
88
165
  throw new Error(
89
166
  `Failed to fetch engine binary: ${e instanceof Error ? e.message : e}\n Fix: Check your network connection and try again`,
90
167
  );
91
168
  }
92
169
 
93
170
  if (!res.ok) {
171
+ sidecarPromise.catch(() => {});
94
172
  throw new Error(
95
173
  `Failed to download engine binary (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
96
174
  );
@@ -100,6 +178,7 @@ export async function downloadEngine(
100
178
  chmodSync(binPath, 0o755);
101
179
  writeInstalledEngineVersion(binPath, engineVersion);
102
180
  log.success(`Engine binary downloaded (v${engineVersion}).`);
181
+ await sidecarPromise;
103
182
  }
104
183
 
105
184
  if (backend) {
@@ -116,6 +195,7 @@ export async function downloadEngine(
116
195
  "install",
117
196
  ...(noCache ? ["--no-cache"] : []),
118
197
  ...(options.tts ? ["--tts"] : []),
198
+ ...(options.vad ? ["--vad"] : []),
119
199
  ];
120
200
  const proc = Bun.spawnSync([binPath, ...installArgs], {
121
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
@@ -38,6 +38,10 @@ export async function showStatus(): Promise<void> {
38
38
 
39
39
  log.info(formatStatusLine("Runtime", `Bun ${Bun.version}`, true));
40
40
  log.info(formatStatusLine("Platform", `${process.platform} ${process.arch}`, true));
41
+ const mirror = activeModelMirror();
42
+ if (mirror) {
43
+ log.info(formatStatusLine("Mirror", mirror, true));
44
+ }
41
45
  log.info("");
42
46
 
43
47
  if (installed) {
@@ -60,6 +64,18 @@ function kesheCacheDir(): string {
60
64
  return process.env.KESHA_CACHE_DIR ?? join(homedir(), ".cache", "kesha");
61
65
  }
62
66
 
67
+ /**
68
+ * Read the effective `KESHA_MODEL_MIRROR` base URL (#121). Returns null when
69
+ * unset, empty, or whitespace. Matches the Rust side's `model_mirror()` in
70
+ * `rust/src/models.rs` — keeping them in lockstep lets `kesha status`
71
+ * surface the exact URL the engine will hit on the next `kesha install`.
72
+ */
73
+ export function activeModelMirror(): string | null {
74
+ const raw = process.env.KESHA_MODEL_MIRROR ?? "";
75
+ const trimmed = raw.trim().replace(/\/+$/, "");
76
+ return trimmed.length > 0 ? trimmed : null;
77
+ }
78
+
63
79
  function listInstalledVoices(): string[] {
64
80
  const cache = kesheCacheDir();
65
81
  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
- }