@drakulavich/kesha-voice-kit 1.1.3 → 1.2.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/README.md CHANGED
@@ -93,11 +93,45 @@ kesha say --voice en-af_heart "text" # explicit voice overrides auto-routing
93
93
  kesha say --list-voices
94
94
  ```
95
95
 
96
- Output format: WAV mono float32 (24 kHz for Kokoro, 22.05 kHz for Piper). OGG/Opus, MP3, and SSML are tracked in follow-up issues ([#122](https://github.com/drakulavich/kesha-voice-kit/issues/122)). Static-linking of `espeak-ng` to remove the system dep is [#124](https://github.com/drakulavich/kesha-voice-kit/issues/124).
96
+ Output format: WAV mono float32 (24 kHz for Kokoro, 22.05 kHz for Piper). OGG/Opus and MP3 are tracked in follow-up issues. Static-linking of `espeak-ng` to remove the system dep is [#124](https://github.com/drakulavich/kesha-voice-kit/issues/124).
97
97
 
98
98
  **Supported voices:**
99
99
  - English: `en-af_heart` (default), plus any Kokoro voice you download into `~/.cache/kesha/models/kokoro-82m/voices/`
100
100
  - Russian: `ru-denis` (default). More speakers (dmitri, irina, ruslan) are ready to drop in once needed.
101
+ - macOS system voices (preview — dev builds only, see below): `macos-<identifier-or-language>` routes to `AVSpeechSynthesizer`. Zero install, any of the 180+ voices already on your Mac.
102
+
103
+ ### macOS system voices (preview)
104
+
105
+ `kesha say --voice macos-*` routes through `AVSpeechSynthesizer` on macOS, so you get voice synthesis for free — no 390 MB TTS bundle, no `espeak-ng` dep. Currently gated behind the `system_tts` cargo feature; release-binary integration is tracked as part of [#141](https://github.com/drakulavich/kesha-voice-kit/issues/141).
106
+
107
+ ```bash
108
+ # From a dev build: `cargo build --release --features system_tts` in rust/.
109
+ kesha say --voice macos-com.apple.voice.compact.en-US.Samantha "Hello from system TTS" > out.wav
110
+ kesha say --voice macos-ru-RU "Привет, мир" > hello-ru.wav # language-code fallback
111
+ ```
112
+
113
+ Voice id format: `macos-<id>` where `<id>` is either a full Apple identifier (`com.apple.voice.compact.en-US.Samantha`) or a language code (`en-US`, `ru-RU`) — the Swift helper tries the identifier first and falls back to the language. Output is mono float32 @ 22050 Hz, structurally identical to Piper.
114
+
115
+ Quality tradeoff is honest: macOS system voices are notification-grade. Use them when you want zero-install TTS on macOS; keep Kokoro/Piper for anything that needs to sound good.
116
+
117
+ ### SSML (preview)
118
+
119
+ `kesha say --ssml` accepts [SSML](https://www.w3.org/TR/speech-synthesis11/) for pauses and text-structuring. v1 is deliberately small:
120
+
121
+ ```bash
122
+ kesha say --ssml '<speak>Hello <break time="500ms"/> world.</speak>'
123
+ kesha say --ssml --voice ru-denis '<speak>Привет <break time="1s"/> мир.</speak>'
124
+ ```
125
+
126
+ | Tag | Status |
127
+ |---|---|
128
+ | `<speak>` | ✅ required root |
129
+ | `<break time="Nms"\|"Ns"\|default>` | ✅ inserts silence of the given duration |
130
+ | plain text inside `<speak>` | ✅ synthesized via the selected engine |
131
+ | `<emphasis>`, `<prosody>`, `<phoneme>`, `<say-as>` | ⚠️ stripped with a stderr warning (contained text still synthesized); tracked in [#122](https://github.com/drakulavich/kesha-voice-kit/issues/122) |
132
+ | `<!DOCTYPE>` | ❌ rejected (hardening against XXE) |
133
+
134
+ SSML is opt-in via the explicit `--ssml` flag — inputs that happen to contain `<angle brackets>` aren't misinterpreted as SSML.
101
135
 
102
136
  ## What's Inside
103
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
4
4
  "description": "Open-source voice toolkit for Apple Silicon. Speech-to-text, language detection. 25 languages.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,7 +29,7 @@
29
29
  ]
30
30
  },
31
31
  "keshaEngine": {
32
- "version": "1.1.3"
32
+ "version": "1.2.0"
33
33
  },
34
34
  "scripts": {
35
35
  "test": "bun test",
package/src/cli.ts CHANGED
@@ -32,6 +32,7 @@ interface MainCommandArgs {
32
32
  _: string[];
33
33
  json: boolean;
34
34
  verbose: boolean;
35
+ debug: boolean;
35
36
  format?: string;
36
37
  lang?: string;
37
38
  }
@@ -120,8 +121,23 @@ export const sayCommand = defineCommand({
120
121
  out: { type: "string", description: "Write WAV to file instead of stdout" },
121
122
  rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
122
123
  "list-voices": { type: "boolean", description: "List installed voices and exit" },
124
+ ssml: {
125
+ type: "boolean",
126
+ description: "Parse input as SSML (supports <speak>, <break>; strips unknown tags)",
127
+ },
128
+ verbose: {
129
+ type: "boolean",
130
+ description: "Log TTS synthesis time to stderr",
131
+ default: false,
132
+ },
133
+ debug: {
134
+ type: "boolean",
135
+ description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
136
+ default: false,
137
+ },
123
138
  },
124
139
  async run({ args }) {
140
+ if (args.debug) log.debugEnabled = true;
125
141
  if (args["list-voices"]) {
126
142
  // The engine prints the list directly — just relay its stdout + exit code.
127
143
  const { getEngineBinPath } = await import("./engine");
@@ -143,10 +159,17 @@ export const sayCommand = defineCommand({
143
159
  lang: typeof args.lang === "string" ? args.lang : undefined,
144
160
  out: typeof args.out === "string" ? args.out : undefined,
145
161
  rate: args.rate ? Number(args.rate) : undefined,
162
+ ssml: Boolean(args.ssml),
146
163
  };
147
164
 
148
165
  try {
166
+ const startedAt = performance.now();
149
167
  const wav = await say(opts);
168
+ const ttsTimeMs = Math.round(performance.now() - startedAt);
169
+ if (args.verbose) {
170
+ // stderr — stdout may carry raw WAV bytes when --out is omitted.
171
+ console.error(`TTS time: ${ttsTimeMs}ms`);
172
+ }
150
173
  if (!opts.out) {
151
174
  process.stdout.write(wav);
152
175
  }
@@ -240,8 +263,14 @@ export const mainCommand = defineCommand({
240
263
  type: "string",
241
264
  description: "Expected language code (ISO 639-1), warn if mismatch",
242
265
  },
266
+ debug: {
267
+ type: "boolean",
268
+ description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
269
+ default: false,
270
+ },
243
271
  },
244
272
  async run({ args }: { args: MainCommandArgs }) {
273
+ if (args.debug) log.debugEnabled = true;
245
274
  const files = args._;
246
275
 
247
276
  if (files.length === 0) {
@@ -255,6 +284,7 @@ export const mainCommand = defineCommand({
255
284
  const wantsLangId = !!(args.lang || args.verbose || args.json || args.format === "transcript" || args.format === "json");
256
285
 
257
286
  for (const file of files) {
287
+ const startedAt = performance.now();
258
288
  try {
259
289
  // Run audio lang-id and transcription concurrently
260
290
  const [audioResult, text] = await Promise.all([
@@ -293,6 +323,7 @@ export const mainCommand = defineCommand({
293
323
  lang,
294
324
  audioLanguage,
295
325
  textLanguage: textLanguage ?? (tinyldLang ? { code: tinyldLang, confidence: 0 } : undefined),
326
+ sttTimeMs: Math.round(performance.now() - startedAt),
296
327
  });
297
328
  } catch (err: unknown) {
298
329
  hasError = true;
@@ -354,6 +385,8 @@ export type TranscribeResult = {
354
385
  lang: string;
355
386
  audioLanguage?: LangDetectResult;
356
387
  textLanguage?: LangDetectResult;
388
+ /** Wall-clock time around the engine subprocess calls for this file, ms. See #139. */
389
+ sttTimeMs?: number;
357
390
  };
358
391
 
359
392
  export function formatTextOutput(results: TranscribeResult[]): string {
@@ -381,6 +414,9 @@ export function formatVerboseOutput(results: TranscribeResult[]): string {
381
414
  const confStr = textLang.confidence > 0 ? ` (confidence: ${textLang.confidence.toFixed(2)})` : "";
382
415
  lines.push(`Text language: ${textLang.code}${confStr}`);
383
416
  }
417
+ if (r.sttTimeMs !== undefined) {
418
+ lines.push(`STT time: ${r.sttTimeMs}ms`);
419
+ }
384
420
  lines.push("---");
385
421
  lines.push(r.text);
386
422
  return lines.join("\n");
@@ -1,11 +1,35 @@
1
1
  import { dirname } from "path";
2
- import { existsSync, mkdirSync, chmodSync } from "fs";
2
+ import { existsSync, mkdirSync, chmodSync, readFileSync, writeFileSync } from "fs";
3
3
  import { getEngineBinPath, getEngineCapabilities } from "./engine";
4
4
  import { log } from "./log";
5
5
  import { streamResponseToFile } from "./progress";
6
6
 
7
7
  const GITHUB_REPO = "drakulavich/kesha-voice-kit";
8
8
 
9
+ /**
10
+ * Version-marker file written next to the engine binary on download (#151).
11
+ * Lets `kesha install` detect a stale cached engine after a CLI upgrade and
12
+ * re-download without requiring `--no-cache`.
13
+ */
14
+ export function getVersionMarkerPath(binPath: string): string {
15
+ return `${binPath}.version`;
16
+ }
17
+
18
+ /** Read the recorded version for the installed binary, or null if missing / empty. */
19
+ export function readInstalledEngineVersion(binPath: string): string | null {
20
+ try {
21
+ const v = readFileSync(getVersionMarkerPath(binPath), "utf-8").trim();
22
+ return v.length > 0 ? v : null;
23
+ } catch {
24
+ // Missing, unreadable, or permission-denied → treat as no marker so we re-download.
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export function writeInstalledEngineVersion(binPath: string, version: string): void {
30
+ writeFileSync(getVersionMarkerPath(binPath), `${version}\n`);
31
+ }
32
+
9
33
  function getEngineBinaryName(): string {
10
34
  const platform = process.platform;
11
35
  const arch = process.arch;
@@ -28,21 +52,31 @@ export async function downloadEngine(
28
52
  options: InstallOptions = {},
29
53
  ): Promise<string> {
30
54
  const binPath = getEngineBinPath();
55
+ const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
56
+ // The engine version is tracked separately from the CLI version so
57
+ // CLI-only patch releases don't require cutting a new GitHub release
58
+ // + Rust rebuild. Fall back to the CLI version for backwards compat.
59
+ const engineVersion =
60
+ typeof pkg.keshaEngine?.version === "string"
61
+ ? pkg.keshaEngine.version
62
+ : typeof pkg.version === "string"
63
+ ? pkg.version
64
+ : "unknown";
31
65
 
32
- if (!noCache && existsSync(binPath)) {
33
- log.success("Engine binary already installed.");
66
+ const installedVersion = readInstalledEngineVersion(binPath);
67
+ const cacheValid =
68
+ !noCache && existsSync(binPath) && installedVersion === engineVersion;
69
+
70
+ if (cacheValid) {
71
+ log.success(`Engine binary already installed (v${engineVersion}).`);
34
72
  } else {
73
+ // Log why we're downloading — helps diagnose surprising re-downloads.
74
+ if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
75
+ log.progress(
76
+ `Upgrading engine v${installedVersion} → v${engineVersion}...`,
77
+ );
78
+ }
35
79
  const binaryName = getEngineBinaryName();
36
- const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
37
- // The engine version is tracked separately from the CLI version so
38
- // CLI-only patch releases don't require cutting a new GitHub release
39
- // + Rust rebuild. Fall back to the CLI version for backwards compat.
40
- const engineVersion =
41
- typeof pkg.keshaEngine?.version === "string"
42
- ? pkg.keshaEngine.version
43
- : typeof pkg.version === "string"
44
- ? pkg.version
45
- : "unknown";
46
80
  const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/${binaryName}`;
