@drakulavich/kesha-voice-kit 1.23.0 → 1.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,7 +125,8 @@ All of these install the Bun CLI wrapper; engine + models still download explici
125
125
  - [Architecture](docs/architecture.md) — runtime data flow, the models that ship, the CLI ↔ Rust engine boundary, model pinning, and where tests live.
126
126
  - [Use cases](docs/use-cases.md) — copy-paste recipes (transcribe a meeting, speak from OpenClaw, run offline, move the cache).
127
127
  - [Product positioning](docs/product-positioning.md) — supported workflows, non-goals, maturity labels, platform matrix.
128
- - **Diagnostics:** `kesha doctor`, `kesha support-bundle` (redacted `.tar.gz` for issues), and `kesha logs` produce local, content-free diagnostics — see [docs/diagnostic-logs.md](docs/diagnostic-logs.md). Every failure prints a stable `error [CODE]: …` line ([docs/errors.md](docs/errors.md)).
128
+ - **Diagnostics:** `kesha doctor`, `kesha support-bundle` (redacted `.tar.gz` for issues), and `kesha logs` produce local, content-free diagnostics — see [docs/diagnostic-logs.md](docs/diagnostic-logs.md). Every failure prints a stable `error [CODE]: …` line and a documented [process exit code](docs/errors.md#process-exit-codes).
129
+ - **Scripting & CI:** `--json` (or `--toon`) for machine-readable output, `--quiet`/`-q` to silence progress, and `--no-color` (or `NO_COLOR=1`) for plain logs. Colors switch off automatically when `CI=true`.
129
130
  - **Privacy / Local Stats:** Stats are **off by default** and fully local. Opt in with `kesha stats enable` to record content-free operational metrics in a local SQLite database — never networked, never storing audio, transcripts, text, or paths. Full commands & lifecycle: [docs/local-stats.md](docs/local-stats.md).
130
131
 
131
132
  ## Contributing
@@ -10,7 +10,7 @@ _kesha_completion() {
10
10
 
11
11
  if [[ "$COMP_CWORD" -eq 1 ]]; then
12
12
  if [[ "$cur" == -* ]]; then
13
- COMPREPLY=( $(compgen -W "--help -h --version -v --json --toon --timestamps --speakers --include-errors --verbose --format --lang --debug --vad --no-vad" -- "$cur") )
13
+ COMPREPLY=( $(compgen -W "--help -h --version -v --json --toon --timestamps --speakers --include-errors --verbose --format --lang --debug --vad --no-vad --quiet --no-color" -- "$cur") )
14
14
  else
15
15
  COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
16
16
  fi
@@ -30,7 +30,7 @@ _kesha_completion() {
30
30
  stats) opts="--help -h --format" ;;
31
31
  status) opts="--help -h --disk" ;;
32
32
  support-bundle) opts="--help -h --output --include-logs" ;;
33
- *) opts="--help -h --version -v --json --toon --timestamps --speakers --include-errors --verbose --format --lang --debug --vad --no-vad" ;;
33
+ *) opts="--help -h --version -v --json --toon --timestamps --speakers --include-errors --verbose --format --lang --debug --vad --no-vad --quiet --no-color" ;;
34
34
  esac
35
35
 
36
36
  if [[ "$cur" == -* ]]; then
@@ -19,14 +19,16 @@ complete -c kesha -n '__fish_use_subcommand' -s v -d 'Show version'
19
19
  complete -c kesha -n '__fish_use_subcommand' -l json -d 'Output results as JSON'
20
20
  complete -c kesha -n '__fish_use_subcommand' -l toon -d 'Output results as TOON (compact, LLM-friendly encoding of the same data as --json)'
21
21
  complete -c kesha -n '__fish_use_subcommand' -l timestamps -d 'Include timestamped transcript segments in JSON/TOON output'
