@drakulavich/kesha-voice-kit 1.19.0-beta.2 → 1.20.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
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/assets/logo.png" alt="Kesha Voice Kit" width="200">
2
+ <img src="https://raw.githubusercontent.com/drakulavich/kesha-voice-kit/main/docs/assets/logo.png" alt="Kesha Voice Kit" width="200">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">Kesha Voice Kit</h1>
@@ -21,7 +21,7 @@
21
21
  See [Product positioning](docs/product-positioning.md) for supported workflows, non-goals, maturity labels, and the platform matrix.
22
22
 
23
23
  <p align="center">
24
- <img src="./demo.gif" alt="kesha demo — English + Russian transcription with automatic language detection" width="800">
24
+ <img src="https://raw.githubusercontent.com/drakulavich/kesha-voice-kit/main/demo.gif" alt="kesha demo — English + Russian transcription with automatic language detection" width="800">
25
25
  </p>
26
26
 
27
27
  ## Quick Start
@@ -104,6 +104,16 @@ kesha say "Hello, world" > hello.wav
104
104
  kesha say "Привет, мир" > privet.wav # auto-routes (Milena on darwin, ru-vosk-m02 elsewhere)
105
105
  ```
106
106
 
107
+ **Output formats** (`--format`, or inferred from `--out` extension):
108
+
109
+ ```bash
110
+ kesha say "Hello" --out hi.wav # WAV (default, uncompressed)
111
+ kesha say "Hello" --format ogg-opus --out hi.ogg # OGG/Opus — messenger voice notes (Telegram/WhatsApp/Signal)
112
+ kesha say "Hello" --format flac --out hi.flac # FLAC — lossless, royalty-free, plays in every browser incl. Safari/iOS
113
+ ```
114
+
115
+ FLAC is the format for web-embeddable samples: it plays natively in all modern browsers (including Safari/iOS, where OGG/Opus does not), stays royalty-free like Opus, and keeps the engine's native sample rate (no resample). `--bitrate` / `--sample-rate` apply only to `--format ogg-opus`.
116
+
107
117
  **Russian abbreviations** (`ru-vosk-*`): all-uppercase Cyrillic 2-5-char tokens auto-expand letter-by-letter when not pronounceable as a Russian syllable (ФСБ → "эф-эс-бэ", ВОЗ → "воз"). Disable with `--no-expand-abbrev`. See [docs/tts.md#russian-abbreviation-auto-expansion](docs/tts.md#russian-abbreviation-auto-expansion).
108
118
 
109
119
  **English acronyms** (`en-*`, Kokoro): three-table mechanism (letter-spell rule + STOP_LIST + IPA_LEXICON) auto-expands FBI → "ef bee eye" and gives EPAM/JSON/Anthropic the right IPA. Disable letter-spell with `--no-expand-abbrev`. See [docs/tts.md#english-acronym-auto-expansion](docs/tts.md#english-acronym-auto-expansion).
@@ -232,7 +242,7 @@ mandb ~/.local/share/man 2>/dev/null || true
232
242
 
233
243
  Compared against Whisper `large-v3-turbo` — all engines auto-detect language.
234
244
 
235
- ![Benchmark: openai-whisper vs faster-whisper vs Kesha Voice Kit](docs/assets/benchmark.svg)
245
+ ![Benchmark: openai-whisper vs faster-whisper vs Kesha Voice Kit](https://raw.githubusercontent.com/drakulavich/kesha-voice-kit/main/docs/assets/benchmark.svg)
236
246
 
237
247
  See [BENCHMARK.md](BENCHMARK.md) for the full per-file breakdown (Russian + English).
238
248
 
@@ -280,6 +290,10 @@ Cache boundary: `kesha install` and opt-in feature installs populate the local
280
290
  cache; ordinary transcription and speech commands fail fast if required assets
281
291
  are missing.
282
292
 
293
+ Contributors: see [`docs/architecture.md`](docs/architecture.md) for the
294
+ code-level map (repo layout, CLI↔engine boundary, backends, model pinning,
295
+ tests, and a "where to change X" table).
296
+
283
297
  ## What's Inside
284
298
 
285
299
  | Model | Task | Size | Source |
package/SKILL.md CHANGED
@@ -25,6 +25,7 @@ Local voice toolkit: transcribe voice messages to text, synthesize speech, detec
25
25
  - **Need to send a voice note (Telegram, WhatsApp, Signal, Discord)**: synthesize directly into messenger-native OGG/Opus with `kesha say --format ogg-opus --out reply.ogg "<text>"`. Default is mono 24 kHz @ 32 kbps - what Telegram `sendVoice` expects. No WAV redirect and no `ffmpeg` round-trip.
26
26
  - **Need local file playback/debug output**: WAV is still available with `kesha say --out reply.wav "<text>"`, but do not use WAV for Telegram voice replies. Auto-routes by detected language (Kokoro-82M for English, Vosk-TTS for Russian). On darwin-arm64, English Kokoro uses FluidAudio CoreML instead of ONNX. For other languages and ~180 more voices use `--voice macos-*` on macOS (zero model download).
27
27
  - **Need to detect what language a file is in** before choosing a pipeline: `kesha --json audio.ogg` returns both audio-based and text-based language detection with confidence scores.
28
+ - **Need to capture your own voice** for transcription or as a voice-note source: `kesha record --out clip.wav` records up to 120s (override with `--max-seconds`) of mono 16 kHz WAV from the default microphone. Pipe straight into `kesha --json clip.wav` to close the loop.
28
29
 
29
30
  ## OpenClaw plugin setup
30
31
 
@@ -150,9 +151,12 @@ Each `segment.speaker` is a number (cluster id, stable within one file). On Linu
150
151
  - `kesha audio.ogg` — plain transcript on stdout
151
152
  - `kesha --format transcript audio.ogg` — transcript + `[lang: ru, confidence: 0.99]` footer
152
153
  - `kesha --json --timestamps audio.ogg` — JSON with timestamped `segments`
154
+ - `kesha --toon audio.ogg` — TOON (compact, LLM-friendly JSON encoding); preferred when piping multi-file results to an LLM/agent
153
155
  - `kesha --verbose audio.ogg` — human-readable with language info
154
156
  - `kesha --lang en audio.ogg` — warn if detected language differs (useful sanity check)
155
157
 
158
+ **Long audio:** files ≥ 120 s auto-engage Silero VAD chunking; force on with `--vad` or off with `--no-vad`. Short files use full-file ASR by default.
159
+
156
160
  ## TTS: synthesize speech
157
161
 
158
162
  ```bash
