@drakulavich/kesha-voice-kit 1.30.0 → 2.0.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.
Files changed (55) hide show
  1. package/README.md +5 -5
  2. package/SKILL.md +1 -1
  3. package/completions/kesha.bash +1 -1
  4. package/completions/kesha.fish +0 -1
  5. package/completions/kesha.zsh +3 -1
  6. package/docs/diagnostic-logs.md +36 -3
  7. package/docs/product-positioning.md +3 -3
  8. package/model-plan.json +130 -0
  9. package/package.json +21 -7
  10. package/src/cache-layout.ts +78 -8
  11. package/src/cli/action-result.ts +2 -1
  12. package/src/cli/command-session.ts +14 -3
  13. package/src/cli/completions.ts +4 -2
  14. package/src/cli/dispatch.ts +37 -5
  15. package/src/cli/init.ts +4 -4
  16. package/src/cli/install.ts +9 -6
  17. package/src/cli/main.ts +61 -46
  18. package/src/cli/mcp.ts +2 -0
  19. package/src/cli/options.ts +79 -0
  20. package/src/cli/record.ts +2 -1
  21. package/src/cli/say.ts +92 -11
  22. package/src/cli/stats.ts +9 -4
  23. package/src/cli/support-bundle.ts +3 -3
  24. package/src/cli.ts +1 -1
  25. package/src/diagnostic-log.ts +2 -9
  26. package/src/doctor.ts +62 -11
  27. package/src/engine/events.ts +20 -1
  28. package/src/engine/spawn.ts +114 -0
  29. package/src/engine-health.ts +25 -56
  30. package/src/engine-install.ts +230 -109
  31. package/src/engine-targets.ts +53 -5
  32. package/src/engine.ts +87 -102
  33. package/src/fluid-roots.ts +5 -0
  34. package/src/format.ts +11 -5
  35. package/src/install-lock.ts +45 -11
  36. package/src/install-plan.ts +96 -48
  37. package/src/kokoro-ane.ts +67 -4
  38. package/src/language-routing.ts +51 -0
  39. package/src/lib.ts +96 -61
  40. package/src/log.ts +2 -0
  41. package/src/mcp/audio-output.ts +2 -2
  42. package/src/mcp/tools.ts +10 -14
  43. package/src/package-info.ts +28 -2
  44. package/src/paths.ts +2 -2
  45. package/src/process-tree.ts +70 -18
  46. package/src/semver.mjs +7 -10
  47. package/src/shell-artifacts.ts +4 -3
  48. package/src/state-paths.ts +84 -0
  49. package/src/stats.ts +2 -10
  50. package/src/status.ts +39 -42
  51. package/src/synth.ts +58 -79
  52. package/src/transcribe.ts +18 -3
  53. package/src/voice-inventory.ts +51 -0
  54. package/src/voice-routing.ts +97 -6
  55. package/tsconfig.json +1 -1
package/src/cli/init.ts CHANGED
@@ -32,16 +32,16 @@ export type TtsLangPrompt = (preselect: string[]) => Promise<string[]>;
32
32
  /** Signature of the interactive yes/no prompt (injectable for tests). */
33
33
  export type ConfirmPrompt = (message: string, initialValue: boolean) => Promise<boolean>;
34
34
 
35
- /** Ctrl-C at any prompt ends init without downloading, rather than falling through with a default. */
35
+ /** Ctrl-C at any prompt ends init without downloading, exiting 130 like an interrupted install so `kesha init && …` stops (S4-F1). */
36
36
  function exitIfCancelled<T>(answer: T | symbol): T {
37
37
  if (isCancel(answer)) {
38
38
  cancel("Init cancelled.");
39
- process.exit(0);
39
+ process.exit(130);
40
40
  }
41
41
  return answer as T;
42
42
  }
43
43
 