22
- complete -c kesha -n '__fish_use_subcommand' -l speakers -d 'Include speaker labels in transcript segments. Requires --json / --toon / --format json. Implies --timestamps. Currently darwin-arm64 only (#199).'
22
+ complete -c kesha -n '__fish_use_subcommand' -l speakers -d 'Include speaker labels in segments. Needs --json/--toon and darwin-arm64; run `kesha install --diarize` first. Implies --timestamps.'
23
23
  complete -c kesha -n '__fish_use_subcommand' -l include-errors -d 'With --json, output { results, errors } so scripts can read per-file failures without parsing stderr'
24
24
  complete -c kesha -n '__fish_use_subcommand' -l verbose -d 'Show language detection details'
25
25
  complete -c kesha -n '__fish_use_subcommand' -l format -r -d 'Output format: transcript | json | toon (long-form alias for --json / --toon)'
26
- complete -c kesha -n '__fish_use_subcommand' -l lang -r -d 'Expected language code (ISO 639-1), warn if mismatch'
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
29
  complete -c kesha -n '__fish_use_subcommand' -l no-vad -d 'Force full-file ASR for short/medium files; long audio fails early'
30
+ complete -c kesha -n '__fish_use_subcommand' -l quiet -d 'Suppress progress output; print only results and errors'
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)'
30
32
  complete -c kesha -n '__fish_seen_subcommand_from completions' -l help -d 'Show help'
31
33
  complete -c kesha -n '__fish_seen_subcommand_from completions' -s h -d 'Show help'
32
34
  complete -c kesha -n '__fish_seen_subcommand_from doctor' -l help -d 'Show help'
@@ -67,7 +69,7 @@ complete -c kesha -n '__fish_seen_subcommand_from record' -l debug -d 'Trace eng
67
69
  complete -c kesha -n '__fish_seen_subcommand_from say' -l help -d 'Show help'
68
70
  complete -c kesha -n '__fish_seen_subcommand_from say' -s h -d 'Show help'
69
71
  complete -c kesha -n '__fish_seen_subcommand_from say' -l voice -r -d 'Voice id, e.g. en-am_michael'
70
- complete -c kesha -n '__fish_seen_subcommand_from say' -l lang -r -d 'BCP 47 language code (default en-us). With no --voice, routes to that language\'s default voice and skips text-language detection.'
72
+ 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.'
71
73
  complete -c kesha -n '__fish_seen_subcommand_from say' -l out -r -d 'Write audio to file instead of stdout'
72
74
  complete -c kesha -n '__fish_seen_subcommand_from say' -l rate -r -d 'Speaking rate 0.5–2.0'
73
75
  complete -c kesha -n '__fish_seen_subcommand_from say' -l list-voices -d 'List installed voices and exit'
