@drakulavich/kesha-voice-kit 2.2.0 → 2.3.0-beta.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.
@@ -26,7 +26,7 @@ complete -c kesha -n '__fish_use_subcommand' -l format -r -d 'Output format: tra
26
26
  complete -c kesha -n '__fish_use_subcommand' -l lang -r -d 'Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch'
27
27
  complete -c kesha -n '__fish_use_subcommand' -l debug -d 'Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)'
28
28
  complete -c kesha -n '__fish_use_subcommand' -l vad -d 'Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.'
29
- complete -c kesha -n '__fish_use_subcommand' -l no-vad -d 'Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers'
29
+ complete -c kesha -n '__fish_use_subcommand' -l no-vad -d 'Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers'
30
30
  complete -c kesha -n '__fish_use_subcommand' -l quiet -d 'Suppress progress output; print only results and errors'
31
31
  complete -c kesha -n '__fish_use_subcommand' -l no-color -d 'Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)'
32
32
  complete -c kesha -n '__fish_seen_subcommand_from completions' -l help -d 'Show help'
@@ -35,7 +35,7 @@ _kesha() {
35
35
  '--lang=[Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch]:lang:' \
36
36
  '--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
37
37
  '--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
38
- '--no-vad[Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers]' \
38
+ '--no-vad[Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers]' \
39
39
  '--quiet[Suppress progress output; print only results and errors]' \
40
40
  '--no-color[Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)]'
41
41
  else
@@ -154,7 +154,7 @@ _kesha() {
154
154
  '--lang=[Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch]:lang:' \
155
155
  '--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
156
156
  '--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
157
- '--no-vad[Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers]' \
157
+ '--no-vad[Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers]' \
158
158
  '--quiet[Suppress progress output; print only results and errors]' \
159
159
  '--no-color[Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)]' \
160
160
  '*:audio file:_files'
package/man/kesha.1 CHANGED
@@ -82,7 +82,7 @@ Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)
82
82
  Force Silero VAD preprocessing (kesha install \-\-vad first). Without this, VAD auto\-engages on audio ≥ 120s.
83
83
  .TP
84
84
  .B \-\-no\-vad
85
- Force full\-file ASR for short/medium files; long audio fails early. Incompatible with \-\-speakers
85
+ Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with \-\-speakers
86
86
  .TP
87
87
  .B \-\-quiet
88
88
  Suppress progress output; print only results and errors
package/model-plan.json CHANGED
@@ -161,6 +161,9 @@
161
161
  { "relPath": "g2pw/g2pw.mlmodelc/weights/weight.bin", "sizeBytes": 159294962 }
162
162
  ],
163
163
  "aneZhG2p": [
164
+ { "relPath": "g2p/jieba_hmm_emit.bin", "sizeBytes": 292640 },
165
+ { "relPath": "g2p/jieba_hmm_start.bin", "sizeBytes": 16 },
166
+ { "relPath": "g2p/jieba_hmm_trans.bin", "sizeBytes": 64 },
164
167
  { "relPath": "g2p/pinyin_phrases.bin", "sizeBytes": 9552083 },
165
168
  { "relPath": "g2p/pinyin_single.bin", "sizeBytes": 480169 }
166
169
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "2.2.0",
3
+ "version": "2.3.0-beta.1",
4
4
  "description": "Kesha listens and talks back: local speech to text and text to speech for agents and scripts, fastest on Apple Silicon.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,8 +50,8 @@
50
50
  "coverage:rust": "mkdir -p coverage/rust && cd rust && cargo llvm-cov nextest --features tts --lcov --output-path ../coverage/rust/lcov.info",
51
51
  "coverage:check:rust": "bun .github/scripts/check-coverage.ts --preset=rust --lcov=coverage/rust/lcov.info --summary=coverage/rust/summary.md",
52
52
  "generate:shell-artifacts": "bun scripts/generate-shell-artifacts.ts",
53
- "lint": "bunx tsc --noEmit",
54
- "check": "bun run lint && bun run check:versions && bun run check:recipes && bun run test:cli-fast",
53
+ "lint": "bun check",
54
+ "check:fast": "bun run lint && bun run check:versions && bun run check:recipes && bun run test:cli-fast",
55
55
  "check:workflows": "bun .github/scripts/check-workflows.ts",
56
56
  "check:recipes": "bun .github/scripts/check-recipes.ts",
57
57
  "check:specs": "openspec validate --specs --strict",
@@ -89,7 +89,6 @@
89
89
  "@fission-ai/openspec": "1.14.1",
90
90
  "@types/bun": "latest",
91
91
  "fast-xml-parser": "^5.8.0",
92
- "typescript": "^7.0.2",
93
92
  "yaml": "^2.9.0"
94
93
  },
