@drakulavich/kesha-voice-kit 1.12.0 → 1.14.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 CHANGED
@@ -25,13 +25,53 @@ Runtime: **[Bun](https://bun.sh)** >= 1.3.0.
25
25
  ```bash
26
26
  curl -fsSL https://bun.sh/install | bash # skip if Bun is already installed
27
27
 
28
- bun install -g @drakulavich/kesha-voice-kit
28
+ bun add -g @drakulavich/kesha-voice-kit
29
29
  kesha install # downloads engine + models
30
30
  kesha audio.ogg # transcript to stdout
31
31
  ```
32
32
 
33
33
  Air-gapped or behind a corporate mirror? See [docs/model-mirror.md](docs/model-mirror.md).
34
34
 
35
+ ## Nix Install (Recommended)
36
+
37
+ **Prerequisites:** [Nix](https://nixos.org/download/) with flakes enabled. Supported systems: `aarch64-darwin`, `x86_64-linux`.
38
+
39
+ ### One-liner run (no install)
40
+ ```bash
41
+ nix run github:drakulavich/kesha-voice-kit -- install # downloads models (engine is bundled)
42
+ nix run github:drakulavich/kesha-voice-kit -- audio.ogg # transcribe
43
+ ```
44
+
45
+ `nix run` resolves to `apps.default` (the `kesha` Bun CLI), which has the engine binary baked in via `KESHA_ENGINE_BIN`, so there's no separate engine download.
46
+
47
+ ### Install to profile (persistent)
48
+ ```bash
49
+ nix profile install github:drakulavich/kesha-voice-kit
50
+ kesha install # downloads models
51
+ kesha audio.ogg # transcript to stdout
52
+ ```
53
+
54
+ `packages.default` ships the Bun CLI (`kesha`, `parakeet`) wired to the Nix-built engine. After `nix profile install`, both shims are on `PATH` and run transcription, language detection, and TTS (including `macos-*` AVSpeech voices on darwin-arm64) identically to the npm install. Speaker diarization (`kesha install --diarize` / `--speakers`) is not yet wired into the Nix build — the `kesha-diarize` Swift sidecar needs network-fetched FluidAudio at build time, which the Nix sandbox forbids; use the Bun install path (`bun add -g @drakulavich/kesha-voice-kit`) on darwin-arm64 if you need that feature (tracked alongside [#199](https://github.com/drakulavich/kesha-voice-kit/issues/199)).
55
+
56
+ ### Engine only (no Bun, no Node)
57
+ For users who just want the Rust binary:
58
+ ```bash
59
+ nix build github:drakulavich/kesha-voice-kit#kesha-engine
60
+ ./result/bin/kesha-engine --help
61
+ ./result/bin/kesha-engine --capabilities-json # see which backends compiled in
62
+ ```
63
+
64
+ ### Development shell
65
+ ```bash
66
+ nix develop github:drakulavich/kesha-voice-kit
67
+ # Now you have: pinned rustc/cargo (via rust-overlay), bun, protoc, cmake, pkg-config, libclang
68
+ ```
69
+
70
+ **Why Nix?**
71
+ - ✅ Reproducible builds across Linux/macOS
72
+ - ✅ All native deps (onnxruntime, protobuf, abseil) handled automatically
73
+ - ✅ No "works on my machine" — same `flake.nix` = identical results everywhere
74
+
35
75
  ## Speech-to-text
36
76
 
37
77
  ```bash
@@ -98,6 +138,18 @@ kesha say --voice ru-vosk-m02 --ssml \
98
138
 
99
139
  Vosk-TTS 0.9-multi honors a `+` placed BEFORE the target stressed vowel — but only when the marker shifts stress AWAY from the model's default (first-syllable). `+` agreeing with the default is a no-op. See [#233](https://github.com/drakulavich/kesha-voice-kit/issues/233).
100
140
 
141
+ **Speech rate via SSML** (`ru-vosk-*` and `en-*` voices):
142
+
143
+ ```bash
144
+ kesha say --voice ru-vosk-m02 --ssml \
145
+ '<speak><prosody rate="slow">Привет, как дела.</prosody></speak>' --out slow.wav
146
+
147
+ kesha say --voice en-am_michael --ssml \
148
+ '<speak><prosody rate="x-fast">Read this fast.</prosody></speak>' --out fast.wav
149
+ ```
150
+
151
+ Honored when `<prosody rate>` wraps the whole utterance. Mid-utterance prosody warns and synthesizes at default rate (whole-segment-only is a v1 limitation; mid-utterance support tracked in [#236](https://github.com/drakulavich/kesha-voice-kit/issues/236)). `--rate` and `<prosody rate>` compose multiplicatively. Range clamped to 0.5×–2.0×.
152
+
101
153
  macOS system voices, SSML, voice listing, and the full voice catalogue: [docs/tts.md](docs/tts.md).
102
154
 
103
155
  ## Performance
package/SKILL.md CHANGED
@@ -100,6 +100,8 @@ Format is also inferred from `--out` extension (`.ogg` / `.opus` / `.oga` → OG
100
100
 
101
101
  **Russian word stress** (`ru-vosk-*` only): `<emphasis>сл+ово</emphasis>` shifts stress to the vowel marked with `+`. `<emphasis level="none">сл+ово</emphasis>` strips the `+` (cancel inherited emphasis). Other voices (`en-*`, `macos-*`) silently strip the `+` and warn once per process. Auto-stress dictionary not provided — caller writes the `+` manually. Closes [#233](https://github.com/drakulavich/kesha-voice-kit/issues/233).
102
102
 
103
+ **Speech rate via SSML** (`ru-vosk-*` and `en-*` voices): wrap the utterance in `<prosody rate="…">` to slow down or speed up synthesis. Supports SSML named values (`x-slow`/`slow`/`medium`/`fast`/`x-fast`), absolute `N%` (e.g. `120%`), and relative `+N%`/`-N%`. Honored only when `<prosody>` wraps the whole utterance — mid-utterance prosody warns and synthesizes at default rate. `--rate` and `<prosody rate>` compose multiplicatively; result is clamped to 0.5×–2.0×. AVSpeech (`macos-*` voices) does not yet accept SSML — see [#236](https://github.com/drakulavich/kesha-voice-kit/issues/236).
104
+
103
105
  ## Language detection standalone
104
106
 
105
107
  `kesha --json audio.ogg` includes both audio-based (`audioLanguage`) and text-based (`textLanguage`) detection. Use audio detection to identify the language before running language-specific logic.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Open-source voice toolkit. Speech-to-text (25 langs, ~19x faster than Whisper on Apple Silicon, ONNX fallback on CPU), text-to-speech (Kokoro + Vosk-TTS + ~180 macOS system voices, SSML), voice-activity detection, language detection (107 langs). Rust engine, OpenClaw skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  ]
31
31
  },
32
32
  "keshaEngine": {
33
- "version": "1.12.0"
33
+ "version": "1.14.0"
34
34
  },
35
35
  "scripts": {
36
36
  "test": "bun test",
package/src/cli/say.ts CHANGED
@@ -1,31 +1,8 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { detectTextLanguageEngine, getEngineBinPath } from "../engine";
3
3
  import { log } from "../log";
4
- import { say, SayError, type SayFormat } from "../say";
5
-
6
- /**
7
- * Darwin defaults to AVSpeech Milena — zero install, no model download required.
8
- * Linux/Windows fall through to Vosk-TTS `ru-vosk-m02` (male, per CLAUDE.md
9
- * "DEFAULT TTS VOICES MUST BE MALE"; replaces Piper-ruslan as of #213).
10
- */
11
- const RU_DARWIN_FALLBACK_VOICE = "macos-com.apple.voice.compact.ru-RU.Milena";
12
-
13
- /** Map a detected language code to a default voice id. Unknown / low-confidence → undefined. */
14
- export function pickVoiceForLang(
15
- code: string | undefined,
16
- confidence: number,
17
- platform: NodeJS.Platform = process.platform,
18
- ): string | undefined {
19
- if (!code || confidence < 0.5) return undefined;
20
- switch (code) {
21
- case "en":
22
- return "en-am_michael";
23
- case "ru":
24
- return platform === "darwin" ? RU_DARWIN_FALLBACK_VOICE : "ru-vosk-m02";
25
- default:
26
- return undefined;
27
- }
28
- }
4
+ import { say, SayError, type SayFormat } from "../synth";
5
+ import { pickVoiceForLang } from "../voice-routing";
29
6
 
30
7
  /** Run NLLanguageRecognizer (via engine) on the text and pick a default voice. */
31
8
  async function autoRouteVoice(text: string): Promise<string | undefined> {
package/src/cli.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  // tests and lib.ts keep importing from "./cli" via these re-exports so the
5
5
  // public surface is unchanged.
6
6
  export { installCommand } from "./cli/install";
7
- export { sayCommand, pickVoiceForLang } from "./cli/say";
7
+ export { sayCommand } from "./cli/say";
8
+ export { pickVoiceForLang } from "./voice-routing";
8
9
  export { statusCommand } from "./cli/status";
9
10
  export { mainCommand, detectLanguage, checkLanguageMismatch } from "./cli/main";
10
11
  export { runCli } from "./cli/dispatch";
@@ -1,6 +1,10 @@
1
1
  import { dirname, join } from "path";
2
- import { existsSync, mkdirSync, chmodSync } from "fs";
3
- import { getEngineBinPath, getEngineCapabilities } from "./engine";
2
+ import { existsSync, mkdirSync, chmodSync, accessSync, constants } from "fs";
3
+ import {
4
+ getEngineBinPath,
5
+ getEngineCapabilities,
6
+ TRANSCRIBE_DIARIZE_FEATURE,
7
+ } from "./engine";
4
8
  import { log } from "./log";
5
9
  import { streamResponseToFile } from "./progress";
6
10
  import {
@@ -151,19 +155,56 @@ export async function downloadEngine(
151
155
  : "unknown";
152
156
 
153
157
  const installedVersion = readInstalledEngineVersion(binPath);
154
- const cacheValid =
155
- !noCache && existsSync(binPath) && installedVersion === engineVersion;
158
+ const engineDir = dirname(binPath);
159
+
160
+ // Detect a read-only engine directory (e.g., a Nix-store install —
161
+ // `KESHA_ENGINE_BIN` points into `/nix/store/.../bin/`). Used twice
162
+ // below: to honor `--no-cache` only when the engine is writable, and
163
+ // to skip the sidecar top-up that would otherwise emit confusing
164
+ // "install failed" warnings for paths we physically can't write to.
165
+ let canWriteEngineDir = true;
166
+ if (existsSync(engineDir)) {
167
+ try {
168
+ accessSync(engineDir, constants.W_OK);
169
+ } catch {
170
+ canWriteEngineDir = false;
171
+ }
172
+ }
173
+
174
+ const versionMatches =
175
+ existsSync(binPath) && installedVersion === engineVersion;
176
+ // When the engine sits on a read-only filesystem at the matching
177
+ // version, `--no-cache` can't re-download it without an EROFS crash
178
+ // and there's nothing to refresh anyway — treat as cache-valid and
179
+ // forward `--no-cache` to the model install step below.
180
+ const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
156
181
 
157
182
  if (cacheValid) {
158
- log.success(`Engine binary already installed (v${engineVersion}).`);
183
+ if (noCache && !canWriteEngineDir) {
184
+ log.info(
185
+ `Engine binary at v${engineVersion} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
186
+ );
187
+ } else {
188
+ log.success(`Engine binary already installed (v${engineVersion}).`);
189
+ }
159
190
  // Top up any sidecars missing from this cached install. Pre-#141 / pre-#199
160
191
  // engines never shipped them, so a cache-valid binary may still need
161
192
  // fetching. Run independent fetches concurrently — same shape as the
162
193
  // cold path below.
163
- const missing = SIDECARS.filter(
164
- (s) => !existsSync(join(dirname(binPath), s.fileBasename)),
165
- );
166
- await Promise.all(missing.map((s) => downloadSidecar(s, binPath, engineVersion)));
194
+ //
195
+ // Skip the top-up entirely when the engine sits in a read-only
196
+ // filesystem (e.g., a Nix-store install). Those installs stage the
197
+ // supported sidecars at build time; writing more is impossible, and
198
+ // attempting it would emit a confusing "install failed" warning for
199
+ // a feature the Nix README explicitly documents as unsupported
200
+ // (diarize on Nix needs network access at build time, which the
201
+ // sandbox forbids).
202
+ if (canWriteEngineDir) {
203
+ const missing = SIDECARS.filter(
204
+ (s) => !existsSync(join(engineDir, s.fileBasename)),
205
+ );
206
+ await Promise.all(missing.map((s) => downloadSidecar(s, binPath, engineVersion)));
207
+ }
167
208
  } else {
168
209
  // Log why we're downloading — helps diagnose surprising re-downloads.
169
210
  if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
@@ -225,6 +266,29 @@ export async function downloadEngine(
225
266
  }
226
267
  }
227
268
 
269
+ // Catch the case where the platform check passed (darwin-arm64) but the
270
+ // engine itself was built without `system_diarize` — e.g., the Nix build,
271
+ // which compiles `coreml,tts,system_tts` and intentionally omits diarize
272
+ // because the FluidAudio CoreML weights need network at build time and the
273
+ // Nix sandbox forbids it. Without this guard, `kesha-engine install
274
+ // --diarize` would fail with clap's generic "unexpected argument" error.
275
+ if (options.diarize) {
276
+ const caps = await getEngineCapabilities();
277
+ // Treat null (pre-capabilities-JSON engine, or capability probe failed)
278
+ // the same as an engine that advertised features but omitted ours —
279
+ // forwarding `--diarize` to a binary that doesn't understand it would
280
+ // surface as clap's generic "unexpected argument" error.
281
+ if (!caps || !caps.features.includes(TRANSCRIBE_DIARIZE_FEATURE)) {
282
+ throw new Error(
283
+ "--diarize is not supported by the installed engine: it was built " +
284
+ "without the 'system_diarize' feature (the Nix build is one such " +
285
+ "case — see README's Nix Install section).\n" +
286
+ " Fix: install via the npm release with `bun add -g @drakulavich/kesha-voice-kit`, " +
287
+ "which ships the diarize-enabled engine on darwin-arm64.",
288
+ );
289
+ }
290
+ }
291
+
228
292
  log.progress("Installing models...");
229
293
  const installArgs = [
230
294
  "install",
package/src/engine.ts CHANGED
@@ -66,6 +66,15 @@ async function runEngine(args: string[]): Promise<{ stdout: string; stderr: stri
66
66
  ]);
67
67
 
68
68
  log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms args=${JSON.stringify(args)}`);
69
+
70
+ // #275 D4: surface engine stderr on the success path so warnings like
71
+ // `hint: audio is 180s`, `Model mirror active:`, and the dtrace lines
72
+ // emitted under KESHA_DEBUG=1 reach the user. On non-zero exit we leave
73
+ // the buffer for callers to fold into a thrown Error — otherwise the
74
+ // user would see the warning AND a duplicate inside the error message.
75
+ if (exitCode === 0 && stderr.length > 0) {
76
+ process.stderr.write(stderr.endsWith("\n") ? stderr : stderr + "\n");
77
+ }
69
78
  return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
70
79
  }
71
80
 
package/src/lib.ts CHANGED
@@ -9,7 +9,7 @@ import { downloadEngine } from "./engine-install";
9
9
  export type { TranscribeOptions };
10
10
  export type { TranscriptionOutput, TranscriptionSegment } from "./engine";
11
11
  export { downloadEngine as downloadModel };
12
- export { say, type SayOptions, SayError } from "./say";
12
+ export { say, type SayOptions, SayError } from "./synth";
13
13
 
14
14
  /**
15
15
  * Encode a `TranscribeResult[]` as TOON (#138). Same data shape as the
package/src/log.ts CHANGED
@@ -4,10 +4,19 @@ import pc from "picocolors";
4
4
  * Debug mode (#148): when `KESHA_DEBUG` is truthy OR the caller has flipped
5
5
  * `log.debugEnabled = true` (via `--debug`), `log.debug()` writes structured
6
6
  * trace lines to stderr. Otherwise it's a no-op. Stdout is never touched.
7
+ *
8
+ * Grammar (#275 D9): values that turn debug OFF — empty, `"0"`, `"false"`,
9
+ * `"no"`, `"off"`, all matched **case-insensitively** after trimming. Any
10
+ * other non-empty value turns debug ON. The Rust engine mirrors this list
11
+ * verbatim in `rust/src/debug.rs` so `KESHA_DEBUG=False` flips both sides
12
+ * the same direction.
7
13
  */
14
+ const KESHA_DEBUG_OFF_VALUES = new Set(["", "0", "false", "no", "off"]);
15
+
8
16
  function envDebug(): boolean {
9
17
  const v = process.env.KESHA_DEBUG;
10
- return !!v && v !== "0" && v.toLowerCase() !== "false";
18
+ if (v === undefined) return false;
19
+ return !KESHA_DEBUG_OFF_VALUES.has(v.trim().toLowerCase());
11
20
  }
12
21
 
13
22
  export const log = {
@@ -41,8 +41,11 @@ export interface SayOptions {
41
41
  /**
42
42
  * Disable acronym auto-expansion for `ru-vosk-*` and `en-*` voices.
43
43
  * When true, passes `--no-expand-abbrev` to the engine (requires engine
44
- * capability `tts.ru_acronym_expansion` or `tts.en_acronym_expansion`; silently dropped for older engines).
45
- * `<say-as interpret-as="characters">` still works regardless of this flag.
44
+ * capability `tts.ru_acronym_expansion` or `tts.en_acronym_expansion`).
45
+ * On older engines that don't advertise the capability, the flag is
46
+ * dropped from argv and `log.warn` surfaces the drop on every
47
+ * invocation (post-#275 D3). `<say-as interpret-as="characters">`
48
+ * still works regardless of this flag.
46
49
  */
47
50
  noExpandAbbrev?: boolean;
48
51
  }
@@ -65,8 +68,12 @@ export function buildSayArgs(o: SayOptions, capabilities?: EngineCapabilities |
65
68
  if (supportsExpand) {
66
69
  args.push("--no-expand-abbrev");
67
70
  } else {
68
- log.debug(
69
- "kesha-engine does not advertise tts.ru_acronym_expansion or tts.en_acronym_expansion; dropping --no-expand-abbrev",
71
+ // CLAUDE.md "NEVER SWALLOW ERRORS": the user explicitly passed the flag.
72
+ // Silent drop with only `log.debug` made the flag look effective on old
73
+ // engines (#275 D3). Surface it as a warning so a CI script or human
74
+ // user sees the mismatch on every invocation, not only with --debug.
75
+ log.warn(
76
+ "--no-expand-abbrev requires kesha-engine ≥ 1.10.0 (advertises no tts.ru_acronym_expansion / tts.en_acronym_expansion capability); flag ignored",
70
77
  );
71
78
  }
72
79
  }
@@ -121,6 +128,14 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
121
128
  ]);
122
129
 
123
130
  log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms bytes=${stdoutBuf.byteLength}`);