@@ -27,14 +27,16 @@ _kesha() {
27
27
  '--json[Output results as JSON]' \
28
28
  '--toon[Output results as TOON (compact, LLM-friendly encoding of the same data as --json)]' \
29
29
  '--timestamps[Include timestamped transcript segments in JSON/TOON output]' \
30
- '--speakers[Include speaker labels in transcript segments. Requires --json / --toon / --format json. Implies --timestamps. Currently darwin-arm64 only (#199).]' \
30
+ '--speakers[Include speaker labels in segments. Needs --json/--toon and darwin-arm64; run `kesha install --diarize` first. Implies --timestamps.]' \
31
31
  '--include-errors[With --json, output { results, errors } so scripts can read per-file failures without parsing stderr]' \
32
32
  '--verbose[Show language detection details]' \
33
33
  '--format=[Output format: transcript | json | toon (long-form alias for --json / --toon)]:format:' \
34
- '--lang=[Expected language code (ISO 639-1), warn if mismatch]:lang:' \
34
+ '--lang=[Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch]:lang:' \
35
35
  '--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
36
36
  '--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
37
- '--no-vad[Force full-file ASR for short/medium files; long audio fails early]'
37
+ '--no-vad[Force full-file ASR for short/medium files; long audio fails early]' \
38
+ '--quiet[Suppress progress output; print only results and errors]' \
39
+ '--no-color[Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)]'
38
40
  else
39
41
  _describe -t commands 'kesha command' commands
40
42
  fi
@@ -99,7 +101,7 @@ _kesha() {
99
101
  _arguments '--help[Show help]' \
100
102
  '-h[Show help]' \
101
103
  '--voice=[Voice id, e.g. en-am_michael]:voice:' \
102
- '--lang=[BCP 47 language code (default en-us). With no --voice, routes to that language'\''s default voice and skips text-language detection.]:lang:' \
104
+ '--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:' \
103
105
  '--out=[Write audio to file instead of stdout]:out:' \
104
106
  '--rate=[Speaking rate 0.5–2.0]:rate:' \
105
107
  '--list-voices[List installed voices and exit]' \
@@ -135,14 +137,16 @@ _kesha() {
135
137
  '--json[Output results as JSON]' \
136
138
  '--toon[Output results as TOON (compact, LLM-friendly encoding of the same data as --json)]' \
137
139
  '--timestamps[Include timestamped transcript segments in JSON/TOON output]' \
138
- '--speakers[Include speaker labels in transcript segments. Requires --json / --toon / --format json. Implies --timestamps. Currently darwin-arm64 only (#199).]' \
140
+ '--speakers[Include speaker labels in segments. Needs --json/--toon and darwin-arm64; run `kesha install --diarize` first. Implies --timestamps.]' \
139
141
  '--include-errors[With --json, output { results, errors } so scripts can read per-file failures without parsing stderr]' \
140
142
  '--verbose[Show language detection details]' \
141
143
  '--format=[Output format: transcript | json | toon (long-form alias for --json / --toon)]:format:' \
142
- '--lang=[Expected language code (ISO 639-1), warn if mismatch]:lang:' \
144
+ '--lang=[Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch]:lang:' \
143
145
  '--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
144
146
  '--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
145
- '--no-vad[Force full-file ASR for short/medium files; long audio fails early]'
147
+ '--no-vad[Force full-file ASR for short/medium files; long audio fails early]' \
148
+ '--quiet[Suppress progress output; print only results and errors]' \
149
+ '--no-color[Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)]'
146
150
  ;;
147
151
  esac
148
152
  }
package/man/kesha.1 CHANGED
@@ -58,7 +58,7 @@ Output results as TOON (compact, LLM\-friendly encoding of the same data as \-\-
58
58
  Include timestamped transcript segments in JSON/TOON output
59
59
  .TP
60
60
  .B \-\-speakers
61
- Include speaker labels in transcript segments. Requires \-\-json / \-\-toon / \-\-format json. Implies \-\-timestamps. Currently darwin\-arm64 only (#199).
61
+ Include speaker labels in segments. Needs \-\-json/\-\-toon and darwin\-arm64; run `kesha install \-\-diarize` first. Implies \-\-timestamps.
62
62
  .TP
63
63
  .B \-\-include\-errors
64
64
  With \-\-json, output { results, errors } so scripts can read per\-file failures without parsing stderr
@@ -70,7 +70,7 @@ Show language detection details
70
70
  Output format: transcript | json | toon (long\-form alias for \-\-json / \-\-toon)
71
71
  .TP
72
72
  .B \-\-lang
73
- Expected language code (ISO 639\-1), warn if mismatch
73
+ Expected language code, e.g. en or en\-us (see docs/languages.md); warn if mismatch
74
74
  .TP
75
75
  .B \-\-debug
76
76
  Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)
@@ -80,6 +80,12 @@ Force Silero VAD preprocessing (kesha install \-\-vad first). Without this, VAD
80
80
  .TP
81
81
  .B \-\-no\-vad
82
82
  Force full\-file ASR for short/medium files; long audio fails early
83
+ .TP
84
+ .B \-\-quiet
85
+ Suppress progress output; print only results and errors
86
+ .TP
87
+ .B \-\-no\-color
88
+ Disable ANSI colors (also via NO_COLOR=1; auto\-off when CI=true)
83
89
  .SH SUBCOMMAND OPTIONS
84
90
  .SS doctor
85
91
  .TP
@@ -155,7 +161,7 @@ Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)
155
161
  Voice id, e.g. en\-am_michael
156
162
  .TP
157
163
  .B \-\-lang
158
- BCP 47 language code (default en\-us). With no \-\-voice, routes to that language's default voice and skips text\-language detection.
164
+ 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.
159
165
  .TP
160
166
  .B \-\-out
161
167
  Write audio to file instead of stdout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.23.0",
3
+ "version": "1.24.1",
4
4
  "description": "Give your tools a voice — speech to text and back, 25 languages, up to ~19× faster than Whisper. On your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,6 +42,7 @@
42
42
  },