@@ -164,6 +168,12 @@ kesha say --list-voices # Kokoro + Vosk-TTS + ~180 ma
164
168
 
165
169
  Output: WAV mono float32 by default. `--out <path>` writes to a file instead of stdout. For Telegram/OpenClaw replies, prefer `--format ogg-opus --out reply.ogg` or the OpenClaw-provided `{{OutputPath}}`.
166
170
 
171
+ **Output formats** (`--format`, or inferred from the `--out` extension): `wav` (default, uncompressed), `ogg-opus` (messenger voice notes), `flac` (lossless, royalty-free, plays in every browser incl. Safari/iOS — the format for web-embeddable samples). FLAC keeps the engine's native rate; `--bitrate` / `--sample-rate` apply only to `ogg-opus`.
172
+
173
+ ```bash
174
+ kesha say --format flac --out sample.flac "Hello" # web-embeddable, Safari-safe
175
+ ```
176
+
167
177
  **Voice notes (Telegram / WhatsApp / Signal / Discord):** add `--format ogg-opus` to emit OGG/Opus directly — the format messenger APIs render as a native voice message:
168
178
 
169
179
  ```bash
@@ -188,12 +198,25 @@ Format is also inferred from `--out` extension (`.ogg` / `.opus` / `.oga` → OG
188
198
 
189
199
  ## Install
190
200
 
201
+ **Humans use `kesha init` (guided). Agents and scripts use `kesha install` (deterministic).**
202
+
191
203
  ```bash