131
+
132
+ // #275 D4: surface engine stderr on the success path so warnings like
133
+ // `Model mirror active:` and the dtrace lines emitted under
134
+ // KESHA_DEBUG=1 reach the user. Errors keep their existing path
135
+ // through `SayError.stderr` so we don't double-print.
136
+ if (exitCode === 0 && stderrText.length > 0) {
137
+ process.stderr.write(stderrText.endsWith("\n") ? stderrText : stderrText + "\n");
138
+ }
124
139
  if (exitCode !== 0) {
125
140
  throw new SayError(
126
141
  stderrText.trim() || `kesha-engine say exited ${exitCode}`,
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Darwin defaults to AVSpeech Milena — zero install, no model download required.
3
+ * Linux/Windows fall through to Vosk-TTS `ru-vosk-m02` (male, per CLAUDE.md
4
+ * "DEFAULT TTS VOICES MUST BE MALE"; replaces Piper-ruslan as of #213).
5
+ */
6
+ const RU_DARWIN_FALLBACK_VOICE = "macos-com.apple.voice.compact.ru-RU.Milena";
7
+
8
+ /** Map a detected language code to a default voice id. Unknown / low-confidence → undefined. */
9
+ export function pickVoiceForLang(
10
+ code: string | undefined,
11
+ confidence: number,
12
+ platform: NodeJS.Platform = process.platform,
13
+ ): string | undefined {
14
+ if (!code || confidence < 0.5) return undefined;
15
+ switch (code) {
16
+ case "en":
17
+ return "en-am_michael";
18
+ case "ru":
19
+ return platform === "darwin" ? RU_DARWIN_FALLBACK_VOICE : "ru-vosk-m02";
20
+ default:
21
+ return undefined;
22
+ }
23
+ }