43
43
  "scripts": {
44
44
  "test": "bun test",
45
+ "test:watch": "bun test --watch",
45
46
  "test:unit": "bun test tests/unit/",
46
47
  "test:cli-fast": "bun test tests/unit/ tests/integration/cli-contracts.test.ts tests/integration/e2e-cli.test.ts tests/integration/say.test.ts",
47
48
  "test:integration": "bun test tests/integration/",
@@ -90,6 +91,7 @@
90
91
  "yaml": "^2.9.0"
91
92
  },
92
93
  "dependencies": {
94
+ "@clack/prompts": "^1.5.0",
93
95
  "@modelcontextprotocol/sdk": "^1.29.0",
94
96
  "@toon-format/toon": "^2.1.0",
95
97
  "citty": "^0.2.2",
@@ -1,6 +1,6 @@
1
1
  import { runMain, type CommandDef } from "citty";
2
2
  import { existsSync } from "fs";
3
- import { log } from "../log";
3
+ import { log, setColorEnabled } from "../log";
4
4
  import { suggestCommand } from "../suggest-command";
5
5
  import { completionsCommand } from "./completions";
6
6
  import { doctorCommand } from "./doctor";
@@ -39,7 +39,94 @@ function isPathLike(arg: string): boolean {
39
39
  return arg.includes(".") || arg.includes("/") || existsSync(arg);
40
40
  }
41
41
 
42
+ // Falsey grammar shared by --no-color and CI. Mirrors KESHA_DEBUG so
43
+ // `CI=false`/`CI=0` and `--no-color=false` opt back in to colors.
44
+ const FALSEY_VALUES = new Set(["", "0", "false", "no", "off"]);
45
+ function isFalsey(v: string): boolean {
46
+ return FALSEY_VALUES.has(v.trim().toLowerCase());
47
+ }
48
+
49
+ // Whether the process was launched with NO_COLOR already forced on. Captured
50
+ // once at import (before any runCli) so re-enabling colors never clobbers an
51
+ // externally-set NO_COLOR — we only clear the var on re-enable when WE set it.
52
+ const USER_FORCED_NO_COLOR =
53
+ process.env.NO_COLOR !== undefined && !isFalsey(process.env.NO_COLOR);
54
+
55
+ /**
56
+ * Strip a boolean global flag out of rawArgs so citty never sees it. Matches
57
+ * any of `names` bare (sets the flag) and the `--name=<value>` form for the
58
+ * long names (`<falsey>` explicitly turns the flag back off).
59
+ */
60
+ function stripBooleanFlag(
61
+ rawArgs: string[],
62
+ names: string[],
63
+ ): { value: boolean; rawArgs: string[] } {
64
+ const valuedPrefixes = names.filter((n) => n.startsWith("--")).map((n) => `${n}=`);
65
+ let value = false;
66
+ const cleaned: string[] = [];
67
+ for (const arg of rawArgs) {
68
+ if (names.includes(arg)) {
69
+ value = true;
70
+ } else if (valuedPrefixes.some((prefix) => arg.startsWith(prefix))) {
71
+ value = !isFalsey(arg.slice(arg.indexOf("=") + 1));
72
+ } else {
73
+ cleaned.push(arg);
74
+ }
75
+ }
76
+ return { value, rawArgs: cleaned };
77
+ }
78
+
79
+ /**
80
+ * Decide whether ANSI colors should be disabled (#531) and return rawArgs with
81
+ * any `--no-color[=value]` token stripped so citty never sees it.
82
+ *
83
+ * Colors are disabled when `--no-color` (bare) or `--no-color=<truthy>` is
84
+ * passed, or when the environment looks like CI (`CI` set to a non-falsey value
85
+ * — GitHub Actions, GitLab, CircleCI, … export `CI=true`). `--no-color=false`
86
+ * and `CI=false`/`CI=0` explicitly opt back in.
87
+ */
88
+ export function resolveColorMode(
89
+ rawArgs: string[],
90
+ env: { CI?: string } = process.env as { CI?: string },
91
+ ): { disableColor: boolean; rawArgs: string[] } {
92
+ const { value, rawArgs: cleaned } = stripBooleanFlag(rawArgs, ["--no-color"]);
93
+ const ci = env.CI !== undefined && !isFalsey(env.CI);
94
+ return { disableColor: value || ci, rawArgs: cleaned };
95
+ }
96
+
97
+ /**
98
+ * Detect `--quiet`/`-q` (and `--quiet=<value>`) and return rawArgs with the
99
+ * token stripped (#526). Resolved before citty — like `--no-color` — so quiet
100
+ * is global: it works for every command, not just the transcribe path, and a
101
+ * subcommand that doesn't declare it never sees the flag.
102
+ */
103
+ export function resolveQuietMode(rawArgs: string[]): { quiet: boolean; rawArgs: string[] } {
104
+ const { value, rawArgs: cleaned } = stripBooleanFlag(rawArgs, ["--quiet", "-q"]);
105
+ return { quiet: value, rawArgs: cleaned };
106
+ }
107
+
42
108
  export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
109
+ // Global flags resolved before citty so they apply to every command (and help
110
+ // output) and never reach a subcommand's arg schema.
111
+ const color = resolveColorMode(rawArgs);
112
+ // Reset on EVERY invocation (not just the disable path): an earlier
113
+ // --no-color / CI call must not leave colors permanently off for later
114
+ // in-process calls (unit tests, `kesha mcp`). picocolors already honors
115
+ // NO_COLOR at startup; setting the env var also propagates to the engine.
116
+ setColorEnabled(!color.disableColor);
117
+ // Keep NO_COLOR symmetric so it doesn't leak to engine subprocesses spawned
118
+ // by a later in-process call: set it when WE disable, clear it on re-enable —
119
+ // but never clear a NO_COLOR the user exported themselves.
120
+ if (color.disableColor) {
121
+ process.env.NO_COLOR = "1";
122
+ } else if (!USER_FORCED_NO_COLOR) {
123
+ delete process.env.NO_COLOR;
124
+ }
125
+
126
+ const quiet = resolveQuietMode(color.rawArgs);
127
+ log.quietEnabled = quiet.quiet;
128
+
129
+ rawArgs = quiet.rawArgs;
43
130
  const [firstArg, ...restArgs] = rawArgs;
44
131
 
45
132
  if (firstArg && Object.hasOwn(SUBCOMMANDS, firstArg)) {
package/src/cli/init.ts CHANGED
@@ -1,9 +1,48 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { stdin as input, stdout as output } from "node:process";
4
+ import { multiselect, isCancel, cancel } from "@clack/prompts";
4
5
  import { renderInstallPlan } from "../install-plan";
5
6
  import { log } from "../log";
6
- import { performInstall, resolveBackendFlag, resolveNoCacheFlag } from "./install";
7
+ import { getEngineCapabilities } from "../engine";
8
+ import {
9
+ performInstall,
10
+ resolveBackendFlag,
11
+ resolveNoCacheFlag,
12
+ TTS_LANG_FALLBACK,
13
+ } from "./install";
14
+
15
+ const TTS_LANG_LABELS: Record<string, string> = {
16
+ en: "English (Kokoro)",
17
+ es: "Spanish (Kokoro)",
18
+ fr: "French (Kokoro)",
19
+ hi: "Hindi (Kokoro ANE)",
20
+ it: "Italian (Kokoro)",
21
+ ja: "Japanese (Kokoro ANE)",
22
+ pt: "Portuguese (Kokoro)",
23
+ zh: "Chinese (Kokoro ANE)",
24
+ ru: "Russian (Vosk-TTS)",
25
+ };
26
+
27
+ /** Signature of the interactive TTS-language picker (injectable for tests). */
28
+ export type TtsLangPrompt = (preselect: string[]) => Promise<string[]>;
29
+
30
+ async function promptTtsLangs(preselect: string[]): Promise<string[]> {
31
+ const caps = await getEngineCapabilities();
32
+ const supported = caps?.tts?.languages.map((l) => l.code) ?? TTS_LANG_FALLBACK;
33
+ const selected = await multiselect({
34
+ message:
35
+ "Select TTS languages to install (space to toggle, enter to confirm; none = skip TTS):",
36
+ options: supported.map((code) => ({ value: code, label: TTS_LANG_LABELS[code] ?? code })),
37
+ initialValues: preselect.filter((l) => supported.includes(l)),
38
+ required: false,
39
+ });
40
+ if (isCancel(selected)) {
41
+ cancel("Init cancelled.");
42
+ process.exit(0);
43
+ }
44
+ return selected as string[];
45
+ }
7
46
 
8
47
  export interface InitCommandArgs {
9
48
  coreml: boolean;
@@ -21,7 +60,7 @@ export interface InitCommandArgs {
21
60
  export interface InitSelection {
22
61
  noCache: boolean;
23
62
  backend?: string;
24
- tts: boolean;
63
+ ttsLangs: string[];
25
64
  vad: boolean;
26
65
  diarize: boolean;
27
66
  }
@@ -45,7 +84,7 @@ export function resolveInitSelection(
45
84
  return {
46
85
  noCache,
47
86
  backend,
48
- tts: args.tts,
87
+ ttsLangs: args.tts ? ["en"] : [],
49
88
  vad: args.vad,
50
89
  diarize: args.diarize,
51
90
  };
@@ -58,7 +97,7 @@ export function initInstallArgs(selection: InitSelection): string[] {
58
97
  selection.noCache ? "--no-cache" : "",
59
98
  selection.backend === "coreml" ? "--coreml" : "",
60
99
  selection.backend === "onnx" ? "--onnx" : "",
61
- selection.tts ? "--tts" : "",
100
+ ...(selection.ttsLangs.length > 0 ? ["--tts", ...selection.ttsLangs] : []),
62
101
  selection.vad ? "--vad" : "",
63
102
  selection.diarize ? "--diarize" : "",
64
103
  ].filter(Boolean);
@@ -70,12 +109,12 @@ export function initSuggestionCommands(
70
109
  ): string[][] {
71
110
  const variants: InitSelection[] = [
72
111
  selection,
73
- { ...selection, tts: false, vad: true, diarize: false },
74
- { ...selection, tts: true, vad: true, diarize: false },
112
+ { ...selection, ttsLangs: [], vad: true, diarize: false },
113
+ { ...selection, ttsLangs: ["en"], vad: true, diarize: false },
75
114
  ];
76
115
 
77
116
  if (canDiarize) {
78
- variants.push({ ...selection, tts: false, vad: true, diarize: true });
117
+ variants.push({ ...selection, ttsLangs: [], vad: true, diarize: true });
79
118
  }
80
119
 
81
120
  const seen = new Set<string>();
@@ -118,8 +157,9 @@ export async function promptInitSelection(
118
157
  backend = resolveBackendFlag(args.coreml, args.onnx),
119
158
  canDiarize = canInstallDiarizeOnPlatform(),
120
159
  noCache = resolveNoCacheFlag(args),
160
+ promptTts: TtsLangPrompt = promptTtsLangs,
121
161
  ): Promise<InitSelection> {
122
- const tts = await askYesNo(prompt, "Install text-to-speech models for `kesha say`?", args.tts);
162
+ const ttsLangs = await promptTts(args.tts ? ["en"] : []);
123
163
  const vad = await askYesNo(prompt, "Install VAD for long or silence-heavy audio?", args.vad);
124
164
  let diarize = false;
125
165
  if (canDiarize) {
@@ -131,7 +171,7 @@ export async function promptInitSelection(
131
171
  return {
132
172
  noCache,
133
173
  backend,
134
- tts,
174
+ ttsLangs,
135
175
  vad,
136
176
  diarize,
137
177
  };
@@ -153,7 +193,7 @@ async function printPlan(selection: InitSelection): Promise<void> {
153
193
  await renderInstallPlan({
154
194
  noCache: selection.noCache,
155
195
  backend: selection.backend,
156
- tts: selection.tts,
196
+ ttsLangs: selection.ttsLangs,
157
197
  vad: selection.vad,
158
198
  diarize: selection.diarize,
159
199
  }),
@@ -240,7 +280,7 @@ export const initCommand = defineCommand({
240
280
  await performInstall(
241
281
  installSelection.noCache,
242
282
  installSelection.backend,
243
- installSelection.tts,
283
+ installSelection.ttsLangs,
244
284
  installSelection.vad,
245
285
  installSelection.diarize,
246
286
  );
@@ -265,7 +305,21 @@ export const initCommand = defineCommand({
265
305
  log.info(`Skipped install. Run later: ${initInstallArgs(prompted).join(" ")}`);
266
306
  return;
267
307
  }
268
- await performInstall(prompted.noCache, prompted.backend, prompted.tts, prompted.vad, prompted.diarize);
308
+ await performInstall(
309
+ prompted.noCache,
310
+ prompted.backend,
311
+ prompted.ttsLangs,
312
+ prompted.vad,
313
+ prompted.diarize,
314
+ );
315
+ // Next-steps hint (#523): leave the user with something runnable rather
316
+ // than ending on the install log.
317
+ log.info("");
318
+ log.success("Kesha is ready. Try:");
319
+ log.info(" kesha path/to/audio.ogg Transcribe a file.");
320
+ if (prompted.ttsLangs.length > 0) {
321
+ log.info(' kesha say "hello" Speak text (TTS).');
322
+ }
269
323
  } finally {
270
324
  rl.close();
271
325
  }
@@ -1,6 +1,7 @@
1
1
  import { defineCommand } from "citty";
2
+ import { errorMessage } from "../error-utils";
2
3
  import { downloadEngine } from "../engine-install";
3
- import { getEngineBinPath } from "../engine";
4
+ import { getEngineBinPath, getEngineCapabilities } from "../engine";
4
5
  import { renderInstallPlan } from "../install-plan";
5
6
  import { maybeAskForStar } from "../star";
6
7
  import { log } from "../log";
@@ -8,6 +9,8 @@ import { packageVersion } from "../package-info";
8
9
  import { createDiagnosticLogSession, type DiagnosticLogSession } from "../diagnostic-log";
9
10
 
10
11
  export interface InstallCommandArgs {
12
+ /** Positional args after `install` — candidate TTS language codes. */
13
+ _?: string[];
11
14
  coreml: boolean;
12
15
  onnx: boolean;
13
16
  "no-cache": boolean;
@@ -19,6 +22,50 @@ export interface InstallCommandArgs {
19
22
  plan: boolean;
20
23
  }
21
24
 
25
+ /**
26
+ * TTS languages installable on EVERY build, used when the engine binary isn't
27
+ * installed yet (capabilities unavailable). hi/ja/zh are darwin-arm64-only and
28
+ * can't be assumed without capabilities, so they're excluded here.
29
+ */
30
+ export const TTS_LANG_FALLBACK = ["en", "es", "fr", "it", "pt", "ru"];
31
+
32
+ export interface TtsArgInput {
33
+ /** Whether --tts was passed. */
34
+ tts: boolean;
35
+ /** Positional args after the install command (candidate language codes). */
36
+ positionals: string[];
37
+ }
38
+
39
+ /**
40
+ * Resolve the requested TTS language list. Bare `--tts` defaults to English.
41
+ * Positionals without `--tts` are an error. When `supported` is provided,
42
+ * unsupported codes are a hard error (nothing downloads); when it's undefined
43
+ * (engine not yet installed, capabilities unavailable) the check is skipped and
44
+ * the engine validates authoritatively at download time.
45
+ */
46
+ export function resolveTtsLangs(input: TtsArgInput, supported: string[] | undefined): string[] {
47
+ if (!input.tts) {
48
+ if (input.positionals.length > 0) {
49
+ throw new Error(
50
+ `Language codes (${input.positionals.join(", ")}) require the --tts flag, ` +
51
+ `e.g. \`kesha install --tts ${input.positionals.join(" ")}\`.`,
52
+ );
53
+ }
54
+ return [];
55
+ }
56
+ const langs = input.positionals.length > 0 ? input.positionals : ["en"];
57
+ if (supported) {
58
+ const bad = langs.filter((l) => !supported.includes(l));
59
+ if (bad.length > 0) {
60
+ throw new Error(
61
+ `Unsupported TTS language(s): ${bad.join(", ")}. ` +
62
+ `Supported on this platform: ${supported.join(", ")}.`,
63
+ );
64
+ }
65
+ }
66
+ return langs;
67
+ }
68
+
22
69
  export function resolveNoCacheFlag(
23
70
  args: Pick<InstallCommandArgs, "no-cache" | "noCache" | "no_cache">,
24
71
  rawArgs: string[] = [],
@@ -65,20 +112,20 @@ function finishInstallDiagnostic(
65
112
  });
66
113
  diagnosticLog.finish(status);
67
114
  } catch (err) {
68
- log.debug(`install diagnostic log finish dropped: ${err instanceof Error ? err.message : String(err)}`);
115
+ log.debug(`install diagnostic log finish dropped: ${errorMessage(err)}`);
69
116
  }
70
117
  }
71
118
 
72
119
  export async function performInstall(
73
120
  noCache: boolean,
74
- backend?: string,
75
- tts = false,
121
+ backend: string | undefined,
122
+ ttsLangs: string[],
76
123
  vad = false,
77
124
  diarize = false,
78
125
  plan = false,
79
126
  ) {
80
127
  if (plan) {
81
- log.info(await renderInstallPlan({ noCache, backend, tts, vad, diarize }));
128
+ log.info(await renderInstallPlan({ noCache, backend, ttsLangs, vad, diarize }));
82
129
  return;
83
130
  }
84
131
 
@@ -91,7 +138,7 @@ export async function performInstall(
91
138
  command: "install",
92
139
  backend: backend ?? "auto",
93
140
  noCache,
94
- tts,
141
+ tts: ttsLangs.length > 0,
95
142
  vad,
96
143
  diarize,
97
144
  });
@@ -111,11 +158,11 @@ export async function performInstall(
111
158
  `the release engine uses "${platformBackend}".`,
112
159
  );
113
160
  }
114
- await downloadEngine(noCache, backend, { tts, vad, diarize });
161
+ await downloadEngine(noCache, backend, { ttsLangs, vad, diarize });
115
162
  await maybeAskForStar(getEngineBinPath(), packageVersion, log);
116
163
  finishInstallDiagnostic(diagnosticLog, startedAt, "success");
117
164
  } catch (err: unknown) {
118
- const message = err instanceof Error ? err.message : String(err);
165
+ const message = errorMessage(err);
119
166
  finishInstallDiagnostic(diagnosticLog, startedAt, "failed", errorKind);
120
167
  log.error(message);
121
168
  process.exit(1);
@@ -166,6 +213,23 @@ export const installCommand = defineCommand({
166
213
  },
167
214
  async run({ args, rawArgs }: { args: InstallCommandArgs; rawArgs: string[] }) {
168
215
  const backend = resolveBackendFlag(args.coreml, args.onnx);
169
- await performInstall(resolveNoCacheFlag(args, rawArgs), backend, args.tts, args.vad, args.diarize, args.plan);
216
+ const positionals = (args._ ?? []).map(String);
217
+ const caps = await getEngineCapabilities();
218
+ const supported = caps?.tts?.languages.map((l) => l.code);
219
+ let ttsLangs: string[];
220
+ try {
221
+ ttsLangs = resolveTtsLangs({ tts: args.tts === true, positionals }, supported);
222
+ } catch (err) {
223
+ log.error(errorMessage(err));
224
+ process.exit(1);
225
+ }
226
+ await performInstall(
227
+ resolveNoCacheFlag(args, rawArgs),
228
+ backend,
229
+ ttsLangs,
230
+ args.vad,
231
+ args.diarize,
232
+ args.plan,
233
+ );
170
234
  },
171
235
  });