95
94
  "dependencies": {
@@ -110,18 +109,18 @@
110
109
  },
111
110
  "kesha": {
112
111
  "engine": {
113
- "version": "2.2.0",
112
+ "version": "2.3.0-beta.1",
114
113
  "sha256": {
115
- "kesha-engine-darwin-arm64": "916ec3dbcb84337487d953b08816f6939ee3995d0851acd8acc5e084449e87cc",
116
- "kesha-engine-linux-x64": "b25b77d3b0a64ccc6b0d276fd0a3fd320c60f06700e1ccfcb792359150248012",
117
- "kesha-engine-windows-x64.exe": "e87a497444180f1d40ae0c51c78028bc41abc73523ba1e9e5f2bb71f278bf443",
114
+ "kesha-engine-darwin-arm64": "dade5c6c2d8fc838a38c1381915ee9e3a1d2840f20c87b6cabcdd3f2dcec3c9e",
115
+ "kesha-engine-linux-x64": "2b6e604cb24fca040b60136df3e4e29e3005959aea0a6cd7d3fb80930e9f3380",
116
+ "kesha-engine-windows-x64.exe": "269e13d10852bc01fdce2bec880bb9831bdaf19c4cb8e7a2d0a09a84c2980dc7",
118
117
  "say-avspeech-darwin-arm64": "3a7e8b8fac92ce4ce0c34f736098a685dcb745f741db8303e1721e320ac73a98",
119
118
  "kesha-textlang-darwin-arm64": "e45e2822248b14dd72aa79e2def8fa6b9c07c97423f11c841508769225d72da4"
120
119
  },
121
120
  "size": {
122
- "kesha-engine-darwin-arm64": 73639712,
123
- "kesha-engine-linux-x64": 67404968,
124
- "kesha-engine-windows-x64.exe": 66583040,
121
+ "kesha-engine-darwin-arm64": 73639696,
122
+ "kesha-engine-linux-x64": 67403816,
123
+ "kesha-engine-windows-x64.exe": 66584064,
125
124
  "say-avspeech-darwin-arm64": 82992,
126
125
  "kesha-textlang-darwin-arm64": 57648
127
126
  }
package/src/cli/main.ts CHANGED
@@ -295,6 +295,14 @@ type ProcessFileSuccess = { ok: true; result: TranscribeResult };
295
295
  type ProcessFileFailure = { ok: false; error: TranscribeErrorRecord; exitCode: number };
296
296
 
297
297
  /** An argument the CLI itself rejected exits by its code (2); every runtime failure keeps the batch's operational 1 (S9-F1). */
298
+ /** `kesha <dir>/*.ogg`, pasteable: a name starting with `-` would parse as a flag, and only Windows separates with `\`. */
299
+ export function folderGlobHint(dir: string): string {
300
+ const trimmed = dir.replace(process.platform === "win32" ? /[\\/]+$/ : /\/+$/, "");
301
+ const path = trimmed.startsWith("-") ? `./${trimmed}` : trimmed;
302
+ const quoted = /^[\w./-]+$/.test(path) ? path : `'${path.replaceAll("'", `'\\''`)}'`;
303
+ return `kesha ${quoted}/*.ogg`;
304
+ }
305
+
298
306
  function failureExitCode(err: unknown): number {
299
307
  const usage = err instanceof KeshaError && err.origin === "cli" && err.code === "E_INVALID_ARG";
300
308
  return usage ? exitCodeFor(err) : 1;
@@ -320,7 +328,9 @@ async function processFile(
320
328
  }
321
329
 
322
330
  if (isDirectoryPath(file)) {
323
- const err = new KeshaError("E_INVALID_ARG", "is a directory (expected an audio file)");
331
+ const err = new KeshaError("E_INVALID_ARG", "is a directory (expected an audio file)", {
332
+ hint: `pass the audio files in it instead, e.g. ${folderGlobHint(file)}`,
333
+ });
324
334
  stats.recordError("input", err, err.code);
325
335
  diagnosticLog.event("input.invalid", {
326
336
  command: "transcribe",
@@ -346,9 +356,8 @@ async function processFile(
346
356
  // so the engine's progress has to land while it happens, not once the run is over.
347
357
  const showProgressLine = (line: string) => {
348
358
  if (log.quietEnabled) return;
349
- const write = () => process.stderr.write(`${line}\n`);
350
- if (progress) progress.interrupt(write);
351
- else write();
359
+ if (progress) progress.status(line);
360
+ else process.stderr.write(`${line}\n`);
352
361
  };
353
362
  try {
354
363
  await validateTranscribeRequest({ vad: vadMode, timestamps, speakers, itn });
@@ -451,7 +460,7 @@ export const MAIN_VAD_ARGS = {
451
460
  "no-vad": {
452
461
  type: "boolean",
453
462
  description:
454
- "Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers",
463
+ "Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers",
455
464
  default: false,
456
465
  },
457
466
  } as const;
@@ -7,6 +7,8 @@
7
7
  // `--no-cache`) — they preselect modules in the interactive wizard rather than
8
8
  // being install-only flags. Source of truth: `src/cli/init.ts::initCommand.args`.
9
9
  export function installHint(...flags: string[]): string {
10
- const verb = process.stderr.isTTY ? "kesha init" : "kesha install";
10
+ // `kesha init` refuses unless stdin and stdout are terminals (#1373), so a piped run gets `install` (#1380).
11
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY;
12
+ const verb = interactive ? "kesha init" : "kesha install";
11
13
  return [verb, ...flags].join(" ");
12
14
  }
package/src/mcp/tools.ts CHANGED
@@ -59,10 +59,11 @@ export function registerTools(server: McpServer): void {
59
59
  },
60
60
  async ({ text, voice, rate, format }, extra) => {
61
61
  if (extra.signal?.aborted) {
62
- return { isError: true, content: [{ type: "text" as const, text: "request cancelled" }] };
62
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(new KeshaError("E_INTERRUPTED", "request cancelled")) }] };
63
63
  }
64
64
  if (rate !== undefined && !(rate >= 0.5 && rate <= 2.0)) {
65
- return { isError: true, content: [{ type: "text" as const, text: `rate ${rate} out of range (0.5-2.0)` }] };
65
+ const err = new KeshaError("E_INVALID_ARG", `rate ${rate} out of range (0.5-2.0)`);
66
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
66
67
  }
67
68
  const fmt: SayFormat = format ?? "wav";
68
69
  const outPath = allocAudioPath(fmt);
@@ -121,7 +122,7 @@ export function registerTools(server: McpServer): void {
121
122
  },
122
123
  async ({ path, timestamps }, extra) => {
123
124
  if (extra.signal?.aborted) {
124
- return { isError: true, content: [{ type: "text" as const, text: "request cancelled" }] };
125
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(new KeshaError("E_INTERRUPTED", "request cancelled")) }] };
125
126
  }
126
127
  if (!existsSync(path)) {
127
128
  const hint = isAbsolute(path)
package/src/progress.ts CHANGED
@@ -36,12 +36,28 @@ export function estimatePercent(elapsedMs: number, estimatedTotalMs: number): nu
36
36
  return Math.max(1, Math.min(99, Math.floor((elapsedMs / targetMs) * 99)));
37
37
  }
38
38
 
39
+ // Whatever was painted on the row before, by this bar or another, would show past a shorter frame (#1440).
40
+ const ERASE_TO_EOL = "\x1b[K";
41
+
42
+ /** Cuts `text` to `max` terminal columns, ending in `…` when cut; wide (CJK) characters count as two. */
43
+ function fitWidth(text: string, max: number): string {
44
+ if (Bun.stringWidth(text) <= max) return text;
45
+ if (max <= 0) return "";
46
+ let out = "";
47
+ for (const { segment } of new Intl.Segmenter().segment(text)) {
48
+ if (Bun.stringWidth(`${out}${segment}…`) > max) break;
49
+ out += segment;
50
+ }
51
+ return `${out}…`;
52
+ }
53
+
39
54
  export function createPercentProgress(
40
55
  label: string,
41
56
  options: { estimatedTotalMs?: number; intervalMs?: number } = {},
42
57
  ): {
43
58
  finish(finalLabel: string): void;
44
59
  interrupt(writeLine: () => void): void;
60
+ status(line: string): void;
45
61
  stop(): void;
46
62
  } {
47
63
  const isTTY = process.stderr.isTTY;
@@ -55,6 +71,9 @@ export function createPercentProgress(
55
71
  interrupt(writeLine: () => void) {
56
72
  writeLine();
57
73
  },
74
+ status(line: string) {
75
+ process.stderr.write(`${line}\n`);
76
+ },
58
77
  stop() {},
59
78
  };
60
79
  }
@@ -62,18 +81,23 @@ export function createPercentProgress(
62
81
  const intervalMs = options.intervalMs ?? 250;
63
82
  const estimatedTotalMs = options.estimatedTotalMs ?? DEFAULT_ESTIMATED_PROGRESS_MS;
64
83
  const startedAt = performance.now();
65
- let lastLineLength = 0;
84
+ let painted = false;
66
85
  let timer: Timer | undefined;
67
86
  let stopped = false;
68
87
  let firstRender = true;
88
+ let statusText = "";
69
89
 
70
90
  const render = () => {
71
91
  if (stopped) return;
72
92
  const percent = firstRender ? 0 : estimatePercent(performance.now() - startedAt, estimatedTotalMs);
73
93
  firstRender = false;
74
- const line = formatPercentProgress(label, percent);
75
- lastLineLength = line.length;
76
- process.stderr.write(`\r${line}`);
94
+ const rest = formatPercentProgress("", percent) + (statusText ? ` ${statusText}` : "");
95
+ // A row wider than the terminal wraps, and `\r` then repaints only its last part; the file name gives way first.
96
+ const columns = process.stderr.columns;
97
+ const name = columns ? fitWidth(label, columns - 1 - Bun.stringWidth(rest)) : label;
98
+ const line = columns ? fitWidth(name + rest, columns - 1) : name + rest;
99
+ painted = true;
100
+ process.stderr.write(`\r${line}${ERASE_TO_EOL}`);
77
101
  };
78
102
 
79
103
  const clearLine = (stopTimer: boolean) => {
@@ -82,9 +106,9 @@ export function createPercentProgress(
82
106
  clearInterval(timer);
83
107
  timer = undefined;
84
108
  }
85
- if (lastLineLength > 0) {
86
- process.stderr.write(`\r${" ".repeat(lastLineLength)}\r`);
87
- lastLineLength = 0;
109
+ if (painted) {
110
+ process.stderr.write(`\r${ERASE_TO_EOL}`);
111
+ painted = false;
88
112
  }
89
113
  };
90
114
 
@@ -106,6 +130,14 @@ export function createPercentProgress(
106
130
  render();
107
131
  timer = setInterval(render, intervalMs);
108
132
  },
133
+ status(line: string) {
134
+ if (stopped) {
135
+ process.stderr.write(`${line}\n`);
136
+ return;
137
+ }
138
+ statusText = line;
139
+ render();
140
+ },
109
141
  stop() {
110
142
  clearLine(true);
111
143
  },
@@ -119,11 +151,11 @@ export function createPercentProgress(
119
151
  */
120
152
  export function createLiveStatus(): { update(line: string): void; clear(): void } {
121
153
  const isTTY = process.stderr.isTTY && !log.quietEnabled;
122
- let painted = 0;
154
+ let painted = false;
123
155
  const erase = () => {
124
- if (painted === 0) return;
125
- process.stderr.write(`\r${" ".repeat(painted)}\r`);
126
- painted = 0;
156
+ if (!painted) return;
157
+ process.stderr.write(`\r${ERASE_TO_EOL}`);
158
+ painted = false;
127
159
  };
128
160
  // Ctrl-C exits through `process.exit` without unwinding the spawn's `finally` (src/process-tree.ts),
129
161
  // so without this the painted row survives into the shell prompt.
@@ -131,13 +163,12 @@ export function createLiveStatus(): { update(line: string): void; clear(): void
131
163
  return {
132
164
  update(line: string) {
133
165
  if (!isTTY) return;
134
- if (painted === 0) process.once("exit", onExit);
135
- const pad = painted > line.length ? " ".repeat(painted - line.length) : "";
136
- process.stderr.write(`\r${line}${pad}`);
137
- painted = line.length;
166
+ if (!painted) process.once("exit", onExit);
167
+ process.stderr.write(`\r${line}${ERASE_TO_EOL}`);
168
+ painted = true;
138
169
  },
139
170
  clear() {
140
- if (painted === 0) return;
171
+ if (!painted) return;
141
172
  erase();
142
173
  process.removeListener("exit", onExit);
143
174
  },
@@ -303,11 +334,11 @@ export function createProgressBar(label: string, totalBytes: number): {
303
334
  if (pct === lastPct) return;
304
335
  lastPct = pct;
305
336
  const line = formatProgressBar(label, current, totalBytes);
306
- process.stderr.write(`\r${line}`);
337
+ process.stderr.write(`\r${line}${ERASE_TO_EOL}`);
307
338
  },
308
339
  finish() {
309
340
  const line = formatProgressBar(label, totalBytes, totalBytes);
310
- process.stderr.write(`\r${line}\n`);
341
+ process.stderr.write(`\r${line}${ERASE_TO_EOL}\n`);
311
342
  },
312
343
  };
313
344
  }
package/src/synth.ts CHANGED
@@ -102,6 +102,42 @@ export function engineCrashMessage(
102
102
  return `${base}.`;
103
103
  }
104
104
 
105
+ // ©, ® and ™ stay, emoji presentation (U+FE0F) or not: read aloud they still mean something.
106
+ const KEPT_SYMBOL = /([©®™])️/g;
107
+ const EMOJI = /\p{RGI_Emoji}|[\p{Extended_Pictographic}--[©®™]]/gv;
108
+
109
+ /**
110
+ * Emoji are decoration in a reply; a voice would read out their Unicode names (#1388).
111
+ * Emoji-only text, SSML tags aside, goes through unchanged, so the engine still refuses it as E_SCRIPT_UNSUPPORTED.
112
+ */
113
+ export function dropEmoji(text: string, ssml = false): string {
114
+ const spoken = (ssml ? decodeCharRefs(text) : text).replace(KEPT_SYMBOL, "$1").replace(EMOJI, "");
115
+ const speech = ssml ? spoken.replace(CDATA, "$1").replace(/<[^>]*>/g, "") : spoken;
116
+ return speech.trim().length > 0 ? spoken : text;
117
+ }
118
+
119
+ const CDATA = /<!\[CDATA\[([\s\S]*?)\]\]>/g;
120
+ const NUMERIC_REF = /&#(?:x([0-9a-f]+)|(\d+));/gi;
121
+
122
+ /**
123
+ * The engine decodes SSML character references, so `&#x1F60A;` is an emoji too. Decoding them first lets the
124
+ * emoji filter see sequences spelled across references; CDATA keeps its text literal, and markup characters stay escaped.
125
+ */
126
+ function decodeCharRefs(ssml: string): string {
127
+ return ssml
128
+ .split(/(<!\[CDATA\[[\s\S]*?\]\]>)/)
129
+ .map((part) =>
130
+ part.startsWith("<![CDATA[")
131
+ ? part
132
+ : part.replace(NUMERIC_REF, (ref, hex?: string, dec?: string) => {
133
+ const cp = hex !== undefined ? parseInt(hex, 16) : Number(dec);
134
+ const plain = cp >= 0x20 && cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff) && !`<>&'"`.includes(String.fromCodePoint(cp));
135
+ return plain ? String.fromCodePoint(cp) : ref;
136
+ }),
137
+ )
138
+ .join("");
139
+ }
140
+
105
141
  /**
106
142
  * The text contract both doors enforce, before anything that costs a subprocess.
107
143
  * Throws `KeshaError`; the CLI runs it before voice routing, `say()` for programmatic callers.
@@ -133,7 +169,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
133
169
  for (const warning of warnings) log.warn(warning);
134
170
  const { exitCode, signalCode, aborted, stdout, events } = await runEngineProcess(getEngineBinPath(), args, {
135
171
  stdio: ["pipe", "pipe", "pipe"],
136
- stdin: text,
172
+ stdin: dropEmoji(text, opts.ssml),
137
173
  readStdout: (stream) => new Response(stream).arrayBuffer(),
138
174
  signal: opts.signal,
139
175
  });