192
- bun add -g @drakulavich/kesha-voice-kit # global CLI install
193
- kesha install # downloads engine (~350 MB)
194
- kesha install --tts # adds Kokoro + Vosk-TTS RU (~990 MB more, for TTS)
204
+ bun add -g @drakulavich/kesha-voice-kit # global CLI install (always first)
205
+
206
+ # For humans: interactive setup that prompts for backend / TTS / VAD / diarize
207
+ kesha init
208
+
209
+ # For agents and CI: explicit, scriptable install commands
210
+ kesha install # engine only (~350 MB)
211
+ kesha install --tts # + Kokoro + Vosk-TTS RU (~990 MB more)
212
+ kesha install --tts --vad # + Silero VAD (long-audio chunking)
213
+ kesha install --tts --vad --diarize # + speaker diarization (darwin-arm64 only)
195
214
  ```
196
215
 
216
+ Kesha's runtime error/warning messages adapt to the same split: when `kesha` is invoked from a TTY, hints suggest `kesha init`; when stderr is piped (CI logs, OpenClaw, agent subprocess), hints suggest the equivalent `kesha install [...flags]`. Both run the same install code under the hood — pick the one your caller is.
217
+
218
+ For pre-release builds: `bun add -g @drakulavich/kesha-voice-kit@beta` (current `beta` channel; `@latest` stays on the last stable release).
219
+
197
220
  No system deps — English G2P is embedded (`misaki-rs`); Russian G2P is bundled inside Vosk-TTS. `macos-*` voices need no install either — they use voices already on the Mac.
198
221
 
199
222
  ## Supported languages
@@ -207,6 +230,13 @@ No system deps — English G2P is embedded (`misaki-rs`); Russian G2P is bundled
207
230
  - ASR: ~19× faster than OpenAI Whisper on Apple Silicon (CoreML via FluidAudio), ~2.5× on CPU (ONNX via `ort`).
208
231
  - TTS: sub-second latency for short utterances on Apple Silicon.
209
232
 
233
+ ## Troubleshooting
234
+
235
+ - `kesha doctor` — collect support diagnostics without changing local state. Add `--json` for machine-readable output, `--redact` to scrub secrets and home paths before sharing.
236
+ - `kesha logs` — manage privacy-safe local diagnostic logs. Default mode is `retain-on-failure` (events buffered in memory, flushed to disk only when a command fails). `kesha logs mode on` captures every run, `kesha logs path` prints the NDJSON file, `kesha logs disable` turns it off entirely.
237
+ - `kesha support-bundle --output bundle.tar.gz` — produce a redacted `.tar.gz` for filing an issue. Add `--include-logs` to bundle a bounded tail of diagnostic logs.
238
+ - `kesha stats` — manage local anonymous performance stats (per-command latency percentiles). Actions: `enable | disable | status | week | errors | export | reset | vacuum | retention`. Stays on the machine.
239
+
210
240
  ## Why local
211
241
 
212
242
  No API keys to manage. No per-minute billing. Voice data never leaves the machine — important for regulated industries, personal messaging, and anything that shouldn't be in a third-party log.
@@ -69,7 +69,7 @@ complete -c kesha -n '__fish_seen_subcommand_from say' -l out -r -d 'Write audio
69
69
  complete -c kesha -n '__fish_seen_subcommand_from say' -l rate -r -d 'Speaking rate 0.5–2.0'
70
70
  complete -c kesha -n '__fish_seen_subcommand_from say' -l list-voices -d 'List installed voices and exit'
71
71
  complete -c kesha -n '__fish_seen_subcommand_from say' -l ssml -d 'Parse input as SSML (supports <speak>, <break>; strips unknown tags)'
72
- complete -c kesha -n '__fish_seen_subcommand_from say' -l format -r -d 'Output format: wav (default) or ogg-opus (Telegram-ready voice note). Inferred from --out extension when omitted.'
72
+ complete -c kesha -n '__fish_seen_subcommand_from say' -l format -r -d 'Output format: wav (default), ogg-opus (Telegram-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from --out extension when omitted.'
73
73
  complete -c kesha -n '__fish_seen_subcommand_from say' -l bitrate -r -d 'Opus bitrate in bits/sec (e.g. 32000). Only with --format ogg-opus.'
74
74
  complete -c kesha -n '__fish_seen_subcommand_from say' -l sample-rate -r -d 'Opus encoder sample rate (8000/12000/16000/24000/48000). Only with --format ogg-opus.'
75
75
  complete -c kesha -n '__fish_seen_subcommand_from say' -l no-expand-abbrev -d 'Disable Russian acronym auto-expansion (ВОЗ → \'вэ о зэ\') for ru-vosk-* voices. <say-as interpret-as=\'characters\'> still works. Applies to Russian (ru-vosk-*) and English (en-*) voices.'
@@ -99,7 +99,7 @@ _kesha() {
99
99
  '--rate=[Speaking rate 0.5–2.0]:rate:' \
100
100
  '--list-voices[List installed voices and exit]' \
101
101
  '--ssml[Parse input as SSML (supports <speak>, <break>; strips unknown tags)]' \
102
- '--format=[Output format: wav (default) or ogg-opus (Telegram-ready voice note). Inferred from --out extension when omitted.]:format:' \
102
+ '--format=[Output format: wav (default), ogg-opus (Telegram-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from --out extension when omitted.]:format:' \
103
103
  '--bitrate=[Opus bitrate in bits/sec (e.g. 32000). Only with --format ogg-opus.]:bitrate:' \
104
104
  '--sample-rate=[Opus encoder sample rate (8000/12000/16000/24000/48000). Only with --format ogg-opus.]:sample rate:' \
105
105
  '--no-expand-abbrev[Disable Russian acronym auto-expansion (ВОЗ → '\''вэ о зэ'\'') for ru-vosk-* voices. <say-as interpret-as='\''characters'\''> still works. Applies to Russian (ru-vosk-*) and English (en-*) voices.]' \
package/man/kesha.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH KESHA 1 "May 2026" "kesha 1.19.0-beta.2" "User Commands"
1
+ .TH KESHA 1 "May 2026" "kesha 1.20.0" "User Commands"
2
2
  .SH NAME
3
3
  kesha \- open-source local voice toolkit
4
4
  .SH SYNOPSIS
@@ -167,7 +167,7 @@ List installed voices and exit
167
167
  Parse input as SSML (supports <speak>, <break>; strips unknown tags)
168
168
  .TP
169
169
  .B \-\-format
170
- Output format: wav (default) or ogg\-opus (Telegram\-ready voice note). Inferred from \-\-out extension when omitted.
170
+ Output format: wav (default), ogg\-opus (Telegram\-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from \-\-out extension when omitted.
171
171
  .TP
172
172
  .B \-\-bitrate
173
173
  Opus bitrate in bits/sec (e.g. 32000). Only with \-\-format ogg\-opus.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.19.0-beta.2",
3
+ "version": "1.20.0",
4
4
  "description": "Give your local tools a voice: fast STT in 25 languages (~19x faster than Whisper on Apple Silicon, ONNX CPU fallback), TTS via Kokoro/Vosk/macOS voices, VAD + 107-language detection. Rust engine, Bun CLI, OpenClaw skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  ]
33
33
  },
34
34
  "keshaEngine": {
35
- "version": "1.19.0-beta.2"
35
+ "version": "1.20.0"
36
36
  },
37
37
  "scripts": {
38
38
  "test": "bun test",
@@ -79,7 +79,7 @@
79
79
  "devDependencies": {
80
80
  "@types/bun": "latest",
81
81
  "@typescript/native-preview": "^7.0.0-dev.20260423.1",
82
- "fast-xml-parser": "^5.5.10",
82
+ "fast-xml-parser": "^5.8.0",
83
83
  "typescript": "^6.0.2",
84
84
  "yaml": "^2.9.0"
85
85
  },
@@ -1,4 +1,4 @@
1
- import { runMain } from "citty";
1
+ import { runMain, type CommandDef } from "citty";
2
2
  import { existsSync } from "fs";
3
3
  import { log } from "../log";
4
4
  import { suggestCommand } from "../suggest-command";
@@ -15,19 +15,23 @@ import { statusCommand } from "./status";
15
15
  import { supportBundleCommand } from "./support-bundle";
16
16
  import { mainCommand } from "./main";
17
17
 
18
- const SUBCOMMANDS = [
19
- "doctor",
20
- "init",
21
- "install",
22
- "logs",
23
- "status",
24
- "record",
25
- "say",
26
- "stats",
27
- "support-bundle",
28
- "completions",
29
- "manpage",
30
- ];
18
+ // Single source of truth: keyed lookup also feeds the `did you mean` suggester.
19
+ // `CommandDef<any>` is intentional — citty's generic is invariant in the args
20
+ // shape, and each subcommand has its own arg schema; the value here is only
21
+ // passed back to `runMain`, which re-reads the schema from the def itself.
22
+ const SUBCOMMANDS: Record<string, CommandDef<any>> = {
23
+ doctor: doctorCommand,
24
+ init: initCommand,
25
+ install: installCommand,
26
+ logs: logsCommand,
27
+ status: statusCommand,
28
+ record: recordCommand,
29
+ say: sayCommand,
30
+ stats: statsCommand,
31
+ "support-bundle": supportBundleCommand,
32
+ completions: completionsCommand,
33
+ manpage: manpageCommand,
34
+ };
31
35
 
32
36
  function isPathLike(arg: string): boolean {
33
37
  return arg.includes(".") || arg.includes("/") || existsSync(arg);
@@ -36,58 +40,8 @@ function isPathLike(arg: string): boolean {
36
40
  export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
37
41
  const [firstArg, ...restArgs] = rawArgs;
38
42
 
39
- if (firstArg === "doctor") {
40
- await runMain(doctorCommand, { rawArgs: restArgs });
41
- return;
42
- }
43
-
44
- if (firstArg === "init") {
45
- await runMain(initCommand, { rawArgs: restArgs });
46
- return;
47
- }
48
-
49
- if (firstArg === "completions") {
50
- await runMain(completionsCommand, { rawArgs: restArgs });
51
- return;
52
- }
53
-
54
- if (firstArg === "install") {
55
- await runMain(installCommand, { rawArgs: restArgs });
56
- return;
57
- }
58
-
59
- if (firstArg === "logs") {
60
- await runMain(logsCommand, { rawArgs: restArgs });
61
- return;
62
- }
63
-
64
- if (firstArg === "manpage") {
65
- await runMain(manpageCommand, { rawArgs: restArgs });
66
- return;
67
- }
68
-
69
- if (firstArg === "status") {
70
- await runMain(statusCommand, { rawArgs: restArgs });
71
- return;
72
- }
73
-
74
- if (firstArg === "record") {
75
- await runMain(recordCommand, { rawArgs: restArgs });
76
- return;
77
- }
78
-
79
- if (firstArg === "say") {
80
- await runMain(sayCommand, { rawArgs: restArgs });
81
- return;
82
- }
83
-
84
- if (firstArg === "stats") {
85
- await runMain(statsCommand, { rawArgs: restArgs });
86
- return;
87
- }
88
-
89
- if (firstArg === "support-bundle") {
90
- await runMain(supportBundleCommand, { rawArgs: restArgs });
43
+ if (firstArg && Object.hasOwn(SUBCOMMANDS, firstArg)) {
44
+ await runMain(SUBCOMMANDS[firstArg], { rawArgs: restArgs });
91
45
  return;
92
46
  }
93
47
 
@@ -95,7 +49,7 @@ export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
95
49
  // Extensionless existing files remain valid transcription inputs; missing
96
50
  // bare tokens are more likely command typos and should not start the engine.
97
51
  if (firstArg && !firstArg.startsWith("-") && !isPathLike(firstArg)) {
98
- const suggestion = suggestCommand(firstArg, SUBCOMMANDS);
52
+ const suggestion = suggestCommand(firstArg, Object.keys(SUBCOMMANDS));
99
53
  log.error(`unknown command '${firstArg}'`);
100
54
  if (suggestion && suggestion !== firstArg) {
101
55
  log.warn(`(Did you mean ${suggestion}?)`);
package/src/cli/say.ts CHANGED
@@ -100,7 +100,7 @@ export const sayCommand = defineCommand({
100
100
  format: {
101
101
  type: "string",
102
102
  description:
103
- "Output format: wav (default) or ogg-opus (Telegram-ready voice note). Inferred from --out extension when omitted.",
103
+ "Output format: wav (default), ogg-opus (Telegram-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from --out extension when omitted.",
104
104
  },
105
105
  bitrate: {
106
106
  type: "string",
@@ -145,12 +145,12 @@ export const sayCommand = defineCommand({
145
145
  const fmtArg = typeof args.format === "string" ? args.format.toLowerCase() : undefined;
146
146
  let format: SayFormat | undefined;
147
147
  if (fmtArg) {
148
- if (fmtArg === "wav" || fmtArg === "ogg-opus") {
148
+ if (fmtArg === "wav" || fmtArg === "ogg-opus" || fmtArg === "flac") {
149
149
  format = fmtArg;
150
150
  } else if (fmtArg === "opus" || fmtArg === "ogg") {
151
151
  format = "ogg-opus";
152
152
  } else {
153
- log.error(`unknown --format '${args.format}'. supported: wav, ogg-opus`);
153
+ log.error(`unknown --format '${args.format}'. supported: wav, ogg-opus, flac`);
154
154
  process.exit(2);
155
155
  }
156
156
  }
@@ -166,7 +166,10 @@ export const sayCommand = defineCommand({
166
166
  ? args.out.split(".").pop()?.toLowerCase()
167
167
  : undefined;
168
168
  const impliesOpus = outExt && ["ogg", "opus", "oga"].includes(outExt);
169
- if (format === "wav" || (format === undefined && !impliesOpus)) {
169
+ // --bitrate / --sample-rate are Opus-only. Reject for wav and flac
170
+ // (both lossless / no encoder knobs) and for any non-Opus extension.
171
+ const resolvesToOpus = format === "ogg-opus" || (format === undefined && impliesOpus);
172
+ if (!resolvesToOpus) {
170
173
  log.error("--bitrate and --sample-rate are only valid with --format ogg-opus");
171
174
  process.exit(2);
172
175
  }
@@ -100,9 +100,8 @@ function readConfig(): DiagnosticLogConfig {
100
100
  if (!existsSync(statePath)) return defaultConfig();
101
101
  try {
102
102
  const parsed = JSON.parse(readFileSync(statePath, "utf8")) as Partial<DiagnosticLogConfig>;
103
- const parsedMode = parseMode(parsed.mode);
104
103
  return {
105
- mode: parsedMode ?? defaultConfig().mode,
104
+ mode: parseDiagnosticLogMode(parsed.mode) ?? defaultConfig().mode,
106
105
  maxBytes: positiveInt(parsed.maxBytes, DEFAULT_MAX_BYTES),
107
106
  retain: positiveInt(parsed.retain, DEFAULT_RETAIN),
108
107
  };
@@ -111,11 +110,7 @@ function readConfig(): DiagnosticLogConfig {
111
110
  }
112
111
  }
113
112
 
114
- export function parseDiagnosticLogMode(value: string): DiagnosticLogMode | null {
115
- return parseMode(value);
116
- }
117
-
118
- function parseMode(value: unknown): DiagnosticLogMode | null {
113
+ export function parseDiagnosticLogMode(value: unknown): DiagnosticLogMode | null {
119
114
  return value === "off" || value === "on" || value === "retain-on-failure" ? value : null;
120
115
  }
121
116
 
@@ -219,9 +214,11 @@ export function resetDiagnosticLogs(): { deleted: number; bytes: number; dir: st
219
214
 
220
215
  export function createDiagnosticLogSession(): DiagnosticLogSession {
221
216
  const status = getDiagnosticLogStatus();
217
+ if (status.mode === "off") {
218
+ return { event: () => false, finish: () => false };
219
+ }
222
220
  const buffered: Uint8Array[] = [];
223
221
  let finished = false;
224
- if (status.mode === "off") return noopSession();
225
222
 
226
223
  return {
227
224
  event(event: string, fields: DiagnosticLogFields = {}): boolean {
@@ -261,13 +258,6 @@ export function createDiagnosticLogSession(): DiagnosticLogSession {
261
258
  };
262
259
  }
263
260
 
264
- function noopSession(): DiagnosticLogSession {
265
- return {
266
- event: () => false,
267
- finish: () => false,
268
- };
269
- }
270
-
271
261
  function appendDiagnosticLogLine(line: Uint8Array, status: DiagnosticLogStatus): void {
272
262
  mkdirSync(status.dir, { recursive: true });
273
263
  rotateIfNeeded(status.activePath, line.byteLength, status.maxBytes, status.retain);
package/src/engine.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, statSync } from "fs";
2
2
  import { join } from "path";
3
+ import { installHint } from "./install-hint";
3
4
  import { log } from "./log";
4
5
  import { defaultEngineBinPath, keshaCacheDir } from "./paths";
5
6
  import { engineAbortError, registerProcessTree } from "./process-tree";
@@ -219,17 +220,21 @@ export async function preflightTranscribeEngineWithSegments(
219
220
  if (hasDiarizeModelLayout(modelPath)) return;
220
221
  throw new Error(
221
222
  `speaker diarization requires a model path\n\nCaused by:\n diarization model not found at ${modelPath}. ` +
222
- "Run `kesha install --diarize` (or set KESHA_DIARIZE_MODEL_PATH).",
223
+ `Run \`${installHint("--diarize")}\` (or set KESHA_DIARIZE_MODEL_PATH).`,
223
224
  );
