@drakulavich/kesha-voice-kit 2.3.0-beta.2 → 2.3.0-beta.4

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
@@ -16,7 +16,7 @@
16
16
  - **Transcribe locally** — [25 languages](docs/languages.md#speech-to-text-25), up to ~19x faster than Whisper on Apple Silicon, ~10x on an x86 CPU
17
17
  - **Speak back** — text-to-speech in [9 languages](docs/languages.md#text-to-speech)
18
18
  - **Plug into agents** — ship voice workflows as CLI commands, an MCP server, an <a href="docs/openclaw.md">OpenClaw</a> skill, or a <a href="docs/hermes.md">Hermes</a> agent
19
- - **Small Rust engine** — single ~65MB binary, no ffmpeg, no Python, no native Node addons
19
+ - **Small Rust engine** — single ~74 MB binary, no ffmpeg, no Python, no native Node addons
20
20
 
21
21
  <p align="center">
22
22
  <img src="https://github.com/drakulavich/kesha-voice-kit/raw/main/docs/assets/demo.webp" alt="kesha demo: an English voice message transcribed, a Russian one as JSON with automatic language detection, then text-to-speech into a wav" width="800">
@@ -127,7 +127,7 @@ kesha say "Hello" --format ogg-opus --out hi.ogg # OGG/Opus — messenger voice
127
127
  kesha say "Hello" --format flac --out hi.flac # FLAC — lossless, plays in every browser incl. Safari/iOS
128
128
  ```
129
129
 
130
- `kesha say --list-voices` lists what's installed. Voices, the full catalogue, macOS system voices, SSML, speaking rate (`--rate`, `<prosody>`), Russian word stress, and Russian/English abbreviation handling are all in **[docs/tts.md](docs/tts.md)**.
130
+ `kesha say --list-voices` lists the voices kesha knows; some need `kesha install --tts <lang>` before they speak. Voices, the full catalogue, macOS system voices, SSML, speaking rate (`--rate`, `<prosody>`), Russian word stress, and Russian/English abbreviation handling are all in **[docs/tts.md](docs/tts.md)**.
131
131
 
132
132
  ## Languages
133
133
 
@@ -78,7 +78,7 @@ complete -c kesha -n '__fish_seen_subcommand_from say' -l voice -r -d 'Voice id,
78
78
  complete -c kesha -n '__fish_seen_subcommand_from say' -l lang -r -d 'Language code, e.g. en-us (default en-us; see docs/languages.md). With no --voice, routes to that language\'s default voice and skips text-language detection.'
79
79
  complete -c kesha -n '__fish_seen_subcommand_from say' -l out -r -d 'Write audio to file instead of stdout'
80
80
  complete -c kesha -n '__fish_seen_subcommand_from say' -l rate -r -d 'Speaking rate 0.5–2.0'
81
- complete -c kesha -n '__fish_seen_subcommand_from say' -l list-voices -d 'List installed voices and exit'
81
+ complete -c kesha -n '__fish_seen_subcommand_from say' -l list-voices -d 'List the voices kesha knows and exit; some need kesha install --tts <lang> first'
82
82
  complete -c kesha -n '__fish_seen_subcommand_from say' -l ssml -d 'Parse input as SSML (supports <speak>, <break>; strips unknown tags)'
83
83
  complete -c kesha -n '__fish_seen_subcommand_from say' -l format -r -d 'Output format: wav (default), ogg-opus (Telegram-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from --out extension when omitted.'
84
84
  complete -c kesha -n '__fish_seen_subcommand_from say' -l bitrate -r -d 'Opus bitrate in bits/sec (e.g. 32000). Only with --format ogg-opus.'
@@ -112,7 +112,7 @@ _kesha() {
112
112
  '--lang=[Language code, e.g. en-us (default en-us; see docs/languages.md). With no --voice, routes to that language'\''s default voice and skips text-language detection.]:lang:' \
113
113
  '--out=[Write audio to file instead of stdout]:out:' \
114
114
  '--rate=[Speaking rate 0.5–2.0]:rate:' \
115
- '--list-voices[List installed voices and exit]' \
115
+ '--list-voices[List the voices kesha knows and exit; some need kesha install --tts <lang> first]' \
116
116
  '--ssml[Parse input as SSML (supports <speak>, <break>; strips unknown tags)]' \
117
117
  '--format=[Output format: wav (default), ogg-opus (Telegram-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from --out extension when omitted.]:format:' \
118
118
  '--bitrate=[Opus bitrate in bits/sec (e.g. 32000). Only with --format ogg-opus.]:bitrate:' \
package/man/kesha.1 CHANGED
@@ -191,7 +191,7 @@ Write audio to file instead of stdout
191
191
  Speaking rate 0.5–2.0
192
192
  .TP
193
193
  .B \-\-list\-voices
194
- List installed voices and exit
194
+ List the voices kesha knows and exit; some need kesha install \-\-tts <lang> first
195
195
  .TP
196
196
  .B \-\-ssml
197
197
  Parse input as SSML (supports <speak>, <break>; strips unknown tags)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "2.3.0-beta.2",
3
+ "version": "2.3.0-beta.4",
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": {
@@ -109,18 +109,18 @@
109
109
  },
110
110
  "kesha": {
111
111
  "engine": {
112
- "version": "2.3.0-beta.2",
112
+ "version": "2.3.0-beta.4",
113
113
  "sha256": {
114
- "kesha-engine-darwin-arm64": "febd870e9f857be6452336c74daad161f2cef74dfedb64a21b49b6b1fc2ce94c",
115
- "kesha-engine-linux-x64": "9c16e72e5290f774b1d93cbf44b2fc6eca3c4b30659038ccb80f5e98dc748d2a",
116
- "kesha-engine-windows-x64.exe": "231bc45eb345ef359ca94685866de8cb4c7c76ae9bef1072c20a60640c458f63",
114
+ "kesha-engine-darwin-arm64": "fb07453ea83ccb5ce04edbb8dc2da0092ffb295fa1b76483dbac0958f2ad110d",
115
+ "kesha-engine-linux-x64": "aaff821808af07a19acd94e46b94262d568d962dcd5b90f4065e5482ac8a943a",
116
+ "kesha-engine-windows-x64.exe": "ddd5eb3143dcd6ad55cb74b8e83a0c524afc16f8d52b2776dec0037e24d77aec",
117
117
  "say-avspeech-darwin-arm64": "6c7e0708f27c1baf3d8013bfbeb6249a6c3acbb718b3d39081707c7a8aa22e5b",
118
118
  "kesha-textlang-darwin-arm64": "cbe74ee1aebdb902e21d4c69769c71b6d49e037794a8623db11faa8cf2814420"
119
119
  },
120
120
  "size": {
121
- "kesha-engine-darwin-arm64": 73672800,
122
- "kesha-engine-linux-x64": 67418152,
123
- "kesha-engine-windows-x64.exe": 66596864,
121
+ "kesha-engine-darwin-arm64": 73656256,
122
+ "kesha-engine-linux-x64": 67422600,
123
+ "kesha-engine-windows-x64.exe": 66576384,
124
124
  "say-avspeech-darwin-arm64": 82896,
125
125
  "kesha-textlang-darwin-arm64": 57488
126
126
  }
@@ -1,6 +1,9 @@
1
1
  import { existsSync, realpathSync, statSync } from "fs";
2
2
  import { dirname, join, resolve, sep } from "path";
3
3
  import { dirSizeBytes } from "./diagnostic-paths";
4
+ import { isDarwinArm64 } from "./engine-targets";
5
+
6
+ export const KOKORO_ONNX_UNUSED_NOTE = "not used on darwin-arm64, where Kokoro runs from the TTS (Kokoro ANE) bundle";
4
7
 
5
8
  /** The sidecar executables the engine spawns from its own directory. */
6
9
  const SIDECAR_FILES = ["say-avspeech", "kesha-textlang"] as const;
@@ -10,6 +13,7 @@ export interface CacheComponentSize {
10
13
  path: string;
11
14
  exists: boolean;
12
15
  sizeBytes: number;
16
+ note?: string;
13
17
  }
14
18
 
15
19
  interface EngineFootprint {
@@ -101,14 +105,33 @@ export function cacheComponents(
101
105
  exists: existsSync(engine.path),
102
106
  sizeBytes: footprintBytes(engine, engine.members),
103
107
  };
108
+ const coremlRows = [
109
+ { label: "FluidAudio (in cache)", path: join(cacheRoot, "fluidaudio") },
110
+ { label: "Diarization (Sortformer)", path: join(cacheRoot, "models/diarize") },
111
+ ];
112
+ const onnxRows = [
113
+ { label: "ASR (Parakeet)", path: join(cacheRoot, "models/parakeet-tdt-v3") },
114
+ { label: "G2P (CharsiuG2P)", path: join(cacheRoot, "models/g2p") },
115
+ ];
116
+ const [usedRows, unusedRows] = coreml ? [coremlRows, onnxRows] : [onnxRows, coremlRows];
117
+ const sized = (row: { label: string; path: string }) => ({
118
+ ...row,
119
+ exists: existsSync(row.path),
120
+ sizeBytes: dirSizeBytes(row.path),
121
+ });
104
122
  const modelRows = [
105
- ...(coreml
106
- ? [{ label: "FluidAudio (in cache)", path: join(cacheRoot, "fluidaudio") }]
107
- : [{ label: "ASR (Parakeet)", path: join(cacheRoot, "models/parakeet-tdt-v3") }]),
123
+ ...usedRows,
108
124
  { label: "Language ID", path: join(cacheRoot, "models/lang-id-ecapa") },
109
125
  { label: "VAD (Silero)", path: join(cacheRoot, "models/silero-vad") },
110
- { label: "TTS (Kokoro)", path: join(cacheRoot, "models/kokoro-82m") },
126
+ {
127
+ label: "TTS (Kokoro)",
128
+ path: join(cacheRoot, "models/kokoro-82m"),
129
+ ...(isDarwinArm64() ? { note: KOKORO_ONNX_UNUSED_NOTE } : {}),
130
+ },
111
131
  { label: "TTS (Vosk)", path: join(cacheRoot, "models/vosk-ru") },
112
- ].map((row) => ({ ...row, exists: existsSync(row.path), sizeBytes: dirSizeBytes(row.path) }));
113
- return [engineRow, ...modelRows];
132
+ ].map(sized);
133
+ const leftoverRows = unusedRows
134
+ .map((row) => sized({ ...row, label: `${row.label}, unused by this engine, safe to delete` }))
135
+ .filter((row) => row.exists);
136
+ return [engineRow, ...modelRows, ...leftoverRows];
114
137
  }
@@ -183,6 +183,11 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
183
183
  await showUsage(rawArgs.includes("--all") ? command : briefMainUsage(command));
184
184
  return;
185
185
  }
186
+ const options = rawArgs.slice(0, rawArgs.includes("--") ? rawArgs.indexOf("--") : undefined);
187
+ if (options.includes("--version") || options.includes("-v")) {
188
+ process.stdout.write(`${packageVersion}\n`);
189
+ return;
190
+ }
186
191
  await runMain(reportThrows(command), { rawArgs, showUsage });
187
192
  }
188
193
  }
package/src/cli/main.ts CHANGED
@@ -1,7 +1,15 @@
1
1
  import { defineCommand, type ArgsDef, type CommandDef } from "citty";
2
2
  import { errorMessage } from "../error-utils";
3
3
  import { existsSync } from "fs";
4
- import { isDirectoryPath, isSpecialFilePath, SPECIAL_FILE_HINT, transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
4
+ import {
5
+ isDirectoryPath,
6
+ isSpecialFilePath,
7
+ missingFileHint,
8
+ SPECIAL_FILE_HINT,
9
+ transcribeWithSegments,
10
+ truncatedWavSeconds,
11
+ validateTranscribeRequest,
12
+ } from "../transcribe";
5
13
 
6
14
  export { isDirectoryPath };
7
15
  import { detectAudioLanguageEngine, detectTextLanguageEngine, isEngineInstalled } from "../engine";
@@ -81,14 +89,12 @@ export function resolveOutputFormat(input: {
81
89
  error: `unknown --format '${input.format}'. supported: ${SUPPORTED_FORMATS.join(", ")}`,
82
90
  };
83
91
  }
84
- if (
85
- (input.json && input.toon) ||
86
- (input.json && input.format === "toon") ||
87
- (input.toon && input.format === "json")
88
- ) {
92
+ const jsonFlag = input.json ? "--json" : input.format === "json" ? "--format json" : null;
93
+ const toonFlag = input.toon ? "--toon" : input.format === "toon" ? "--format toon" : null;
94
+ if (jsonFlag && toonFlag) {
89
95
  return {
90
96
  ok: false,
91
- error: "--json and --toon are mutually exclusive (pick one output format).",
97
+ error: `${jsonFlag} and ${toonFlag} are mutually exclusive (pick one output format).`,
92
98
  };
93
99
  }
94
100
  // `--format transcript` + boolean `--json` / `--toon` was previously
@@ -321,8 +327,19 @@ async function processFile(
321
327
  const { vadMode, timestamps, speakers, itn, wantsLangId, expectedLang, reportProgress } = options;
322
328
  const { stats, diagnosticLog } = recorders;
323
329
 
330
+ if (file === "-") {
331
+ const err = new KeshaError("E_INVALID_ARG", "reading audio from stdin is not supported", { hint: SPECIAL_FILE_HINT });
332
+ stats.recordError("input", err, err.code);
333
+ diagnosticLog.event("input.invalid", {
334
+ command: "transcribe",
335
+ error_code: err.code,
336
+ });
337
+ log.error(`${file}: ${errorMessage(err)}`);
338
+ return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
339
+ }
340
+
324
341
  if (!existsSync(file)) {
325
- const err = new KeshaError("E_INPUT_NOT_FOUND", "File not found");
342
+ const err = new KeshaError("E_INPUT_NOT_FOUND", "File not found", { hint: missingFileHint(file) });
326
343
  stats.recordError("input", err, err.code);
327
344
  diagnosticLog.event("input.missing", {
328
345
  command: "transcribe",
@@ -358,6 +375,13 @@ async function processFile(
358
375
  return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
359
376
  }
360
377
 
378
+ const truncated = truncatedWavSeconds(file);
379
+ if (truncated) {
380
+ log.warn(
381
+ `warning: ${file} is truncated: its header declares ${truncated.declared.toFixed(1)} s of audio but the file holds ${truncated.present.toFixed(1)} s; transcribing the part present`,
382
+ );
383
+ }
384
+
361
385
  const inputArtifact = artifactFromFile(file, "input_audio");
362
386
  if (inputArtifact) {
363
387
  stats.recordArtifact(inputArtifact);
@@ -526,20 +550,25 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
526
550
  " kesha init Guided first-time setup.\n" +
527
551
  "\n" +
528
552
  "Commands:\n" +
529
- " completions Print shell completion script.\n" +
530
- " doctor Collect support diagnostics.\n" +
531
- " init Interactive setup guide for Kesha features.\n" +
532
- " install Download engine and models.\n" +
533
- " logs Manage local privacy-safe diagnostic logs.\n" +
534
- " manpage Print the kesha(1) manpage.\n" +
535
- " mcp Run an MCP server over stdio.\n" +
536
- " record Record microphone audio to a WAV file.\n" +
537
- " status Inspect installed backend.\n" +
538
- " say Synthesize speech from text.\n" +
539
- " stats Manage local anonymous performance stats.\n" +
540
- " support-bundle Create a redacted diagnostics archive.",
553
+ " completions Print shell completion script.\n" +
554
+ " doctor Collect support diagnostics.\n" +
555
+ " init Interactive setup guide for Kesha features.\n" +
556
+ " install Download engine and models.\n" +
557
+ " logs Manage local privacy-safe diagnostic logs.\n" +
558
+ " manpage Print the kesha(1) manpage.\n" +
559
+ " mcp Run an MCP server over stdio.\n" +
560
+ " record Record microphone audio to a WAV file.\n" +
561
+ " status Inspect installed backend.\n" +
562
+ " say Synthesize speech from text.\n" +
563
+ " stats Manage local anonymous performance stats.\n" +
564
+ " support-bundle Create a redacted diagnostics archive.\n",
541
565
  },
542
566
  args: {
567
+ audio_file: {
568
+ type: "positional",
569
+ required: false,
570
+ description: "Audio file(s) to transcribe",
571
+ },
543
572
  json: {
544
573
  type: "boolean",
545
574
  description: "Output results as JSON",
@@ -71,6 +71,11 @@ function scanArgs(rawArgs: string[], argsDef: ArgsDef): { unknown: UnknownOption
71
71
  return { unknown: null, positionals };
72
72
  }
73
73
 
74
+ /** The positionals in `rawArgs` as the user typed them; citty reads `-5 degrees` as a bundle of short flags (#1473 S2-5). */
75
+ export function positionalArgs(rawArgs: string[], argsDef: ArgsDef): string[] {
76
+ return scanArgs(rawArgs, argsDef).positionals;
77
+ }
78
+
74
79
  /** The coded line for a usage error the CLI refuses before doing anything (S9-F2). */
75
80
  export function renderInvalidArg(message: string): string {
76
81
  return renderError({ code: "E_INVALID_ARG", message });
package/src/cli/record.ts CHANGED
@@ -3,14 +3,13 @@ import { errorMessage } from "../error-utils";
3
3
  import { exitCodeFor, KeshaError } from "../engine/events";
4
4
  import { renderInvalidArg } from "./options";
5
5
  import {
6
- getEngineBinPath,
6
+ engineMissingError,
7
7
  isEngineInstalled,
8
8
  recordEngine,
9
9
  validateRecordRequest,
10
10
  type LiveAutoStopOptions,
11
11
  type RecordTarget,
12
12
  } from "../engine";
13
- import { installHint } from "../install-hint";
14
13
  import { log } from "../log";
15
14
  import { getPendingSignalExitCode, waitForPendingSignalCleanup } from "../process-tree";
16
15
 
@@ -143,11 +142,7 @@ function resolveThreshold(
143
142
 
144
143
  /** Mirrors `src/transcribe.ts`'s "no transcription backend" guard message, worded for recording. */
145
144
  export function noRecordingBackendMessage(): string {
146
- return errorMessage(
147
- new KeshaError("E_ENGINE_SPAWN", `No recording backend is installed: no kesha-engine at ${getEngineBinPath()}`, {
148
- hint: `bun add -g @drakulavich/kesha-voice-kit, then ${installHint()}`,
149
- }),
150
- );
145
+ return errorMessage(engineMissingError("recording"));
151
146
  }
152
147
 
153
148
  export const recordCommand = defineCommand({
package/src/cli/say.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { defineCommand } from "citty";
1
+ import { defineCommand, type ArgsDef } from "citty";
2
2
  import { statSync } from "fs";
3
+ import { extname } from "path";
3
4
  import { errorMessage } from "../error-utils";
5
+ import { engineMissingError, isEngineInstalled } from "../engine";
4
6
  import { exitCodeFor, KeshaError } from "../engine/events";
5
- import { renderInvalidArg } from "./options";
7
+ import { positionalArgs, renderInvalidArg } from "./options";
6
8
  import { log } from "../log";
7
9
  import {
8
10
  MAX_TEXT_CHARS,
@@ -130,6 +132,16 @@ function checkOpusOnlyFlags(
130
132
  return "--bitrate and --sample-rate are only valid with --format ogg-opus";
131
133
  }
132
134
 
135
+ const OUT_EXTENSIONS = [".wav", ".ogg", ".opus", ".oga", ".flac"];
136
+
137
+ /** Mirrors the engine's `format_from_extension`, which writes WAV under any name it does not know (#1473 S2-4). */
138
+ function checkOutExtension(outArg: string | undefined): string | null {
139
+ if (outArg === undefined) return null;
140
+ const ext = extname(outArg).toLowerCase();
141
+ if (ext === "" || OUT_EXTENSIONS.includes(ext)) return null;
142
+ return `--out ${outArg}: kesha cannot encode ${ext}; use one of ${OUT_EXTENSIONS.join(", ")}, or no extension for WAV`;
143
+ }
144
+
133
145
  export type SayFlagArgs = {
134
146
  voice?: unknown;
135
147
  lang?: unknown;
@@ -181,6 +193,8 @@ export function resolveSayFlags(args: SayFlagArgs): ResolvedSayFlags {
181
193
  const out = typeof args.out === "string" ? args.out : undefined;
182
194
  const opusOnlyError = checkOpusOnlyFlags(bitrate.value, sampleRate.value, format.value, out);
183
195
  if (opusOnlyError) return { ok: false, error: opusOnlyError };
196
+ const outExtError = checkOutExtension(out);
197
+ if (outExtError) return { ok: false, error: outExtError };
184
198
 
185
199
  return {
186
200
  ok: true,
@@ -255,6 +269,7 @@ async function synthesizeAndEmit(
255
269
  const { stats } = session;
256
270
  try {
257
271
  const startedAt = performance.now();
272
+ if (!isEngineInstalled()) throw engineMissingError("speech synthesis", "--tts");
258
273
  if (opts.out) {
259
274
  const voiceLabel = opts.voice ?? "default voice";
260
275
  log.status(`Synthesizing ${voiceLabel} -> ${opts.out}...`);
@@ -312,7 +327,7 @@ export const sayCommand = defineCommand({
312
327
  },
313
328
  out: { type: "string", description: "Write audio to file instead of stdout" },
314
329
  rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
315
- "list-voices": { type: "boolean", description: "List installed voices and exit" },
330
+ "list-voices": { type: "boolean", description: "List the voices kesha knows and exit; some need kesha install --tts <lang> first" },
316
331
  ssml: {
317
332
  type: "boolean",
318
333
  description: "Parse input as SSML (supports <speak>, <break>; strips unknown tags)",
@@ -376,7 +391,7 @@ export const sayCommand = defineCommand({
376
391
  process.exit(2);
377
392
  }
378
393
 
379
- const positionals = (args._ ?? []).map(String);
394
+ const positionals = positionalArgs(rawArgs, sayCommand.args as ArgsDef);
380
395
  const inlineText =
381
396
  positionals.length > 0 ? positionals.join(" ") : typeof args.text === "string" ? args.text : undefined;
382
397
  const stdinIsTty = (process.stdin as { isTTY?: boolean }).isTTY;
@@ -21,7 +21,7 @@ import { packageVersion } from "./package-info";
21
21
 
22
22
  const ACTIVE_LOG_FILE = "kesha.ndjson";
23
23
  const STATE_FILE = "diagnostic-logs.json";
24
- const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
24
+ const DEFAULT_MAX_BYTES = 10_000_000;
25
25
  const DEFAULT_RETAIN = 5;
26
26
  const DEFAULT_TAIL_BYTES = 64 * 1024;
27
27
  const SAFE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
@@ -321,12 +321,12 @@ function rotateIfNeeded(activePath: string, nextBytes: number, maxBytes: number,
321
321
  }
322
322
 
323
323
  export function humanBytes(bytes: number): string {
324
- if (bytes < 1024) return `${bytes} B`;
324
+ if (bytes < 1000) return `${bytes} B`;
325
325
  const units = ["KB", "MB", "GB", "TB"];
326
- let value = bytes / 1024;
326
+ let value = bytes / 1000;
327
327
  let unit = units[0];
328
- for (let i = 1; i < units.length && value >= 1024; i++) {
329
- value /= 1024;
328
+ for (let i = 1; i < units.length && value >= 1000; i++) {
329
+ value /= 1000;
330
330
  unit = units[i];
331
331
  }
332
332
  return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`;
package/src/doctor.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "fs";
2
- import { cacheComponents, cacheTotalBytes } from "./cache-layout";
2
+ import { cacheComponents, cacheTotalBytes, KOKORO_ONNX_UNUSED_NOTE } from "./cache-layout";
3
3
  import { errorMessage } from "./error-utils";
4
4
  import { humanBytes } from "./format";
5
5
  import { dirname, join } from "path";
@@ -17,6 +17,7 @@ import { engineVersion, packageName, packageVersion } from "./package-info";
17
17
  import { getStatsStatus, type StatsStatus } from "./stats";
18
18
  import { kokoroAneComponents, kokoroTtsLanguages } from "./kokoro-ane";
19
19
  import { isCoremlBackend } from "./fluid-asr-cache";
20
+ import { isDarwinArm64 } from "./engine-targets";
20
21
  import {
21
22
  fluidExternalRoots,
22
23
  fluidExternalTotalBytes,
@@ -36,6 +37,7 @@ const KNOWN_ENV_KEYS = [
36
37
  "KESHA_CACHE_DIR",
37
38
  "KESHA_MODEL_MIRROR",
38
39
  "KESHA_STATS_DB",
40
+ "KESHA_LOG_DIR",
39
41
  "KESHA_DEBUG",
40
42
  "KESHA_KOKORO_COMPUTE_UNITS",
41
43
  "KESHA_DIARIZE_COMPUTE_UNITS",
@@ -58,6 +60,7 @@ interface PathSummary {
58
60
 
59
61
  interface CacheComponent extends PathSummary {
60
62
  label: string;
63
+ note?: string;
61
64
  }
62
65
 
63
66
  interface OptionalComponent extends PathSummary {
@@ -318,12 +321,12 @@ async function collectOptionalComponents(
318
321
  },
319
322
  {
320
323
  name: "TTS (Kokoro)",
321
- note: "enabled with `kesha install --tts`",
324
+ note: isDarwinArm64() ? KOKORO_ONNX_UNUSED_NOTE : "enabled with `kesha install --tts`",
322
325
  ...pathSummary(join(cache, "models/kokoro-82m")),
323
326
  },
324
327
  {
325
328
  name: "TTS (Vosk RU)",
326
- note: "enabled with `kesha install --tts`",
329
+ note: "enabled with `kesha install --tts ru`",
327
330
  ...pathSummary(join(cache, "models/vosk-ru")),
328
331
  },
329
332
  ...kokoroAneComponents({ homeDir, cacheRoot: cache }).map((c) => ({
@@ -395,7 +398,7 @@ function collectDiagnosticLogs(redact: boolean): DoctorReport["diagnosticLogs"]
395
398
  rotatedFiles: [],
396
399
  totalSizeBytes: 0,
397
400
  mode: "retain-on-failure",
398
- maxBytes: 10 * 1024 * 1024,
401
+ maxBytes: 10_000_000,
399
402
  retain: 5,
400
403
  error:
401
404
  redactString("diagnosticLogsError", errorMessage(err), redact) ??
@@ -501,7 +504,7 @@ function formatCapabilities(engine: DoctorReport["engine"]): string {
501
504
  function formatCacheSection(cache: DoctorReport["cache"]): string[] {
502
505
  const header = `Cache: ${cache.path} (${cache.exists ? humanBytes(cache.totalBytes) : "missing"})`;
503
506
  const rows = cache.components.map(
504
- (c) => ` ${c.label}: ${c.exists ? humanBytes(c.sizeBytes) : "missing"}`,
507
+ (c) => ` ${c.label}: ${c.exists ? humanBytes(c.sizeBytes) : "missing"}${c.note ? ` - ${c.note}` : ""}`,
505
508
  );
506
509
  return [header, ...rows, ...formatExternalRootsSection(cache)];
507
510
  }
@@ -1,7 +1,7 @@
1
1
  import { dirname, join, resolve, sep } from "path";
2
2
  import { errorMessage } from "./error-utils";
3
3
  import { homedir, tmpdir } from "os";
4
- import { existsSync, mkdirSync, chmodSync, accessSync, constants, rmSync } from "fs";
4
+ import { existsSync, mkdirSync, chmodSync, accessSync, constants, readFileSync, rmSync, writeFileSync } from "fs";
5
5
  import { getDescribe, getEngineBinPath } from "./engine";
6
6
  import { engineFunctionalHealth, probeExecutable, readExecutableVersion } from "./engine-health";
7
7
  import {
@@ -159,6 +159,26 @@ async function rejectMismatchedDownload(
159
159
  );
160
160
  }
161
161
 
162
+ /** `<pin> <sha256 after codesign>` for a download that matched its pin before `darwinTrustBinary` rewrote it. */
163
+ function signedShaPath(path: string): string {
164
+ return `${path}.signed-sha256`;
165
+ }
166
+
167
+ function readSignedSha(path: string): string | null {
168
+ try {
169
+ return readFileSync(signedShaPath(path), "utf8").trim();
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ async function trustVerifiedDownload(path: string, displayName: string, pinnedSha: string): Promise<void> {
176
+ darwinTrustBinary(path, displayName);
177
+ if (process.platform !== "darwin") return;
178
+ rmSync(signedShaPath(path), { recursive: true, force: true });
179
+ writeFileSync(signedShaPath(path), `${pinnedSha} ${await sha256OfFile(path)}\n`);
180
+ }
181
+
162
182
  /**
163
183
  * Whether a cached asset of the pinned release still hashes to its pin; one that does not is
164
184
  * deleted so the verified download replaces it, or refused when its directory is read-only.
@@ -180,7 +200,7 @@ async function cachedAssetMatchesPin(
180
200
  } catch (e) {
181
201
  throw canRepair ? e : unreadableInReadOnlyDir(path, e);
182
202
  }
183
- if (actual === expected.sha256) return true;
203
+ if (actual === expected.sha256 || readSignedSha(path) === `${expected.sha256} ${actual}`) return true;
184
204
  const mismatch =
185
205
  `Installed ${what} ${assetName} at ${path} does not match ${expected.source} ` +
186
206
  `(expected sha256 ${expected.sha256}, got ${actual})`;
@@ -369,7 +389,7 @@ async function downloadSidecar(
369
389
  return;
370
390
  }
371
391
  chmodSync(sidecarPath, 0o755);
372
- darwinTrustBinary(sidecarPath, spec.displayName);
392
+ await trustVerifiedDownload(sidecarPath, spec.displayName, expected.sha256);
373
393
  log.progress(`${spec.displayName} installed (${spec.availableHint}).`);
374
394
  } catch (e) {
375
395
  log.warn(
@@ -674,7 +694,7 @@ async function fetchEngineBinary(
674
694
  throw new KeshaError("E_CACHE_CORRUPT", refusal, { hint: MISMATCH_HINT });
675
695
  }
676
696
  chmodSync(binPath, 0o755);
677
- darwinTrustBinary(binPath, "kesha-engine binary");
697
+ await trustVerifiedDownload(binPath, "kesha-engine binary", expected.sha256);
678
698
  // Marker last: writing it first sends the retry down the cacheValid branch, which never waits.
679
699
  await waitUntilSpawnable(binPath);
680
700
  writeInstalledEngineVersion(binPath, version);
package/src/engine.ts CHANGED
@@ -7,6 +7,7 @@ import { log } from "./log";
7
7
  import { createLiveStatus } from "./progress";
8
8
  import { defaultEngineBinPath, keshaCacheDir } from "./paths";
9
9
  import { engineAbortError, interruptedRun } from "./process-tree";
10
+ import { resolveStatePaths } from "./state-paths";
10
11
  import { engineExitCause, engineSignal, runEngineProcess, spawnHint } from "./engine/spawn";
11
12
  import { engineFailure, KeshaError, quoteInvalidLine, renderError, SUPPORT_BUNDLE_HINT, type ErrorEvent } from "./engine/events";
12
13
  import { MIN_ENGINE_VERSION, predatesMinimumEngine, readInstalledEngineVersion } from "./engine-version-marker";
@@ -76,6 +77,17 @@ export function isEngineInstalled(): boolean {
76
77
  return existsSync(getEngineBinPath());
77
78
  }
78
79
 
80
+ /** The E_ENGINE_SPAWN for a missing engine: it names the path looked at and the variable that chose it. */
81
+ export function engineMissingError(backend: string, ...installFlags: string[]): KeshaError {
82
+ const cacheSource = resolveStatePaths().cacheDir.source;
83
+ const hint = process.env.KESHA_ENGINE_BIN
84
+ ? spawnHint()
85
+ : cacheSource === "default"
86
+ ? `bun add -g @drakulavich/kesha-voice-kit, then ${installHint(...installFlags)}`
87
+ : `${cacheSource} is set; run \`${installHint(...installFlags)}\` to install there, or unset it`;
88
+ return new KeshaError("E_ENGINE_SPAWN", `No ${backend} backend is installed: no kesha-engine at ${getEngineBinPath()}`, { hint });
89
+ }
90
+
79
91
  export interface RunEngineOptions {
80
92
  signal?: AbortSignal;
81
93
  /** Text for the subprocess's stdin. Nothing a user typed may become an argv element: a NUL byte or a megabyte of it makes the spawn itself fail (#T1-1). */
package/src/format.ts CHANGED
@@ -69,14 +69,14 @@ export function formatJsonOutput(
69
69
  return JSON.stringify(payload, null, 2) + "\n";
70
70
  }
71
71
 
72
- /** Render a byte count as a human-readable size (e.g. "1.5 MB"). */
72
+ /** Render a byte count as a human-readable decimal size (e.g. "1.5 MB" for 1,500,000 bytes). */
73
73
  export function humanBytes(bytes: number): string {
74
- if (bytes < 1024) return `${bytes} B`;
74
+ if (bytes < 1000) return `${bytes} B`;
75
75
  const units = ["KB", "MB", "GB", "TB"];
76
- let n = bytes / 1024;
76
+ let n = bytes / 1000;
77
77
  let i = 0;
78
- while (n >= 1024 && i < units.length - 1) {
79
- n /= 1024;
78
+ while (n >= 1000 && i < units.length - 1) {
79
+ n /= 1000;
80
80
  i++;
81
81
  }
82
82
  return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
@@ -116,7 +116,7 @@ function releaseAssetSize(assetName: string, version: string): number | null {
116
116
  function filesCached(cacheRoot: string, files: PlanFile[]): boolean {
117
117
  return files.every((file) => {
118
118
  const path = join(cacheRoot, file.relPath);
119
- return existsSync(path) && statSync(path).size > 0;
119
+ return existsSync(path) && statSync(path).size === file.sizeBytes;
120
120
  });
121
121
  }
122
122
 
@@ -467,7 +467,7 @@ function renderBehaviorLines(ttsLangs: string[], wantsAnyKokoro: boolean): strin
467
467
  isDarwinArm64()
468
468
  ? " - macOS install signs/unquarantines downloaded binaries for Gatekeeper."
469
469
  : " - No macOS Gatekeeper signing step on this platform.",
470
- " - install warms the ASR backend after downloads; CoreML warm-up is typically 20-30 s, ONNX warm-up is about 500 ms.",
470
+ " - install warms the ASR backend after downloads; CoreML warm-up typically takes 1-2 min, ONNX warm-up is about 500 ms.",
471
471
  ];
472
472
  if (ttsLangs.length > 0 && wantsAnyKokoro && isDarwinArm64()) {
473
473
  lines.push(
package/src/mcp/tools.ts CHANGED
@@ -30,11 +30,11 @@ export function registerTools(server: McpServer): void {
30
30
  try {
31
31
  bytes = readFileSync(filePath);
32
32
  } catch {
33
- // The SDK sends only `message`, so the code and hint go inside it.
33
+ // The SDK sends only `message` and `code`; McpError would prepend "MCP error -32002:" to the message.
34
34
  const coded = new KeshaError("E_INPUT_NOT_FOUND", `synthesized-audio: file not found or already swept: ${name}`, {
35
35
  hint: "synthesized files are deleted after 24 hours; call synthesize_speech again.",
36
36
  });
37
- throw new Error(errorMessage(coded));
37
+ throw Object.assign(new Error(errorMessage(coded)), { code: -32002 });
38
38
  }
39
39
  return { contents: [{ uri: uri.href, mimeType: mimeForExt(name), blob: bytes.toString("base64") }] };
40
40
  },
@@ -46,7 +46,7 @@ export function registerTools(server: McpServer): void {
46
46
  title: "Synthesize speech",
47
47
  description:
48
48
  "Synthesize speech from text into an audio file and return a resource link. " +
49
- "Omit voice to auto-route by language (male defaults en-am_michael / ru-vosk-m02).",
49
+ "Omit voice (or pass an empty string) to auto-route by language (male defaults en-am_michael / ru-vosk-m02).",
50
50
  inputSchema: {
51
51
  text: z.string().min(1).describe("Text to speak"),
52
52
  voice: z.string().optional().describe("Voice id, e.g. en-am_michael"),
@@ -84,7 +84,7 @@ export function registerTools(server: McpServer): void {
84
84
  }
85
85
  try {
86
86
  // An MCP caller has no stderr to read: the voice it is told is its only record (#942).
87
- const resolvedVoice = (await resolveSayVoice(voice, undefined, text, { signal: extra.signal })) ?? DEFAULT_VOICE_ID;
87
+ const resolvedVoice = (await resolveSayVoice(voice || undefined, undefined, text, { signal: extra.signal })) ?? DEFAULT_VOICE_ID;
88
88
  await say({ text, voice: resolvedVoice, rate, format: fmt, out: outPath, signal: extra.signal });
89
89
  chmodSync(outPath, 0o600);
90
90
  const bytes = statSync(outPath).size;
@@ -163,7 +163,7 @@ export function registerTools(server: McpServer): void {
163
163
  "list_voices",
164
164
  {
165
165
  title: "List voices",
166
- description: "List installed TTS voices with their engine and language.",
166
+ description: "List the TTS voices kesha knows, with their engine and language. Some need `kesha install --tts <lang>` before they synthesize.",
167
167
  inputSchema: {},
168
168
  outputSchema: {
169
169
  voices: z.array(
@@ -182,7 +182,7 @@ export function registerTools(server: McpServer): void {
182
182
  async () => {
183
183
  try {
184
184
  const voices = await listVoices();
185
- const text = voices.length === 0 ? `0 voices installed. Run: ${installHint("--tts")}` : `${voices.length} voices installed.`;
185
+ const text = voices.length === 0 ? `0 voices installed. Run: ${installHint("--tts")}` : `${voices.length} voices known; some need kesha install --tts <lang> before use.`;
186
186
  return {
187
187
  content: [{ type: "text" as const, text }],
188
188
  structuredContent: { voices },
@@ -197,7 +197,7 @@ export function registerTools(server: McpServer): void {
197
197
  "list_languages",
198
198
  {
199
199
  title: "List languages",
200
- description: "List languages available for synthesis, derived from installed voices.",
200
+ description: "List languages available for synthesis, derived from the voices list_voices reports.",
201
201
  inputSchema: {},
202
202
  outputSchema: {
203
203
  languages: z.array(
package/src/mcp/voices.ts CHANGED
@@ -106,6 +106,7 @@ export async function listVoices(): Promise<VoiceInfo[]> {
106
106
  export function aggregateLanguages(voices: VoiceInfo[]): LanguageInfo[] {
107
107
  const map = new Map<string, LanguageInfo>();
108
108
  for (const v of voices) {
109
+ if (!v.languageCode) continue;
109
110
  const existing = map.get(v.languageCode);
110
111
  if (existing) existing.voiceCount++;
111
112
  else map.set(v.languageCode, { languageCode: v.languageCode, languageName: v.languageName, voiceCount: 1 });
package/src/progress.ts CHANGED
@@ -8,7 +8,7 @@ const BAR_WIDTH = 20;
8
8
  const DEFAULT_ESTIMATED_PROGRESS_MS = 30 * 60 * 1000;
9
9
 
10
10
  export function formatBytes(bytes: number): string {
11
- return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
11
+ return `${(bytes / 1000 / 1000).toFixed(1)}MB`;
12
12
  }
13
13
 
14
14
  function bar(pct: number): string {
@@ -110,6 +110,14 @@ function existsAsLink(path: string): boolean {
110
110
  }
111
111
  }
112
112
 
113
+ function isRegularFile(path: string): boolean {
114
+ try {
115
+ return statSync(path).isFile();
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
113
121
  function fileInTheWay(dir: string): string | null {
114
122
  for (let d = dir; ; d = dirname(d)) {
115
123
  try {
@@ -168,6 +176,11 @@ export async function createSupportBundle(
168
176
  ): Promise<SupportBundleResult> {
169
177
  const now = options.now ?? new Date();
170
178
  const outputPath = resolve(options.output ?? defaultBundlePath(now));
179
+ if (isRegularFile(outputPath)) {
180
+ throw new KeshaError("E_INVALID_ARG", `Cannot write the support bundle to ${outputPath}: the file already exists`, {
181
+ hint: `pass --output a path that does not exist yet, or delete ${outputPath} first.`,
182
+ });
183
+ }
171
184
  const redact = options.redact ?? true;
172
185
  const report = await collectDoctorReport({ redact });
173
186
  const root = basename(outputPath).replace(/\.tar\.gz$/, "");
package/src/synth.ts CHANGED
@@ -1,8 +1,7 @@
1
- import { getDescribe, getEngineBinPath, isEngineInstalled } from "./engine";
1
+ import { engineMissingError, getDescribe, getEngineBinPath, isEngineInstalled } from "./engine";
2
2
  import { engineSignal, runEngineProcess } from "./engine/spawn";
3
3
  import { validateArgv } from "./engine/describe";
4
4
  import { engineFailure, exitCodeFor, KeshaError, type EventSinks } from "./engine/events";
5
- import { installHint } from "./install-hint";
6
5
  import { log } from "./log";
7
6
  import { engineAbortError, interruptedRun } from "./process-tree";
8
7
 
@@ -155,10 +154,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
155
154
  validateSayText(text);
156
155
 
157
156
  if (!isEngineInstalled()) {
158
- throw new KeshaError("E_ENGINE_SPAWN", `kesha-engine not installed. run: ${installHint("--tts")}`, {
159
- exitCode: 1,
160
- stderr: "",
161
- });
157
+ throw engineMissingError("speech synthesis", "--tts");
162
158
  }
163
159
  const { argv: args, warnings } = validateArgv(buildSayArgs({ ...opts, text: undefined }), await getDescribe({ signal: opts.signal }));
164
160
  for (const warning of warnings) log.warn(warning);
@@ -195,7 +191,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
195
191
  /** Installed voice ids as the engine lists them, one per line; a missing engine, a build without tts or a failed run is a KeshaError. */
196
192
  export async function listVoiceIds(sinks: EventSinks = {}, signal?: AbortSignal): Promise<string[]> {
197
193
  if (!isEngineInstalled()) {
198
- throw new KeshaError("E_ENGINE_SPAWN", `kesha-engine not installed. run: ${installHint()}`);
194
+ throw engineMissingError("speech synthesis");
199
195
  }
200
196
  if (signal?.aborted) throw engineAbortError();
201
197
  const { argv, warnings } = validateArgv(["say", "--list-voices"], await getDescribe({ signal }));
package/src/transcribe.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  assertSpeakerModelsInstalled,
3
3
  buildTranscribeArgs,
4
+ engineMissingError,
4
5
  getDescribe,
5
6
  isEngineInstalled,
6
7
  transcribeEngine,
@@ -10,8 +11,8 @@ import {
10
11
  } from "./engine";
11
12
  import { validateArgv } from "./engine/describe";
12
13
  import { KeshaError } from "./engine/events";
13
- import { installHint } from "./install-hint";
14
- import { existsSync, statSync } from "fs";
14
+ import { closeSync, existsSync, fstatSync, openSync, readSync, statSync } from "fs";
15
+ import { isAbsolute } from "path";
15
16
 
16
17
  export type { VadMode };
17
18
 
@@ -24,11 +25,18 @@ export function isDirectoryPath(path: string): boolean {
24
25
  }
25
26
  }
26
27
 
28
+ /** The next step for E_INPUT_NOT_FOUND, shared by the CLI and the Core API. */
29
+ export function missingFileHint(path: string): string {
30
+ return isAbsolute(path) ? "check the path" : `check the path, relative to the current directory ${process.cwd()}`;
31
+ }
32
+
27
33
  export const SPECIAL_FILE_HINT =
28
34
  "kesha reads its input more than once, so a named pipe, /dev/stdin or <(...) cannot work; save the audio to a file and pass that.";
29
35
 
30
36
  /** True when `path` exists and is neither a regular file nor a directory: the engine opens its input more than once, so a FIFO, `/dev/stdin` or `<(...)` hangs it (#1468). */
31
37
  export function isSpecialFilePath(path: string): boolean {
38
+ // stat follows /dev/stdin to whatever stdin holds, so `kesha /dev/stdin < en.wav` would look like a regular file.
39
+ if (path === "/dev/stdin" || path.startsWith("/dev/fd/")) return true;
32
40
  try {
33
41
  const st = statSync(path);
34
42
  return !st.isFile() && !st.isDirectory();
@@ -37,6 +45,38 @@ export function isSpecialFilePath(path: string): boolean {
37
45
  }
38
46
  }
39
47
 
48
+ /** Seconds a RIFF WAV's data chunk declares and seconds the file holds, when the file is shorter than its header says; null otherwise. */
49
+ export function truncatedWavSeconds(path: string): { declared: number; present: number } | null {
50
+ let fd: number | undefined;
51
+ try {
52
+ fd = openSync(path, "r");
53
+ const size = fstatSync(fd).size;
54
+ const buf = Buffer.alloc(20);
55
+ if (readSync(fd, buf, 0, 12, 0) < 12 || buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WAVE") {
56
+ return null;
57
+ }
58
+ let byteRate = 0;
59
+ for (let offset = 12; offset + 8 <= size; ) {
60
+ readSync(fd, buf, 0, 20, offset);
61
+ const id = buf.toString("ascii", 0, 4);
62
+ const length = buf.readUInt32LE(4);
63
+ if (id === "fmt ") byteRate = buf.readUInt32LE(16);
64
+ if (id === "data") {
65
+ const present = size - offset - 8;
66
+ // Streaming writers leave 0xFFFFFFFF in the size field when they cannot seek back to fill it in.
67
+ if (length === 0xffffffff || byteRate === 0 || length <= present) return null;
68
+ return { declared: length / byteRate, present: present / byteRate };
69
+ }
70
+ offset += 8 + length + (length % 2);
71
+ }
72
+ return null;
73
+ } catch {
74
+ return null;
75
+ } finally {
76
+ if (fd !== undefined) closeSync(fd);
77
+ }
78
+ }
79
+
40
80
  export interface TranscribeOptions {
41
81
  /** Silero VAD preprocessing selector. Defaults to `"auto"`. */
42
82
  vad?: VadMode;
@@ -62,7 +102,7 @@ export interface TranscribeOptions {
62
102
  /** The refusals the CLI makes before it spawns anything, so an agent branching on the documented code sees it on every surface. */
63
103
  export function assertAudioFileArgument(audioPath: string): void {
64
104
  if (!existsSync(audioPath)) {
65
- throw new KeshaError("E_INPUT_NOT_FOUND", `File not found: ${audioPath}`);
105
+ throw new KeshaError("E_INPUT_NOT_FOUND", `File not found: ${audioPath}`, { hint: missingFileHint(audioPath) });
66
106
  }
67
107
  if (isDirectoryPath(audioPath)) {
68
108
  throw new KeshaError("E_INVALID_ARG", `${audioPath}: is a directory (expected an audio file)`);
@@ -75,9 +115,7 @@ export function assertAudioFileArgument(audioPath: string): void {
75
115
  /** The CLI's gate before any progress UI: the engine, its describe document, the request's flags, and the model files a request needs; the argv actually sent is validated again at the spawn. */
76
116
  export async function validateTranscribeRequest(opts: TranscribeOptions = {}): Promise<void> {
77
117
  if (!isEngineInstalled()) {
78
- throw new KeshaError("E_ENGINE_SPAWN", "No transcription backend is installed", {
79
- hint: `bun add -g @drakulavich/kesha-voice-kit, then ${installHint()}`,
80
- });
118
+ throw engineMissingError("transcription");
81
119
  }
82
120
  validateArgv(
83
121
  buildTranscribeArgs(