@drakulavich/kesha-voice-kit 1.23.0 → 1.24.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.
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.0",
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,90 @@ 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
+ * Decide whether ANSI colors should be disabled (#531) and return rawArgs with
57
+ * any `--no-color[=value]` token stripped so citty never sees it.
58
+ *
59
+ * Colors are disabled when `--no-color` (bare) or `--no-color=<truthy>` is
60
+ * passed, or when the environment looks like CI (`CI` set to a non-falsey value
61
+ * — GitHub Actions, GitLab, CircleCI, … export `CI=true`). `--no-color=false`
62
+ * and `CI=false`/`CI=0` explicitly opt back in.
63
+ */
64
+ export function resolveColorMode(
65
+ rawArgs: string[],
66
+ env: { CI?: string } = process.env as { CI?: string },
67
+ ): { disableColor: boolean; rawArgs: string[] } {
68
+ let flag = false;
69
+ const cleaned: string[] = [];
70
+ for (const arg of rawArgs) {
71
+ if (arg === "--no-color") {
72
+ flag = true;
73
+ } else if (arg.startsWith("--no-color=")) {
74
+ flag = !isFalsey(arg.slice("--no-color=".length));
75
+ } else {
76
+ cleaned.push(arg);
77
+ }
78
+ }
79
+ const ci = env.CI !== undefined && !isFalsey(env.CI);
80
+ return { disableColor: flag || ci, rawArgs: cleaned };
81
+ }
82
+
83
+ /**
84
+ * Detect `--quiet`/`-q` (and `--quiet=<value>`) and return rawArgs with the
85
+ * token stripped (#526). Resolved before citty — like `--no-color` — so quiet
86
+ * is global: it works for every command, not just the transcribe path, and a
87
+ * subcommand that doesn't declare it never sees the flag.
88
+ */
89
+ export function resolveQuietMode(rawArgs: string[]): { quiet: boolean; rawArgs: string[] } {
90
+ let flag = false;
91
+ const cleaned: string[] = [];
92
+ for (const arg of rawArgs) {
93
+ if (arg === "--quiet" || arg === "-q") {
94
+ flag = true;
95
+ } else if (arg.startsWith("--quiet=")) {
96
+ flag = !isFalsey(arg.slice("--quiet=".length));
97
+ } else {
98
+ cleaned.push(arg);
99
+ }
100
+ }
101
+ return { quiet: flag, rawArgs: cleaned };
102
+ }
103
+
42
104
  export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
105
+ // Global flags resolved before citty so they apply to every command (and help
106
+ // output) and never reach a subcommand's arg schema.
107
+ const color = resolveColorMode(rawArgs);
108
+ // Reset on EVERY invocation (not just the disable path): an earlier
109
+ // --no-color / CI call must not leave colors permanently off for later
110
+ // in-process calls (unit tests, `kesha mcp`). picocolors already honors
111
+ // NO_COLOR at startup; setting the env var also propagates to the engine.
112
+ setColorEnabled(!color.disableColor);
113
+ // Keep NO_COLOR symmetric so it doesn't leak to engine subprocesses spawned
114
+ // by a later in-process call: set it when WE disable, clear it on re-enable —
115
+ // but never clear a NO_COLOR the user exported themselves.
116
+ if (color.disableColor) {
117
+ process.env.NO_COLOR = "1";
118
+ } else if (!USER_FORCED_NO_COLOR) {
119
+ delete process.env.NO_COLOR;
120
+ }
121
+
122
+ const quiet = resolveQuietMode(color.rawArgs);
123
+ log.quietEnabled = quiet.quiet;
124
+
125
+ rawArgs = quiet.rawArgs;
43
126
  const [firstArg, ...restArgs] = rawArgs;
44
127
 
45
128
  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,6 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { downloadEngine } from "../engine-install";
3
- import { getEngineBinPath } from "../engine";
3
+ import { getEngineBinPath, getEngineCapabilities } from "../engine";
4
4
  import { renderInstallPlan } from "../install-plan";
5
5
  import { maybeAskForStar } from "../star";
6
6
  import { log } from "../log";
@@ -8,6 +8,8 @@ import { packageVersion } from "../package-info";
8
8
  import { createDiagnosticLogSession, type DiagnosticLogSession } from "../diagnostic-log";
9
9
 
10
10
  export interface InstallCommandArgs {
11
+ /** Positional args after `install` — candidate TTS language codes. */
12
+ _?: string[];
11
13
  coreml: boolean;
12
14
  onnx: boolean;
13
15
  "no-cache": boolean;
@@ -19,6 +21,50 @@ export interface InstallCommandArgs {
19
21
  plan: boolean;
20
22
  }
21
23
 
24
+ /**
25
+ * TTS languages installable on EVERY build, used when the engine binary isn't
26
+ * installed yet (capabilities unavailable). hi/ja/zh are darwin-arm64-only and
27
+ * can't be assumed without capabilities, so they're excluded here.
28
+ */
29
+ export const TTS_LANG_FALLBACK = ["en", "es", "fr", "it", "pt", "ru"];
30
+
31
+ export interface TtsArgInput {
32
+ /** Whether --tts was passed. */
33
+ tts: boolean;
34
+ /** Positional args after the install command (candidate language codes). */
35
+ positionals: string[];
36
+ }
37
+
38
+ /**
39
+ * Resolve the requested TTS language list. Bare `--tts` defaults to English.
40
+ * Positionals without `--tts` are an error. When `supported` is provided,
41
+ * unsupported codes are a hard error (nothing downloads); when it's undefined
42
+ * (engine not yet installed, capabilities unavailable) the check is skipped and
43
+ * the engine validates authoritatively at download time.
44
+ */
45
+ export function resolveTtsLangs(input: TtsArgInput, supported: string[] | undefined): string[] {
46
+ if (!input.tts) {
47
+ if (input.positionals.length > 0) {
48
+ throw new Error(
49
+ `Language codes (${input.positionals.join(", ")}) require the --tts flag, ` +
50
+ `e.g. \`kesha install --tts ${input.positionals.join(" ")}\`.`,
51
+ );
52
+ }
53
+ return [];
54
+ }
55
+ const langs = input.positionals.length > 0 ? input.positionals : ["en"];
56
+ if (supported) {
57
+ const bad = langs.filter((l) => !supported.includes(l));
58
+ if (bad.length > 0) {
59
+ throw new Error(
60
+ `Unsupported TTS language(s): ${bad.join(", ")}. ` +
61
+ `Supported on this platform: ${supported.join(", ")}.`,
62
+ );
63
+ }
64
+ }
65
+ return langs;
66
+ }
67
+
22
68
  export function resolveNoCacheFlag(
23
69
  args: Pick<InstallCommandArgs, "no-cache" | "noCache" | "no_cache">,
24
70
  rawArgs: string[] = [],
@@ -71,14 +117,14 @@ function finishInstallDiagnostic(
71
117
 
72
118
  export async function performInstall(
73
119
  noCache: boolean,
74
- backend?: string,
75
- tts = false,
120
+ backend: string | undefined,
121
+ ttsLangs: string[],
76
122
  vad = false,
77
123
  diarize = false,
78
124
  plan = false,
79
125
  ) {
80
126
  if (plan) {
81
- log.info(await renderInstallPlan({ noCache, backend, tts, vad, diarize }));
127
+ log.info(await renderInstallPlan({ noCache, backend, ttsLangs, vad, diarize }));
82
128
  return;
83
129
  }
84
130
 
@@ -91,7 +137,7 @@ export async function performInstall(
91
137
  command: "install",
92
138
  backend: backend ?? "auto",
93
139
  noCache,
94
- tts,
140
+ tts: ttsLangs.length > 0,
95
141
  vad,
96
142
  diarize,
97
143
  });
@@ -111,7 +157,7 @@ export async function performInstall(
111
157
  `the release engine uses "${platformBackend}".`,
112
158
  );
113
159
  }
114
- await downloadEngine(noCache, backend, { tts, vad, diarize });
160
+ await downloadEngine(noCache, backend, { ttsLangs, vad, diarize });
115
161
  await maybeAskForStar(getEngineBinPath(), packageVersion, log);
116
162
  finishInstallDiagnostic(diagnosticLog, startedAt, "success");
117
163
  } catch (err: unknown) {
@@ -166,6 +212,23 @@ export const installCommand = defineCommand({
166
212
  },
167
213
  async run({ args, rawArgs }: { args: InstallCommandArgs; rawArgs: string[] }) {
168
214
  const backend = resolveBackendFlag(args.coreml, args.onnx);
169
- await performInstall(resolveNoCacheFlag(args, rawArgs), backend, args.tts, args.vad, args.diarize, args.plan);
215
+ const positionals = (args._ ?? []).map(String);
216
+ const caps = await getEngineCapabilities();
217
+ const supported = caps?.tts?.languages.map((l) => l.code);
218
+ let ttsLangs: string[];
219
+ try {
220
+ ttsLangs = resolveTtsLangs({ tts: args.tts === true, positionals }, supported);
221
+ } catch (err) {
222
+ log.error(err instanceof Error ? err.message : String(err));
223
+ process.exit(1);
224
+ }
225
+ await performInstall(
226
+ resolveNoCacheFlag(args, rawArgs),
227
+ backend,
228
+ ttsLangs,
229
+ args.vad,
230
+ args.diarize,
231
+ args.plan,
232
+ );
170
233
  },
171
234
  });
package/src/cli/main.ts CHANGED
@@ -37,6 +37,8 @@ interface MainCommandArgs {
37
37
  "include-errors": boolean;
38
38
  format?: string;
39
39
  lang?: string;
40
+ quiet: boolean;
41
+ "no-color": boolean;
40
42
  }
41
43
 
42
44
  export function detectLanguage(text: string): string {
@@ -116,7 +118,9 @@ export function shouldReportTranscribeProgress(input: {
116
118
  stderrIsTty: boolean;
117
119
  stdoutIsTty: boolean;
118
120
  debugEnabled: boolean;
121
+ quiet?: boolean;
119
122
  }): boolean {
123
+ if (input.quiet) return false;
120
124
  if (input.debugEnabled) return false;
121
125
  return input.stderrIsTty || !input.stdoutIsTty;
122
126
  }
@@ -147,6 +151,12 @@ export const mainCommand = defineCommand({
147
151
  description:
148
152
  "Kesha Voice Kit — open-source voice toolkit for Apple Silicon.\n" +
149
153
  "\n" +
154
+ "Examples:\n" +
155
+ " kesha audio.ogg Transcribe an audio file.\n" +
156
+ " kesha --json audio.ogg Transcribe with machine-readable output.\n" +
157
+ " kesha say \"hello\" Speak text (text-to-speech).\n" +
158
+ " kesha init Guided first-time setup.\n" +
159
+ "\n" +
150
160
  "Commands:\n" +
151
161
  " completions Print shell completion script.\n" +
152
162
  " doctor Collect support diagnostics.\n" +
@@ -178,7 +188,7 @@ export const mainCommand = defineCommand({
178
188
  },
179
189
  speakers: {
180
190
  type: "boolean",
181
- description: "Include speaker labels in transcript segments. Requires --json / --toon / --format json. Implies --timestamps. Currently darwin-arm64 only (#199).",
191
+ description: "Include speaker labels in segments. Needs --json/--toon and darwin-arm64; run `kesha install --diarize` first. Implies --timestamps.",
182
192
  default: false,
183
193
  },
184
194
  "include-errors": {
@@ -197,7 +207,7 @@ export const mainCommand = defineCommand({
197
207
  },
198
208
  lang: {
199
209
  type: "string",
200
- description: "Expected language code (ISO 639-1), warn if mismatch",
210
+ description: "Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch",
201
211
  },
202
212
  debug: {
203
213
  type: "boolean",
@@ -214,9 +224,25 @@ export const mainCommand = defineCommand({
214
224
  description: "Force full-file ASR for short/medium files; long audio fails early",
215
225
  default: false,
216
226
  },
227
+ // quiet and no-color are resolved before citty in dispatch.ts (so they
228
+ // apply to every command, not just transcribe); declared here only so they
229
+ // appear in `kesha --help`.
230
+ quiet: {
231
+ type: "boolean",
232
+ alias: "q",
233
+ description: "Suppress progress output; print only results and errors",
234
+ default: false,
235
+ },
236
+ "no-color": {
237
+ type: "boolean",
238
+ description: "Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)",
239
+ default: false,
240
+ },
217
241
  },
218
242
  async run({ args, rawArgs }: { args: MainCommandArgs; rawArgs: string[] }) {
219
243
  if (args.debug) log.debugEnabled = true;
244
+ // `log.quietEnabled` is set globally in dispatch.ts (where --quiet/-q is
245
+ // resolved and stripped before citty), so it already reflects --quiet here.
220
246
  const files = args._;
221
247
 
222
248
  // Validate `--format <value>` and normalize into the boolean flags
@@ -297,6 +323,7 @@ export const mainCommand = defineCommand({
297
323
  stderrIsTty: process.stderr.isTTY === true,
298
324
  stdoutIsTty: process.stdout.isTTY === true,
299
325
  debugEnabled: log.isDebugEnabled(),
326
+ quiet: log.quietEnabled,
300
327
  });
301
328
 
302
329
  for (const file of files) {
package/src/cli/say.ts CHANGED
@@ -111,7 +111,7 @@ export const sayCommand = defineCommand({
111
111
  lang: {
112
112
  type: "string",
113
113
  description:
114
- "BCP 47 language code (default en-us). With no --voice, routes to that language's default voice and skips text-language detection.",
114
+ "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.",
115
115
  },
116
116
  out: { type: "string", description: "Write audio to file instead of stdout" },
117
117
  rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
@@ -239,16 +239,16 @@ export const sayCommand = defineCommand({
239
239
  const startedAt = performance.now();
240
240
  if (opts.out) {
241
241
  const voiceLabel = opts.voice ?? "default voice";
242
- console.error(`Synthesizing ${voiceLabel} -> ${opts.out}...`);
242
+ log.status(`Synthesizing ${voiceLabel} -> ${opts.out}...`);
243
243
  }
244
244
  const audio = await stats.timeStage("tts", () => say(opts));
245
245
  const ttsTimeMs = Math.round(performance.now() - startedAt);
246
246
  if (opts.out) {
247
- console.error(`Saved ${opts.out} (${ttsTimeMs}ms)`);
247
+ log.status(`Saved ${opts.out} (${ttsTimeMs}ms)`);
248
248
  }
249
249
  if (args.verbose && !opts.out) {
250
250
  // stderr — stdout may carry raw audio bytes when --out is omitted.
251
- console.error(`TTS time: ${ttsTimeMs}ms`);
251
+ log.status(`TTS time: ${ttsTimeMs}ms`);
252
252
  }
253
253
  let outputFormat: string = opts.format ?? "wav";
254
254
  let outputSizeBytes: number | null | undefined = audio.byteLength;
@@ -325,9 +325,25 @@ async function warmDarwinKokoro(binPath: string): Promise<void> {
325
325
  }
326
326
  }
327
327
 
328
+ /** Build the `kesha-engine install` argv from options. Exported for testing. */
329
+ export function buildEngineInstallArgs(opts: {
330
+ noCache: boolean;
331
+ ttsLangs?: string[];
332
+ vad?: boolean;
333
+ diarize?: boolean;
334
+ }): string[] {
335
+ return [
336
+ "install",
337
+ ...(opts.noCache ? ["--no-cache"] : []),
338
+ ...(opts.ttsLangs && opts.ttsLangs.length > 0 ? ["--tts", ...opts.ttsLangs] : []),
339
+ ...(opts.vad ? ["--vad"] : []),
340
+ ...(opts.diarize ? ["--diarize"] : []),
341
+ ];
342
+ }
343
+
328
344
  export interface InstallOptions {
329
- /** Also install Kokoro + Vosk-TTS models. */
330
- tts?: boolean;
345
+ /** TTS languages to install (empty/undefined = no TTS). */
346
+ ttsLangs?: string[];
331
347
  /** Also install Silero VAD model for long-audio preprocessing. */
332
348
  vad?: boolean;
333
349
  /** Also install the Sortformer streaming-diarization model (~245MB,
@@ -498,13 +514,12 @@ export async function downloadEngine(
498
514
  }
499
515
 
500
516
  log.progress("Installing models...");
501
- const installArgs = [
502
- "install",
503
- ...(noCache ? ["--no-cache"] : []),
504
- ...(options.tts ? ["--tts"] : []),
505
- ...(options.vad ? ["--vad"] : []),
506
- ...(options.diarize ? ["--diarize"] : []),
507
- ];
517
+ const installArgs = buildEngineInstallArgs({
518
+ noCache,
519
+ ttsLangs: options.ttsLangs,
520
+ vad: options.vad,
521
+ diarize: options.diarize,
522
+ });
508
523
  const proc = Bun.spawnSync([binPath, ...installArgs], {
509
524
  stdout: "pipe",
510
525
  stderr: "pipe",
@@ -520,7 +535,11 @@ export async function downloadEngine(
520
535
  throw new Error(detail ? `Failed to install models: ${detail}` : "Failed to install models");
521
536
  }
522
537
 
523
- if (options.tts) {
538
+ // Warm the FluidAudio Kokoro CoreML cache only when a Kokoro language is
539
+ // requested. Russian (`ru`) routes through Vosk-TTS, not Kokoro, so a
540
+ // Russian-only install has nothing to warm.
541
+ const wantsKokoro = (options.ttsLangs ?? []).some((l) => l !== "ru");
542
+ if (wantsKokoro) {
524
543
  await warmDarwinKokoro(binPath);
525
544
  }
526
545
 
package/src/engine.ts CHANGED
@@ -340,10 +340,16 @@ export async function detectTextLanguageEngine(
340
340
  return parseLangResult(stdout);
341
341
  }
342
342
 
343
+ export interface TtsLanguageCapability {
344
+ code: string;
345
+ engines: string[];
346
+ }
347
+
343
348
  export interface EngineCapabilities {
344
349
  protocolVersion: number;
345
350
  backend: string;
346
351
  features: string[];
352
+ tts?: { languages: TtsLanguageCapability[] };
347
353
  }
348
354
 
349
355
  let cachedEngineCapabilities:
@@ -13,7 +13,8 @@ import {
13
13
  export interface InstallPlanOptions {
14
14
  noCache?: boolean;
15
15
  backend?: string;
16
- tts?: boolean;
16
+ /** Resolved TTS language codes, e.g. ["en", "ru"]. */
17
+ ttsLangs?: string[];
17
18
  vad?: boolean;
18
19
  diarize?: boolean;
19
20
  }
@@ -70,17 +71,29 @@ const G2P_CHARSIU_FILES: PlanFile[] = [
70
71
  { relPath: "models/g2p/byt5-tiny/decoder_with_past_model.onnx", sizeBytes: 5_427_260 },
71
72
  ];
72
73
 
73
- const KOKORO_FILES: PlanFile[] = [
74
- { relPath: "models/kokoro-82m/model.onnx", sizeBytes: 325_532_387 },
75
- { relPath: "models/kokoro-82m/voices/am_michael.bin", sizeBytes: 522_240 },
76
- // Multilingual voice packs for es/fr/it/pt (#212).
77
- // em_alex (es, male), ff_siwis (fr, female — only French voice in Kokoro v1.0),
78
- // im_nicola (it, male), pm_alex (pt, male).
79
- { relPath: "models/kokoro-82m/voices/em_alex.bin", sizeBytes: 522_240 },
80
- { relPath: "models/kokoro-82m/voices/ff_siwis.bin", sizeBytes: 522_240 },
81
- { relPath: "models/kokoro-82m/voices/im_nicola.bin", sizeBytes: 522_240 },
82
- { relPath: "models/kokoro-82m/voices/pm_alex.bin", sizeBytes: 522_240 },
83
- ];
74
+ // Kokoro ONNX graph (shared by all Kokoro languages on non-darwin builds).
75
+ const KOKORO_GRAPH_FILE: PlanFile = { relPath: "models/kokoro-82m/model.onnx", sizeBytes: 325_532_387 };
76
+
77
+ // Per-language default voice files.
78
+ // Multilingual voice packs for es/fr/it/pt (#212).
79
+ // em_alex (es, male), ff_siwis (fr, female — only French voice in Kokoro v1.0),
80
+ // im_nicola (it, male), pm_alex (pt, male).
81
+ const KOKORO_VOICE_FILES: Record<string, PlanFile> = {
82
+ en: { relPath: "models/kokoro-82m/voices/am_michael.bin", sizeBytes: 522_240 },
83
+ es: { relPath: "models/kokoro-82m/voices/em_alex.bin", sizeBytes: 522_240 },
84
+ fr: { relPath: "models/kokoro-82m/voices/ff_siwis.bin", sizeBytes: 522_240 },
85
+ it: { relPath: "models/kokoro-82m/voices/im_nicola.bin", sizeBytes: 522_240 },
86
+ pt: { relPath: "models/kokoro-82m/voices/pm_alex.bin", sizeBytes: 522_240 },
87
+ };
88
+
89
+ function kokoroPlanFiles(langs: string[]): PlanFile[] {
90
+ const files: PlanFile[] = [KOKORO_GRAPH_FILE];
91
+ for (const l of langs) {
92
+ const v = KOKORO_VOICE_FILES[l];
93
+ if (v) files.push(v);
94
+ }
95
+ return files;
96
+ }
84
97
 
85
98
  const VOSK_RU_FILES: PlanFile[] = [
86
99
  { relPath: "models/vosk-ru/model.onnx", sizeBytes: 179_314_533 },
@@ -240,53 +253,59 @@ export async function renderInstallPlan(options: InstallPlanOptions = {}): Promi
240
253
  ),
241
254
  );
242
255
 
243
- if (options.tts) {
256
+ const ttsLangs = options.ttsLangs ?? [];
257
+ // Darwin ANE serves every non-ru language through Kokoro (en/es/fr/it/pt + the
258
+ // ANE-only hi/ja/zh), so the warm-up gate mirrors engine-install.ts's
259
+ // `some((l) => l !== "ru")`. The ONNX component, by contrast, only covers the
260
+ // graph-backed en/es/fr/it/pt set (hi/ja/zh are darwin-only and rejected on ONNX).
261
+ const wantsAnyKokoro = ttsLangs.some((l) => l !== "ru");
262
+ const wantsOnnxKokoro = ttsLangs.some((l) => ["en", "es", "fr", "it", "pt"].includes(l));
263
+ const wantsG2p = ttsLangs.some((l) => ["es", "fr", "it", "pt"].includes(l));
264
+ const wantsRu = ttsLangs.includes("ru");
265
+
266
+ if (ttsLangs.length > 0) {
244
267
  if (isDarwinArm64()) {
245
- components.push(
246
- bundleComponent(
247
- cacheRoot,
248
- "TTS Vosk RU",
249
- "model cache",
250
- VOSK_RU_FILES,
251
- noCache,
252
- "Russian ru-vosk-* voices",
253
- ),
254
- );
255
- warmups.push({
256
- name: "TTS Kokoro EN",
257
- note: `${FLUID_KOKORO_CACHE_NOTE} (${fluidKokoroCachePath()})`,
258
- });
268
+ if (wantsAnyKokoro) {
269
+ warmups.push({
270
+ name: "TTS Kokoro (ANE)",
271
+ note: `${FLUID_KOKORO_CACHE_NOTE} (${fluidKokoroCachePath()})`,
272
+ });
273
+ }
274
+ if (wantsRu) {
275
+ components.push(
276
+ bundleComponent(cacheRoot, "TTS Vosk RU", "model cache", VOSK_RU_FILES, noCache, "Russian ru-vosk-* voices"),
277
+ );
278
+ }
259
279
  } else {
260
- components.push(
261
- bundleComponent(
262
- cacheRoot,
263
- "TTS Kokoro EN/ES/FR/IT/PT",
264
- "model cache",
265
- KOKORO_FILES,
266
- noCache,
267
- "English en-* voices + multilingual es-*/fr-*/it-*/pt-* voices",
268
- ),
269
- );
270
- components.push(
271
- bundleComponent(
272
- cacheRoot,
273
- "G2P CharsiuG2P byt5-tiny",
274
- "model cache",
275
- G2P_CHARSIU_FILES,
276
- noCache,
277
- "multilingual grapheme-to-phoneme for es/fr/it/pt Kokoro voices (CC-BY 4.0)",
278
- ),
279
- );
280
- components.push(
281
- bundleComponent(
282
- cacheRoot,
283
- "TTS Vosk RU",
284
- "model cache",
285
- VOSK_RU_FILES,
286
- noCache,
287
- "Russian ru-vosk-* voices",
288
- ),
289
- );
280
+ if (wantsOnnxKokoro) {
281
+ components.push(
282
+ bundleComponent(
283
+ cacheRoot,
284
+ "TTS Kokoro graph + voices",
285
+ "model cache",
286
+ kokoroPlanFiles(ttsLangs),
287
+ noCache,
288
+ `voices for ${ttsLangs.filter((l) => l !== "ru").join(", ")}`,
289
+ ),
290
+ );
291
+ }
292
+ if (wantsG2p) {
293
+ components.push(
294
+ bundleComponent(
295
+ cacheRoot,
296
+ "G2P CharsiuG2P byt5-tiny",
297
+ "model cache",
298
+ G2P_CHARSIU_FILES,
299
+ noCache,
300
+ "multilingual G2P for es/fr/it/pt (CC-BY 4.0)",
301
+ ),
302
+ );
303
+ }
304
+ if (wantsRu) {
305
+ components.push(
306
+ bundleComponent(cacheRoot, "TTS Vosk RU", "model cache", VOSK_RU_FILES, noCache, "Russian ru-vosk-* voices"),
307
+ );
308
+ }
290
309
  }
291
310
  }
292
311
 
@@ -370,7 +389,7 @@ export async function renderInstallPlan(options: InstallPlanOptions = {}): Promi
370
389
  " - install warms the ASR backend after downloads; CoreML warm-up is typically 20-30 s, ONNX warm-up is about 500 ms.",
371
390
  );
372
391
 
373
- if (options.tts && isDarwinArm64()) {
392
+ if (ttsLangs.length > 0 && wantsAnyKokoro && isDarwinArm64()) {
374
393
  lines.push(
375
394
  " - --tts also warms FluidAudio Kokoro CoreML; FluidAudio may download/compile its own Kokoro cache on first use.",
376
395
  );
@@ -382,7 +401,7 @@ export async function renderInstallPlan(options: InstallPlanOptions = {}): Promi
382
401
  options.noCache ? "--no-cache" : "",
383
402
  options.backend === "coreml" ? "--coreml" : "",
384
403
  options.backend === "onnx" ? "--onnx" : "",
385
- options.tts ? "--tts" : "",
404
+ ...(ttsLangs.length > 0 ? ["--tts", ...ttsLangs] : []),
386
405
  options.vad ? "--vad" : "",
387
406
  options.diarize ? "--diarize" : "",
388
407
  ].filter(Boolean);
package/src/lib.ts CHANGED
@@ -29,9 +29,13 @@ export type {
29
29
  TranscribeResult,
30
30
  } from "./types";
31
31
 
32
- /** Install Kokoro TTS models. Shorthand for `downloadModel({ tts: true })`. */
33
- export async function downloadTts(noCache = false): Promise<void> {
34
- await downloadEngine(noCache, undefined, { tts: true });
32
+ /**
33
+ * Install TTS models for the given languages (default: English only, matching
34
+ * `kesha install --tts`). Pass e.g. `["en", "ru"]` for more. Unsupported-on-platform
35
+ * codes are rejected by the engine.
36
+ */
37
+ export async function downloadTts(noCache = false, langs: string[] = ["en"]): Promise<void> {
38
+ await downloadEngine(noCache, undefined, { ttsLangs: langs });
35
39
  }
36
40
 
37
41
  /** @deprecated Use `downloadModel` instead. */
package/src/log.ts CHANGED
@@ -1,5 +1,21 @@
1
1
  import pc from "picocolors";
2
2
 
3
+ /**
4
+ * Colorizer indirection (#531). `picocolors` decides color support once at
5
+ * import time (honoring `NO_COLOR`, TTY, `FORCE_COLOR`). To support a runtime
6
+ * `--no-color` flag we keep a swappable reference: `setColorEnabled(false)`
7
+ * replaces it with a no-op colorizer. Methods below read `colors.*`, never the
8
+ * frozen default binding, so the toggle takes effect immediately.
9
+ */
10
+ // Narrower type than the default export (which also carries `createColors`),
11
+ // so the no-op colorizer from `createColors(false)` is assignable back.
12
+ let colors: ReturnType<typeof pc.createColors> = pc;
13
+
14
+ /** Force colors on/off at runtime (used by `--no-color`). */
15
+ export function setColorEnabled(enabled: boolean): void {
16
+ colors = enabled ? pc : pc.createColors(false);
17
+ }
18
+
3
19
  /**
4
20
  * Debug mode (#148): when `KESHA_DEBUG` is truthy OR the caller has flipped
5
21
  * `log.debugEnabled = true` (via `--debug`), `log.debug()` writes structured
@@ -28,11 +44,30 @@ const PROCESS_T0_MS = performance.now();
28
44
 
29
45
  export const log = {
30
46
  info: (msg: string) => console.log(msg),
31
- success: (msg: string) => console.log(pc.green(msg)),
32
- progress: (msg: string) => console.log(pc.cyan(msg)),
33
- warn: (msg: string) => console.error(pc.yellow(msg)),
34
- error: (msg: string) => console.error(pc.red(msg)),
47
+ success: (msg: string) => console.log(colors.green(msg)),
48
+ // `--quiet` (#526) silences status/progress chatter; warnings and errors
49
+ // always print, and results are written straight to stdout (not via log.*).
50
+ progress: (msg: string) => {
51
+ if (log.quietEnabled) return;
52
+ console.log(colors.cyan(msg));
53
+ },
54
+ // stderr via process.stderr.write (not console.error): Bun auto-colors
55
+ // console.error red in a TTY, and that decision is frozen at startup — it
56
+ // would survive `--no-color`. Writing the stream directly leaves picocolors
57
+ // (which `setColorEnabled` controls) as the only colorizer, so `--no-color`
58
+ // and `CI=true` produce genuinely plain output. (#531)
59
+ warn: (msg: string) => void process.stderr.write(colors.yellow(msg) + "\n"),
60
+ error: (msg: string) => void process.stderr.write(colors.red(msg) + "\n"),
61
+ // Status/progress line on STDERR (e.g. `say --out`, where stdout may carry
62
+ // raw audio bytes). Suppressed under `--quiet` and colored via the swappable
63
+ // colorizer so `--no-color` applies; process.stderr.write (not console.error)
64
+ // avoids Bun's startup-frozen TTY auto-red. (#526/#531)
65
+ status: (msg: string) => {
66
+ if (log.quietEnabled) return;
67
+ process.stderr.write(colors.cyan(msg) + "\n");
68
+ },
35
69
 
70
+ quietEnabled: false,
36
71
  debugEnabled: false,
37
72
  isDebugEnabled(): boolean {
38
73
  return this.debugEnabled || envDebug();
@@ -47,7 +82,7 @@ export const log = {
47
82
  // process", read the prefix difference; for cross-process spans,
48
83
  // the spawn→exit `dt=Nms` inside the message remains authoritative.
49
84
  const t = Math.round(performance.now() - PROCESS_T0_MS);
50
- console.error(pc.dim(`[debug +${t}ms] ${msg}`));
85
+ process.stderr.write(colors.dim(`[debug +${t}ms] ${msg}`) + "\n");
51
86
  }
52
87
  },
53
88
  };