224
225
  }
225
226
 
227
+ function vadArg(vad: VadMode | undefined): string[] {
228
+ if (vad === "on") return ["--vad"];
229
+ if (vad === "off") return ["--no-vad"];
230
+ return [];
231
+ }
232
+
226
233
  export async function transcribeEngine(
227
234
  audioPath: string,
228
235
  opts: TranscribeEngineOptions = {},
229
236
  ): Promise<string> {
230
- const args = ["transcribe", audioPath];
231
- if (opts.vad === "on") args.push("--vad");
232
- else if (opts.vad === "off") args.push("--no-vad");
237
+ const args = ["transcribe", audioPath, ...vadArg(opts.vad)];
233
238
  const { stdout, stderr, exitCode } = await runEngine(args, { signal: opts.signal });
234
239
  if (exitCode !== 0) {
235
240
  throw new Error(stderr || `kesha-engine exited with code ${exitCode}`);
@@ -266,12 +271,8 @@ export async function transcribeEngineWithSegments(
266
271
  ): Promise<TranscriptionOutput> {
267
272
  await preflightTranscribeEngineWithSegments(opts);
268
273
 
269
- const args = ["transcribe", audioPath, "--json"];
270
- if (opts.vad === "on") args.push("--vad");
271
- else if (opts.vad === "off") args.push("--no-vad");
272
- if (opts.speakers) {
273
- args.push("--speakers");
274
- }
274
+ const args = ["transcribe", audioPath, "--json", ...vadArg(opts.vad)];
275
+ if (opts.speakers) args.push("--speakers");
275
276
  const { stdout, stderr, exitCode } = await runEngine(args, { signal: opts.signal });
276
277
  if (exitCode !== 0) {
277
278
  throw new Error(stderr || `kesha-engine exited with code ${exitCode}`);
@@ -0,0 +1,12 @@
1
+ // Pick the right setup-command hint for error/warning messages. Humans on an
2
+ // interactive TTY get `kesha init` (guided + idempotent), agents and scripts
3
+ // piping stderr get `kesha install [...flags]` (deterministic + scriptable).
4
+ //
5
+ // Flags forward to BOTH paths. `kesha init` accepts the same module-selection
6
+ // flags as `kesha install` (`--tts`, `--vad`, `--diarize`, `--coreml`, `--onnx`,
7
+ // `--no-cache`) — they preselect modules in the interactive wizard rather than
8
+ // being install-only flags. Source of truth: `src/cli/init.ts::initCommand.args`.
9
+ export function installHint(...flags: string[]): string {
10
+ const verb = process.stderr.isTTY ? "kesha init" : "kesha install";
11
+ return [verb, ...flags].join(" ");
12
+ }
package/src/status.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readdirSync, statSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { isEngineInstalled, getEngineBinPath, getEngineCapabilities } from "./engine";
4
+ import { installHint } from "./install-hint";
4
5
  import { log } from "./log";
5
6
  import { keshaCacheDir } from "./paths";
6
7
  import { fluidKokoroCacheInfo } from "./fluid-kokoro-cache";
@@ -82,7 +83,7 @@ export async function showStatus(options: ShowStatusOptions = {}): Promise<void>
82
83
  }
83
84
 
84
85
  if (!installed) {
85
- log.warn('Run "kesha install" to download the engine and models.');
86
+ log.warn(`Run \`${installHint()}\` to download the engine and models.`);
86
87
  return;
87
88
  }
88
89
  }
package/src/synth.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  spawnStdioWithDebugFd,
6
6
  type EngineCapabilities,
7
7
  } from "./engine";
8
+ import { installHint } from "./install-hint";
8
9
  import { log } from "./log";
9
10
  import { registerProcessTree } from "./process-tree";
10
11
 
@@ -13,8 +14,10 @@ import { registerProcessTree } from "./process-tree";
13
14
  * - `wav` (default): RIFF WAV at the engine's native sample rate.
14
15
  * - `ogg-opus`: OGG-encapsulated Opus, mono. The format Telegram, WhatsApp,
15
16
  * Signal, and Discord render as native voice messages. See #223.
17
+ * - `flac`: lossless, royalty-free, plays in every modern browser including
18
+ * Safari/iOS. Keeps the engine's native rate; no bitrate knob.
16
19
  */
17
- export type SayFormat = "wav" | "ogg-opus";
20
+ export type SayFormat = "wav" | "ogg-opus" | "flac";
18
21
  export const MAX_TEXT_CHARS = 5000;
19
22
 
20
23
  export interface SayOptions {
@@ -116,7 +119,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
116
119
 
117
120
  if (!isEngineInstalled()) {
118
121
  throw new SayError(
119
- "kesha-engine not installed. run: kesha install",
122
+ `kesha-engine not installed. run: ${installHint("--tts")}`,
120
123
  1,
121
124
  "",
122
125
  );
package/src/transcribe.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type TranscriptionOutput,
7
7
  type VadMode,
8
8
  } from "./engine";
9
+ import { installHint } from "./install-hint";
9
10
 
10
11
  export type { VadMode };
11
12
  export type { TranscriptionOutput };
@@ -31,13 +32,10 @@ export async function transcribe(audioPath: string, opts: TranscribeOptions = {}
31
32
  export async function preflightTranscribeWithSegments(opts: TranscribeOptions = {}): Promise<void> {
32
33
  if (!isEngineInstalled()) {
33
34
  throw new Error(
34
- "Error: No transcription backend is installed\n\n" +
35
- "╔══════════════════════════════════════════════════════════╗\n" +
36
- "║ Please run the following commands to get started: ║\n" +
37
- "║ ║\n" +
38
- "║ bun add -g @drakulavich/kesha-voice-kit ║\n" +
39
- "║ kesha install ║\n" +
40
- "╚══════════════════════════════════════════════════════════╝",
35
+ "Error: No transcription backend is installed.\n\n" +
36
+ "Run the following to get started:\n\n" +
37
+ " bun add -g @drakulavich/kesha-voice-kit\n" +
38
+ ` ${installHint()}`,
41
39
  );
42
40
  }
43
41