44
- async function promptTtsLangs(preselect: string[]): Promise<string[]> {
44
+ export async function promptTtsLangs(preselect: string[]): Promise<string[]> {
45
45
  // A corrupt engine must leave init offering the fallback list rather than aborting the
46
46
  // guided setup that would replace it (#770).
47
47
  const caps = await getEngineCapabilities().catch(() => null);
@@ -57,7 +57,7 @@ async function promptTtsLangs(preselect: string[]): Promise<string[]> {
57
57
  }
58
58
 
59
59
  /** #677: keep every prompt on clack — its close() unpipes stdin, deadlocking any readline sharing it. */
60
- async function promptConfirm(message: string, initialValue: boolean): Promise<boolean> {
60
+ export async function promptConfirm(message: string, initialValue: boolean): Promise<boolean> {
61
61
  return exitIfCancelled(await confirm({ message, initialValue }));
62
62
  }
63
63
 
@@ -6,6 +6,7 @@ import { getEngineBinPath, getEngineCapabilities, type EngineCapabilities } from
6
6
  import { exitCodeFor, KeshaError } from "../engine/events";
7
7
  import { renderInstallPlan } from "../install-plan";
8
8
  import { maybeAskForStar } from "../star";
9
+ import { renderInvalidArg } from "./options";
9
10
  import { log } from "../log";
10
11
  import { packageVersion } from "../package-info";
11
12
  import { isSemver } from "../semver.mjs";
@@ -85,10 +86,12 @@ export function resolveEngineVersionFlag(raw: unknown): string | undefined {
85
86
  const value = String(raw).trim();
86
87
  if (isSemver(value)) return value;
87
88
  const hint = /^v\d/.test(value)
88
- ? ' Drop the leading "v" — that belongs to the release tag, not the version.'
89
- : "";
90
- throw new Error(
91
- `--engine-version needs an exact SemVer 2.0 version like 1.24.8 or 1.24.8-alpha.1, got "${value}".${hint}`,
89
+ ? 'drop the leading "v" — that belongs to the release tag, not the version.'
90
+ : undefined;
91
+ throw new KeshaError(
92
+ "E_INVALID_ARG",
93
+ `--engine-version needs an exact SemVer 2.0 version like 1.24.8 or 1.24.8-alpha.1, got "${value}".`,
94
+ hint === undefined ? {} : { hint },
92
95
  );
93
96
  }
94
97
 
@@ -321,8 +324,8 @@ export const installCommand = defineCommand({
321
324
  try {
322
325
  ttsLangs = resolveTtsLangs({ tts: args.tts === true, positionals }, supported);
323
326
  } catch (err) {
324
- log.error(errorMessage(err));
325
- process.exit(1);
327
+ log.error(renderInvalidArg(errorMessage(err)));
328
+ process.exit(2);
326
329
  }
327
330
  await performInstall({
328
331
  noCache: resolveNoCacheFlag(args, rawArgs),
package/src/cli/main.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { errorMessage } from "../error-utils";
3
- import { existsSync, statSync } from "fs";
4
- import { detectAll } from "tinyld";
5
- import { transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
3
+ import { existsSync } from "fs";
4
+ import { isDirectoryPath, transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
5
+
6
+ export { isDirectoryPath };
6
7
  import { detectAudioLanguageEngine, detectTextLanguageEngine } from "../engine";
7
8
  import type { LangDetectResult } from "../engine";
8
9
  import { log } from "../log";
@@ -11,19 +12,21 @@ import {
11
12
  formatJsonOutput,
12
13
  formatTextOutput,
13
14
  formatTranscriptOutput,
14
- formatVerboseOutput,
15
+ formatVerboseDiagnostics,
15
16
  } from "../format";
16
17
  import { packageVersion } from "../package-info";
17
18
  import { formatToonOutput } from "../toon";
18
19
  import { artifactFromFile, type StatsRecorder } from "../stats";
19
20
  import { createPercentProgress } from "../progress";
20
- import { getPendingSignalExitCode, waitForPendingSignalCleanup } from "../process-tree";
21
+ import { getPendingSignalExitCode, pendingInterruption, waitForPendingSignalCleanup } from "../process-tree";
21
22
  import type { TranscriptionSegment } from "../types";
22
23
  import { diagnosticSizeBucket } from "../diagnostic-events";
23
24
  import { runCommandSession, type CommandSession } from "./command-session";
24
25
  import { USAGE_MESSAGE } from "./dispatch";
25
26
  import type { CliContext } from "./context";
26
- import { KeshaError } from "../engine/events";
27
+ import { exitCodeFor, KeshaError } from "../engine/events";
28
+ import { detectTextLanguageFallback, routeLanguage } from "../language-routing";
29
+ import { renderInvalidArg } from "./options";
27
30
 
28
31
  interface MainCommandArgs {
29
32
  _: string[];
@@ -43,32 +46,12 @@ interface MainCommandArgs {
43
46
  "no-color": boolean;
44
47
  }
45
48
 
46
- /**
47
- * The CLI-side `tinyld` fallback, tagged `source: "tinyld"` so a consumer can
48
- * tell it apart from an Engine detection instead of reading the placeholder
49
- * `confidence: 0` it used to carry as a genuine zero (#941). The score is
50
- * tinyld's own and is not on the Engine's scale.
51
- */
52
- export function detectTextLanguageFallback(text: string): TextLangDetectResult | undefined {
53
- if (!text) return undefined;
54
- const [best] = detectAll(text);
55
- if (!best) return undefined;
56
- return { code: best.lang, confidence: best.accuracy, source: "tinyld" };
57
- }
49
+ export { detectTextLanguageFallback };
58
50
 
59
51
  export function detectLanguage(text: string): string {
60
52
  return detectTextLanguageFallback(text)?.code ?? "";
61
53
  }
62
54
 
63
- /** True when `path` exists and is a directory. Used to reject directory positionals before any progress/engine spawn. */
64
- export function isDirectoryPath(path: string): boolean {
65
- try {
66
- return statSync(path).isDirectory();
67
- } catch {
68
- return false;
69
- }
70
- }
71
-
72
55
  /**
73
56
  * Pulled out of the citty `run` handler so the contract is unit-testable.
74
57
  * Mutex check happens AFTER format validation so unknown `--format` surfaces
@@ -121,8 +104,14 @@ export function resolveOutputFormat(input: {
121
104
  return { ok: true, format: input.json ? "json" : input.toon ? "toon" : (input.format ?? "text") };
122
105
  }
123
106
 
107
+ /** Case-folded primary subtag: `en-US`, `EN` and `en_us` all reduce to `en`; a three-letter `eng` stays `eng`. */
108
+ export function primaryLanguageSubtag(code: string): string {
109
+ return code.trim().toLowerCase().split(/[-_]/, 1)[0] ?? "";
110
+ }
111
+
124
112
  export function checkLanguageMismatch(expected: string | undefined, detected: string): string | null {
125
- if (!expected || !detected || expected === detected) return null;
113
+ if (!expected || !detected) return null;
114
+ if (primaryLanguageSubtag(expected) === primaryLanguageSubtag(detected)) return null;
126
115
  return `warning: expected language "${expected}" but detected "${detected}"`;
127
116
  }
128
117
 
@@ -214,13 +203,21 @@ export function validateTranscribeArgs(
214
203
  return { ok: true, vadMode, outputFormat: fmt.format };
215
204
  }
216
205
 
206
+ type FileProgress = ReturnType<typeof createPercentProgress> | null;
207
+
208
+ /** A warning is not progress: it must reach stderr under --quiet, where no bar exists to interrupt. */
209
+ function warnAboveProgress(progress: FileProgress, message: string): void {
210
+ if (progress) progress.interrupt(() => log.warn(message));
211
+ else log.warn(message);
212
+ }
213
+
217
214
  async function detectLanguages(
218
215
  file: string,
219
216
  text: string,
220
217
  options: {
221
218
  wantsLangId: boolean;
222
219
  expectedLang?: string;
223
- progress: ReturnType<typeof createPercentProgress> | null;
220
+ progress: FileProgress;
224
221
  stats: StatsRecorder;
225
222
  transcriptDurationSeconds: number | null;
226
223
  },
@@ -250,7 +247,7 @@ async function detectLanguages(
250
247
  if (audioLanguage && expectedLang && audioLanguage.confidence > 0.8) {
251
248
  const mismatch = checkLanguageMismatch(expectedLang, audioLanguage.code);
252
249
  if (mismatch) {
253
- progress?.interrupt(() => log.warn(`${file}: ${mismatch} (from audio)`));
250
+ warnAboveProgress(progress, `${file}: ${mismatch} (from audio)`);
254
251
  }
255
252
  }
256
253
 
@@ -264,11 +261,13 @@ async function detectLanguages(
264
261
  }
265
262
  }
266
263
 
267
- const lang = textLanguage?.code || tinyldResult?.code || "";
264
+ const route = routeLanguage({ audioLanguage, textLanguage: textLanguage ?? tinyldResult });
265
+ const { lang } = route;
268
266
 
269
- const mismatchWarning = checkLanguageMismatch(expectedLang, lang);
267
+ // A lang taken from audio was already judged by the audio check above; warning again would double it.
268
+ const mismatchWarning = route.source === "audio" ? null : checkLanguageMismatch(expectedLang, lang);
270
269
  if (mismatchWarning) {
271
- progress?.interrupt(() => log.warn(`${file}: ${mismatchWarning}`));
270
+ warnAboveProgress(progress, `${file}: ${mismatchWarning}`);
272
271
  }
273
272
 
274
273
  return {
@@ -290,7 +289,13 @@ type ProcessFileOptions = {
290
289
  };
291
290
 
292
291
  type ProcessFileSuccess = { ok: true; result: TranscribeResult };
293
- type ProcessFileFailure = { ok: false; error: TranscribeErrorRecord };
292
+ type ProcessFileFailure = { ok: false; error: TranscribeErrorRecord; exitCode: number };
293
+
294
+ /** An argument the CLI itself rejected exits by its code (2); every runtime failure keeps the batch's operational 1 (S9-F1). */
295
+ function failureExitCode(err: unknown): number {
296
+ const usage = err instanceof KeshaError && err.origin === "cli" && err.code === "E_INVALID_ARG";
297
+ return usage ? exitCodeFor(err) : 1;
298
+ }
294
299
 
295
300
  async function processFile(
296
301
  file: string,
@@ -308,7 +313,7 @@ async function processFile(
308
313
  error_code: err.code,
309
314
  });
310
315
  log.error(`${file}: ${errorMessage(err)}`);
311
- return { ok: false, error: { file, code: err.code, message: err.message } };
316
+ return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
312
317
  }
313
318
 
314
319
  if (isDirectoryPath(file)) {
@@ -319,7 +324,7 @@ async function processFile(
319
324
  error_code: err.code,
320
325
  });
321
326
  log.error(`${file}: ${errorMessage(err)}`);
322
- return { ok: false, error: { file, code: err.code, message: err.message } };
327
+ return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
323
328
  }
324
329
 
325
330
  const inputArtifact = artifactFromFile(file, "input_audio");
@@ -337,6 +342,7 @@ async function processFile(
337
342
  // #1002: the bar sitting at 0% through a ~100 s diarization model load reads as a hang,
338
343
  // so the engine's progress has to land while it happens, not once the run is over.
339
344
  const showProgressLine = (line: string) => {
345
+ if (log.quietEnabled) return;
340
346
  const write = () => process.stderr.write(`${line}\n`);
341
347
  if (progress) progress.interrupt(write);
342
348
  else write();
@@ -403,17 +409,18 @@ async function processFile(
403
409
  error_code: code,
404
410
  });
405
411
  log.error(`${file}: ${stderrText}`);
406
- return { ok: false, error: { file, code, message: stderrText } };
412
+ return { ok: false, error: { file, code, message: stderrText }, exitCode: failureExitCode(err) };
407
413
  }
408
414
  }
409
415
 
410
- /** `verbose` is only consulted for the plain-text fallback path. */
416
+ /** `--verbose` diagnostics are stderr's whatever the format; stdout carries results only. */
411
417
  function writeOutput(
412
418
  results: TranscribeResult[],
413
419
  errors: TranscribeErrorRecord[],
414
420
  format: ValidatedTranscribeArgs["outputFormat"],
415
421
  opts: { includeErrors: boolean; verbose: boolean },
416
422
  ): void {
423
+ if (opts.verbose) process.stderr.write(formatVerboseDiagnostics(results));
417
424
  const errorEnvelope = (format === "json" || format === "toon") && opts.includeErrors;
418
425
  // #773: `[]` reads as "ran fine, nothing found" to a consumer ignoring the exit code.
419
426
  if (results.length === 0 && errors.length > 0 && !errorEnvelope) return;
@@ -424,8 +431,6 @@ function writeOutput(
424
431
  process.stdout.write(formatToonOutput(results, opts.includeErrors ? errors : undefined));
425
432
  } else if (format === "transcript") {
426
433
  process.stdout.write(formatTranscriptOutput(results));
427
- } else if (opts.verbose) {
428
- process.stdout.write(formatVerboseOutput(results));
429
434
  } else {
430
435
  process.stdout.write(formatTextOutput(results));
431
436
  }
@@ -550,20 +555,21 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
550
555
  format: args.format,
551
556
  });
552
557
  if (!fmt.ok) {
553
- log.error(fmt.error);
558
+ log.error(renderInvalidArg(fmt.error));
554
559
  process.exit(2);
555
560
  }
556
561
 
557
562
  const validated = validateTranscribeArgs(args, rawArgs, fmt);
558
563
  if (!validated.ok) {
559
- log.error(validated.error);
564
+ log.error(renderInvalidArg(validated.error));
560
565
  process.exit(2);
561
566
  }
562
567
  const { vadMode, outputFormat } = validated;
563
568
 
564
569
  if (files.length === 0) {
565
- log.info(USAGE_MESSAGE);
566
- process.exit(1);
570
+ log.error(renderInvalidArg("no input file"));
571
+ process.stderr.write(`${USAGE_MESSAGE}\n`);
572
+ process.exit(2);
567
573
  }
568
574
 
569
575
  const wantsLangId = !!(args.lang || args.verbose || outputFormat !== "text");
@@ -574,7 +580,7 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
574
580
  quiet: context.quiet,
575
581
  });
576
582
 
577
- const { status } = await runCommandSession(
583
+ const { status, exitCode } = await runCommandSession(
578
584
  "transcribe",
579
585
  {
580
586
  itemCount: files.length,
@@ -589,8 +595,15 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
589
595
  async (session) => {
590
596
  const results: TranscribeResult[] = [];
591
597
  const errors: TranscribeErrorRecord[] = [];
598
+ let exitCode = 0;
592
599
 
593
600
  for (const file of files) {
601
+ const interrupted = pendingInterruption();
602
+ if (interrupted) {
603
+ log.error(`${file}: ${errorMessage(interrupted)}`);
604
+ errors.push({ file, code: interrupted.code, message: interrupted.message });
605
+ continue;
606
+ }
594
607
  const outcome = await processFile(
595
608
  file,
596
609
  {
@@ -608,6 +621,7 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
608
621
  results.push(outcome.result);
609
622
  } else {
610
623
  errors.push(outcome.error);
624
+ exitCode = Math.max(exitCode, outcome.exitCode);
611
625
  }
612
626
  }
613
627
 
@@ -619,6 +633,7 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
619
633
  return {
620
634
  status: errors.length > 0 ? "failed" : "success",
621
635
  itemCount: files.length,
636
+ exitCode: errors.length > 0 ? exitCode : undefined,
622
637
  finishFields: {
623
638
  itemCount: files.length,
624
639
  resultCount: results.length,
@@ -634,7 +649,7 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
634
649
  await waitForPendingSignalCleanup();
635
650
  process.exit(signalExitCode);
636
651
  }
637
- process.exit(1);
652
+ process.exit(exitCode ?? 1);
638
653
  }
639
654
  },
640
655
  });
package/src/cli/mcp.ts CHANGED
@@ -13,5 +13,7 @@ export const mcpCommand = defineCommand({
13
13
  const server = createKeshaMcpServer();
14
14
  const transport = new StdioServerTransport();
15
15
  await server.connect(transport);
16
+ // The SDK transport never watches for EOF, so a client that died mid-call left the engine running (exploratory S7-2).
17
+ process.stdin.once("end", () => void server.close());
16
18
  },
17
19
  });
@@ -0,0 +1,79 @@
1
+ import type { ArgsDef } from "citty";
2
+ import { renderError } from "../engine/events";
3
+ import { log } from "../log";
4
+ import { suggestCommand } from "../suggest-command";
5
+
6
+ export interface UnknownOption {
7
+ name: string;
8
+ suggestion: string | null;
9
+ }
10
+
11
+ const RUNNER_LONG = ["help", "version"];
12
+ const RUNNER_SHORT = ["h", "v"];
13
+
14
+ function camelCase(name: string): string {
15
+ return name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
16
+ }
17
+
18
+ /** The first flag `argsDef` does not declare, or null; citty parses non-strict, so the flag would otherwise vanish and its value become a positional (S9-F3). */
19
+ export function findUnknownOption(rawArgs: string[], argsDef: ArgsDef): UnknownOption | null {
20
+ const long = new Set(RUNNER_LONG);
21
+ const short = new Set(RUNNER_SHORT);
22
+ const valued = new Set<string>();
23
+ const suggestable: string[] = [];
24
+ for (const [name, def] of Object.entries(argsDef)) {
25
+ if (def.type === "positional") continue;
26
+ const alias = "alias" in def ? def.alias : undefined;
27
+ const aliases = alias === undefined ? [] : Array.isArray(alias) ? alias : [alias];
28
+ for (const spelling of [name, ...aliases]) {
29
+ if (spelling.length === 1) {
30
+ short.add(spelling);
31
+ } else {
32
+ long.add(spelling);
33
+ long.add(camelCase(spelling));
34
+ suggestable.push(spelling);
35
+ }
36
+ if (def.type === "boolean") long.add(`no-${spelling}`);
37
+ else valued.add(spelling);
38
+ }
39
+ }
40
+ const suggest = (name: string): string | null => {
41
+ const match = suggestCommand(name, suggestable);
42
+ return match === null ? null : `--${match}`;
43
+ };
44
+ for (let i = 0; i < rawArgs.length; i++) {
45
+ const arg = rawArgs[i]!;
46
+ if (arg === "--") break;
47
+ if (!arg.startsWith("-") || arg === "-" || /^-\d/.test(arg)) continue;
48
+ if (arg.startsWith("--")) {
49
+ const eq = arg.indexOf("=");
50
+ const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
51
+ if (!long.has(name)) return { name: `--${name}`, suggestion: suggest(name) };
52
+ if (eq === -1 && valued.has(name)) i++;
53
+ continue;
54
+ }
55
+ for (const letter of arg.slice(1)) {
56
+ if (!short.has(letter)) return { name: `-${letter}`, suggestion: null };
57
+ }
58
+ if (valued.has(arg[arg.length - 1]!)) i++;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /** The coded line for a usage error the CLI refuses before doing anything (S9-F2). */
64
+ export function renderInvalidArg(message: string): string {
65
+ return renderError({ code: "E_INVALID_ARG", message });
66
+ }
67
+
68
+ export function renderUnknownOption(option: UnknownOption): string {
69
+ const tail = option.suggestion === null ? "" : ` (did you mean ${option.suggestion}?)`;
70
+ return renderInvalidArg(`unknown option ${option.name}${tail}`);
71
+ }
72
+
73
+ /** Exits 2 with one coded stderr line when `rawArgs` carries a flag the command does not declare. */
74
+ export function rejectUnknownOptions(rawArgs: string[], argsDef: ArgsDef): void {
75
+ const unknown = findUnknownOption(rawArgs, argsDef);
76
+ if (unknown === null) return;
77
+ log.error(renderUnknownOption(unknown));
78
+ process.exit(2);
79
+ }
package/src/cli/record.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { errorMessage } from "../error-utils";
3
3
  import { exitCodeFor, KeshaError } from "../engine/events";
4
+ import { renderInvalidArg } from "./options";
4
5
  import {
5
6
  isEngineInstalled,
6
7
  recordEngine,
@@ -197,7 +198,7 @@ export const recordCommand = defineCommand({
197
198
  if (args.debug) log.debugEnabled = true;
198
199
  const resolved = resolveRecordArgs(args as RecordArgs);
199
200
  if (!resolved.ok) {
200
- log.error(resolved.error);
201
+ log.error(renderInvalidArg(resolved.error));
201
202
  process.exit(2);
202
203
  }
203
204
  if (!isEngineInstalled()) {
package/src/cli/say.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { defineCommand } from "citty";
2
+ import { statSync } from "fs";
2
3
  import { errorMessage } from "../error-utils";
3
4
  import { exitCodeFor, KeshaError } from "../engine/events";
5
+ import { renderInvalidArg } from "./options";
4
6
  import { log } from "../log";
5
7
  import {
8
+ MAX_TEXT_CHARS,
6
9
  listVoiceIds,
7
10
  say,
8
11
  SUPPORTED_SAMPLE_RATES,
12
+ validateSayText,
9
13
  type SayFormat,
10
14
  type SayOptions,
11
15
  } from "../synth";
@@ -14,13 +18,25 @@ import { resolveSayVoice } from "../voice-routing";
14
18
  import { diagnosticCharBucket, diagnosticSizeBucket } from "../diagnostic-events";
15
19
  import { runCommandSession, type CommandOutcome, type CommandSession } from "./command-session";
16
20
 
21
+ /** Four bytes per allowed character: a pipe that streams past it is refused without waiting for EOF. */
22
+ const MAX_STDIN_BYTES = MAX_TEXT_CHARS * 4 + 4;
23
+
24
+ /** A positional the user gave is the text, empty or not; only an absent one falls through to stdin (#T1-2). */
17
25
  async function resolveText(inline: string | undefined): Promise<string> {
18
- if (inline !== undefined && inline.length > 0) return inline;
26
+ if (inline !== undefined) return inline;
19
27
  const chunks: Uint8Array[] = [];
28
+ let total = 0;
20
29
  for await (const chunk of Bun.stdin.stream()) {
21
30
  chunks.push(chunk);
31
+ total += chunk.byteLength;
32
+ if (total > MAX_STDIN_BYTES) break;
33
+ }
34
+ if (total > MAX_STDIN_BYTES) {
35
+ throw new KeshaError(
36
+ "E_TEXT_TOO_LONG",
37
+ `text exceeds ${MAX_TEXT_CHARS} chars (stdin passed ${MAX_STDIN_BYTES} bytes)`,
38
+ );
22
39
  }
23
- const total = chunks.reduce((n, c) => n + c.byteLength, 0);
24
40
  const merged = new Uint8Array(total);
25
41
  let offset = 0;
26
42
  for (const c of chunks) {
@@ -34,7 +50,7 @@ export function shouldRejectMissingSayText(
34
50
  inlineText: string | undefined,
35
51
  stdinIsTty: boolean | undefined,
36
52
  ): boolean {
37
- return (inlineText === undefined || inlineText.length === 0) && stdinIsTty === true;
53
+ return inlineText === undefined && stdinIsTty === true;
38
54
  }
39
55
 
40
56
  type Parsed<T> = { ok: true; value: T | undefined } | { ok: false; error: string };
@@ -58,12 +74,19 @@ function parseRateFlag(value: unknown): Parsed<number> {
58
74
  return rate;
59
75
  }
60
76
 
77
+ /** The Opus encoder's own range, mirrored here so a caller error never costs a synthesis (#T1-5). */
78
+ const MIN_OPUS_BITRATE = 6000;
79
+ const MAX_OPUS_BITRATE = 510000;
80
+
61
81
  function parseBitrateFlag(value: unknown): Parsed<number> {
62
82
  const bitrate = parseFiniteNumberFlag("--bitrate", value);
63
83
  if (!bitrate.ok || bitrate.value === undefined) return bitrate;
64
84
  if (!Number.isInteger(bitrate.value) || bitrate.value <= 0) {
65
85
  return { ok: false, error: "--bitrate must be a positive integer." };
66
86
  }
87
+ if (bitrate.value < MIN_OPUS_BITRATE || bitrate.value > MAX_OPUS_BITRATE) {
88
+ return { ok: false, error: `--bitrate must be between ${MIN_OPUS_BITRATE} and ${MAX_OPUS_BITRATE} bps.` };
89
+ }
67
90
  return bitrate;
68
91
  }
69
92
 
@@ -106,6 +129,8 @@ function checkOpusOnlyFlags(
106
129
  }
107
130
 
108
131
  export type SayFlagArgs = {
132
+ voice?: unknown;
133
+ lang?: unknown;
109
134
  format?: unknown;
110
135
  rate?: unknown;
111
136
  bitrate?: unknown;
@@ -116,6 +141,9 @@ export type SayFlagArgs = {
116
141
  export type ResolvedSayFlags =
117
142
  | {
118
143
  ok: true;
144
+ voice: string | undefined;
145
+ lang: string | undefined;
146
+ out: string | undefined;
119
147
  format: SayFormat | undefined;
120
148
  rate: number | undefined;
121
149
  bitrate: number | undefined;
@@ -123,8 +151,22 @@ export type ResolvedSayFlags =
123
151
  }
124
152
  | { ok: false; error: string };
125
153
 
154
+ const STRING_FLAGS = ["voice", "lang", "out", "format", "rate", "bitrate", "sample-rate"] as const;
155
+
156
+ /** citty returns a trailing valueless string flag as `true` and `--out=` as `""`; both used to be discarded silently (#T1-3). */
157
+ function missingFlagValue(args: SayFlagArgs): string | null {
158
+ for (const name of STRING_FLAGS) {
159
+ const value = (args as Record<string, unknown>)[name];
160
+ if (value === true || value === "") return `--${name} needs a value`;
161
+ }
162
+ return null;
163
+ }
164
+
126
165
  /** Validates the encoder flags before the engine is spawned: faster failure in scripts (the engine repeats them authoritatively). */
127
166
  export function resolveSayFlags(args: SayFlagArgs): ResolvedSayFlags {
167
+ const missing = missingFlagValue(args);
168
+ if (missing) return { ok: false, error: missing };
169
+
128
170
  const format = parseFormatFlag(args.format);
129
171
  if (!format.ok) return format;
130
172
  const rate = parseRateFlag(args.rate);
@@ -140,6 +182,9 @@ export function resolveSayFlags(args: SayFlagArgs): ResolvedSayFlags {
140
182
 
141
183
  return {
142
184
  ok: true,
185
+ voice: typeof args.voice === "string" ? args.voice : undefined,
186
+ lang: typeof args.lang === "string" ? args.lang : undefined,
187
+ out,
143
188
  format: format.value,
144
189
  rate: rate.value,
145
190
  bitrate: bitrate.value,
@@ -147,6 +192,24 @@ export function resolveSayFlags(args: SayFlagArgs): ResolvedSayFlags {
147
192
  };
148
193
  }
149
194
 
195
+ const STDIO_ALIASES = ["/dev/stdout", "/dev/stderr", "/dev/stdin"];
196
+
197
+ /** `/dev/fd/N` stats as whatever the descriptor points at, so the name decides where stat cannot. */
198
+ function isDeviceDestination(out: string): boolean {
199
+ if (STDIO_ALIASES.includes(out) || /^\/dev\/fd\/\d+$/.test(out)) return true;
200
+ try {
201
+ return statSync(out).isCharacterDevice();
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ /** The engine writes `--out` from its own process, whose stdout is the CLI's pipe, so a device destination swallows the audio and still reports success (#T1-15). */
208
+ export function deviceOutRefusal(out: string | undefined): string | null {
209
+ if (out === undefined || !isDeviceDestination(out)) return null;
210
+ return `--out ${out} is a character device, where the audio would be discarded; omit --out to write it to stdout`;
211
+ }
212
+
150
213
  type SayOpts = {
151
214
  text: string;
152
215
  voice: string | undefined;
@@ -177,6 +240,11 @@ function recordOutputArtifact(
177
240
  return { outputFormat: opts.format ?? "wav", outputSizeBytes: audio.byteLength };
178
241
  }
179
242
 
243
+ /** citty's parser reads `--no-expand-abbrev` as the negation of a boolean `expand-abbrev`, so the declared key is never set (same trap `--no-vad` works around in main.ts). */
244
+ export function noExpandAbbrevRequested(args: Record<string, unknown>, rawArgs: string[]): boolean {
245
+ return rawArgs.includes("--no-expand-abbrev") || args["no-expand-abbrev"] === true;
246
+ }
247
+
180
248
  async function synthesizeAndEmit(
181
249
  opts: SayOpts,
182
250
  verbose: boolean,
@@ -280,7 +348,7 @@ export const sayCommand = defineCommand({
280
348
  default: false,
281
349
  },
282
350
  },
283
- async run({ args }) {
351
+ async run({ args, rawArgs }) {
284
352
  if (args.debug) log.debugEnabled = true;
285
353
  if (args["list-voices"]) {
286
354
  let ids: string[];
@@ -296,32 +364,45 @@ export const sayCommand = defineCommand({
296
364
 
297
365
  const flags = resolveSayFlags(args);
298
366
  if (!flags.ok) {
299
- log.error(flags.error);
367
+ log.error(renderInvalidArg(flags.error));
368
+ process.exit(2);
369
+ }
370
+
371
+ const deviceOut = deviceOutRefusal(flags.out);
372
+ if (deviceOut) {
373
+ log.error(renderInvalidArg(deviceOut));
300
374
  process.exit(2);
301
375
  }
302
376
 
303
377
  const inlineText = typeof args.text === "string" ? args.text : undefined;
304
378
  const stdinIsTty = (process.stdin as { isTTY?: boolean }).isTTY;
305
379
  if (shouldRejectMissingSayText(inlineText, stdinIsTty)) {
306
- log.error("kesha say requires text or piped stdin. Usage: kesha say <text>");
380
+ log.error(renderInvalidArg("kesha say requires text or piped stdin. Usage: kesha say <text>"));
307
381
  process.exit(2);
308
382
  }
309
- const text = await resolveText(inlineText);
310
- const explicitVoice = typeof args.voice === "string" ? args.voice : undefined;
311
- const langHint = typeof args.lang === "string" ? args.lang : undefined;
383
+ let text: string;
384
+ try {
385
+ text = await resolveText(inlineText);
386
+ validateSayText(text);
387
+ } catch (err) {
388
+ log.error(errorMessage(err));
389
+ process.exit(exitCodeFor(err));
390
+ }
391
+ const explicitVoice = flags.voice;
392
+ const langHint = flags.lang;
312
393
  const voice = await resolveSayVoice(explicitVoice, langHint, text);
313
394
 
314
395
  const opts: SayOpts = {
315
396
  text,
316
397
  voice,
317
398
  lang: langHint,
318
- out: typeof args.out === "string" ? args.out : undefined,
399
+ out: flags.out,
319
400
  rate: flags.rate,
320
401
  ssml: Boolean(args.ssml),
321
402
  format: flags.format,
322
403
  bitrate: flags.bitrate,
323
404
  sampleRate: flags.sampleRate,
324
- noExpandAbbrev: Boolean(args["no-expand-abbrev"]),
405
+ noExpandAbbrev: noExpandAbbrevRequested(args, rawArgs),
325
406
  };
326
407
 
327
408
  const outcome = await runCommandSession(