47
81
 
48
82
  mkdirSync(dirname(binPath), { recursive: true });
@@ -64,7 +98,8 @@ export async function downloadEngine(
64
98
 
65
99
  await streamResponseToFile(res, binPath, "kesha-engine binary");
66
100
  chmodSync(binPath, 0o755);
67
- log.success("Engine binary downloaded.");
101
+ writeInstalledEngineVersion(binPath, engineVersion);
102
+ log.success(`Engine binary downloaded (v${engineVersion}).`);
68
103
  }
69
104
 
70
105
  if (backend) {
package/src/engine.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { join } from "path";
2
2
  import { homedir } from "os";
3
3
  import { existsSync } from "fs";
4
+ import { log } from "./log";
4
5
 
5
6
  export interface LangDetectResult {
6
7
  code: string;
@@ -24,6 +25,8 @@ export function isEngineInstalled(): boolean {
24
25
 
25
26
  async function runEngine(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
26
27
  const binPath = getEngineBinPath();
28
+ const startedAt = performance.now();
29
+ log.debug(`spawn ${binPath} ${args.join(" ")}`);
27
30
  const proc = Bun.spawn([binPath, ...args], {
28
31
  stdout: "pipe",
29
32
  stderr: "pipe",
@@ -35,6 +38,7 @@ async function runEngine(args: string[]): Promise<{ stdout: string; stderr: stri
35
38
  proc.exited,
36
39
  ]);
37
40
 
41
+ log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms args=${JSON.stringify(args)}`);
38
42
  return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
39
43
  }
40
44
 
package/src/log.ts CHANGED
@@ -1,9 +1,26 @@
1
1
  import pc from "picocolors";
2
2
 
3
+ /**
4
+ * Debug mode (#148): when `KESHA_DEBUG` is truthy OR the caller has flipped
5
+ * `log.debugEnabled = true` (via `--debug`), `log.debug()` writes structured
6
+ * trace lines to stderr. Otherwise it's a no-op. Stdout is never touched.
7
+ */
8
+ function envDebug(): boolean {
9
+ const v = process.env.KESHA_DEBUG;
10
+ return !!v && v !== "0" && v.toLowerCase() !== "false";
11
+ }
12
+
3
13
  export const log = {
4
14
  info: (msg: string) => console.log(msg),
5
15
  success: (msg: string) => console.log(pc.green(msg)),
6
16
  progress: (msg: string) => console.log(pc.cyan(msg)),
7
17
  warn: (msg: string) => console.error(pc.yellow(msg)),
8
18
  error: (msg: string) => console.error(pc.red(msg)),
19
+
20
+ debugEnabled: false,
21
+ debug(msg: string): void {
22
+ if (this.debugEnabled || envDebug()) {
23
+ console.error(pc.dim(`[debug] ${msg}`));
24
+ }
25
+ },
9
26
  };
package/src/say.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getEngineBinPath, isEngineInstalled } from "./engine";
2
+ import { log } from "./log";
2
3
 
3
4
  export interface SayOptions {
4
5
  /**
@@ -15,6 +16,8 @@ export interface SayOptions {
15
16
  out?: string;
16
17
  /** Speaking rate 0.5–2.0. */
17
18
  rate?: number;
19
+ /** Parse `text` as SSML (`<speak>…<break time="500ms"/>…</speak>`). See issue #122. */
20
+ ssml?: boolean;
18
21
  }
19
22
 
20
23
  /** Build the argv passed to `kesha-engine say` (pure, unit-testable). */
@@ -24,6 +27,7 @@ export function buildSayArgs(o: SayOptions): string[] {
24
27
  if (o.lang) args.push("--lang", o.lang);
25
28
  if (o.out) args.push("--out", o.out);
26
29
  if (o.rate !== undefined && o.rate !== 1.0) args.push("--rate", String(o.rate));
30
+ if (o.ssml) args.push("--ssml");
27
31
  if (o.text !== undefined && o.text.length > 0) args.push(o.text);
28
32
  return args;
29
33
  }
@@ -52,6 +56,8 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
52
56
  );
53
57
  }
54
58
  const args = buildSayArgs({ ...opts, text: undefined });
59
+ const startedAt = performance.now();
60
+ log.debug(`spawn ${getEngineBinPath()} ${args.join(" ")} (text: ${opts.text?.length ?? 0} chars)`);
55
61
  const proc = Bun.spawn([getEngineBinPath(), ...args], {
56
62
  stdin: "pipe",
57
63
  stdout: "pipe",
@@ -71,6 +77,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
71
77
  proc.exited,
72
78
  ]);
73
79
 
80
+ log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms bytes=${stdoutBuf.byteLength}`);
74
81
  if (exitCode !== 0) {
75
82
  throw new SayError(
76
83
  stderrText.trim() || `kesha-engine say exited ${exitCode}`,