@drakulavich/kesha-voice-kit 2.3.0-beta.1 → 2.3.0-beta.3
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 +3 -2
- package/completions/kesha.fish +1 -1
- package/completions/kesha.zsh +1 -1
- package/man/kesha.1 +1 -1
- package/package.json +12 -12
- package/src/cache-layout.ts +29 -6
- package/src/cli/context.ts +3 -2
- package/src/cli/dispatch.ts +27 -2
- package/src/cli/install.ts +2 -2
- package/src/cli/main.ts +68 -21
- package/src/cli/options.ts +5 -0
- package/src/cli/record.ts +2 -7
- package/src/cli/say.ts +21 -4
- package/src/cli/stats.ts +21 -3
- package/src/diagnostic-log.ts +17 -7
- package/src/doctor.ts +8 -5
- package/src/engine/describe.ts +3 -1
- package/src/engine/events.ts +5 -3
- package/src/engine/spawn.ts +22 -0
- package/src/engine-install.ts +37 -9
- package/src/engine.ts +49 -16
- package/src/format.ts +9 -8
- package/src/install-plan.ts +2 -2
- package/src/language-routing.ts +24 -1
- package/src/mcp/tools.ts +24 -9
- package/src/mcp/voices.ts +1 -0
- package/src/progress.ts +1 -1
- package/src/stats.ts +27 -3
- package/src/stdout-pipe.ts +7 -1
- package/src/support-bundle.ts +13 -0
- package/src/synth.ts +13 -22
- package/src/transcribe.ts +60 -6
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
- **Transcribe locally** — [25 languages](docs/languages.md#speech-to-text-25), up to ~19x faster than Whisper on Apple Silicon, ~10x on an x86 CPU
|
|
17
17
|
- **Speak back** — text-to-speech in [9 languages](docs/languages.md#text-to-speech)
|
|
18
18
|
- **Plug into agents** — ship voice workflows as CLI commands, an MCP server, an <a href="docs/openclaw.md">OpenClaw</a> skill, or a <a href="docs/hermes.md">Hermes</a> agent
|
|
19
|
-
- **Small Rust engine** — single ~
|
|
19
|
+
- **Small Rust engine** — single ~74 MB binary, no ffmpeg, no Python, no native Node addons
|
|
20
20
|
|
|
21
21
|
<p align="center">
|
|
22
22
|
<img src="https://github.com/drakulavich/kesha-voice-kit/raw/main/docs/assets/demo.webp" alt="kesha demo: an English voice message transcribed, a Russian one as JSON with automatic language detection, then text-to-speech into a wav" width="800">
|
|
@@ -104,6 +104,7 @@ $ kesha freedom.ogg tahiti.ogg
|
|
|
104
104
|
- **Word-level timestamps** (every platform): `kesha --json --timestamps audio.ogg` adds a `words` array to each segment — `{ "word": "email", "start": 0.72, "end": 1.12 }` — on the same file-relative clock as the segment, so a word always lies inside the segment carrying it. Read them off the decoder's own frame grid, so: times are quantised to 0.08 s, consecutive spans may overlap (each `end` is a per-word duration prediction, not the next word's `start`), `end >= start` rather than strictly greater, and punctuation stays attached to its word. The key is simply absent where a segment has none — any segment `--itn` rewrote, for one — so check the `transcribe.words` capability rather than expecting an empty array ([#720](https://github.com/drakulavich/kesha-voice-kit/issues/720)).
|
|
105
105
|
- **Text-language detection:** JSON and TOON results include `textLanguage` with a language code, confidence, and its `source`. On macOS Kesha uses Apple `NLLanguageRecognizer`; elsewhere it uses the bundled `tinyld` fallback, whose confidence scale is different. This is separate from `audioLanguage`, which identifies the spoken audio when available.
|
|
106
106
|
- **Written-form numbers:** `--itn` rewrites what the model spells out — `"two hundred thirty two"` → `"232"`, `"five dollars and fifty cents"` → `"$5.50"`. Opt-in, every platform, timestamps untouched. English-only in practice; Russian and the rest pass through unchanged. Spoken punctuation names stay words (`"dot"`, `"comma"`, `"the period of growth"`) because Kesha transcribes speech rather than dictation — so `"example dot com"` keeps its words too ([#822](https://github.com/drakulavich/kesha-voice-kit/issues/822)). A sentence `"and"` survives the number that follows it (`"cats and three dogs"` → `"cats and 3 dogs"`), while an `"and"` the number owns still joins it (`"three hundred and five"` → `"305"`) ([#1000](https://github.com/drakulavich/kesha-voice-kit/issues/1000)) — and no longer splits the number around it (`"two hundred and thirty two"` → `"232"`, not `"230 2"`) ([#1006](https://github.com/drakulavich/kesha-voice-kit/issues/1006)). A hyphenated number reads the same as the spaced form (`"twenty-five apples"` → `"25 apples"`), while a hyphen between ordinary words is left alone (`"well-known"`, `"state-of-the-art"`, `"twenty-something"`) ([#1004](https://github.com/drakulavich/kesha-voice-kit/issues/1004)).
|
|
107
|
+
- **A file name starting with `-`:** write it as `./-file.wav`, or put an option before `--` (`kesha --json -- -file.wav`). A bare `kesha -- -file.wav` fails with `unknown option`, because Bun drops a `--` that comes right after the command name ([#1464](https://github.com/drakulavich/kesha-voice-kit/issues/1464)).
|
|
107
108
|
|
|
108
109
|
## Text-to-speech
|
|
109
110
|
|
|
@@ -126,7 +127,7 @@ kesha say "Hello" --format ogg-opus --out hi.ogg # OGG/Opus — messenger voice
|
|
|
126
127
|
kesha say "Hello" --format flac --out hi.flac # FLAC — lossless, plays in every browser incl. Safari/iOS
|
|
127
128
|
```
|
|
128
129
|
|
|
129
|
-
`kesha say --list-voices` lists
|
|
130
|
+
`kesha say --list-voices` lists the voices kesha knows; some need `kesha install --tts <lang>` before they speak. Voices, the full catalogue, macOS system voices, SSML, speaking rate (`--rate`, `<prosody>`), Russian word stress, and Russian/English abbreviation handling are all in **[docs/tts.md](docs/tts.md)**.
|
|
130
131
|
|
|
131
132
|
## Languages
|
|
132
133
|
|
package/completions/kesha.fish
CHANGED
|
@@ -78,7 +78,7 @@ complete -c kesha -n '__fish_seen_subcommand_from say' -l voice -r -d 'Voice id,
|
|
|
78
78
|
complete -c kesha -n '__fish_seen_subcommand_from say' -l lang -r -d 'Language code, e.g. en-us (default en-us; see docs/languages.md). With no --voice, routes to that language\'s default voice and skips text-language detection.'
|
|
79
79
|
complete -c kesha -n '__fish_seen_subcommand_from say' -l out -r -d 'Write audio to file instead of stdout'
|
|
80
80
|
complete -c kesha -n '__fish_seen_subcommand_from say' -l rate -r -d 'Speaking rate 0.5–2.0'
|
|
81
|
-
complete -c kesha -n '__fish_seen_subcommand_from say' -l list-voices -d 'List
|
|
81
|
+
complete -c kesha -n '__fish_seen_subcommand_from say' -l list-voices -d 'List the voices kesha knows and exit; some need kesha install --tts <lang> first'
|
|
82
82
|
complete -c kesha -n '__fish_seen_subcommand_from say' -l ssml -d 'Parse input as SSML (supports <speak>, <break>; strips unknown tags)'
|
|
83
83
|
complete -c kesha -n '__fish_seen_subcommand_from say' -l format -r -d 'Output format: wav (default), ogg-opus (Telegram-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from --out extension when omitted.'
|
|
84
84
|
complete -c kesha -n '__fish_seen_subcommand_from say' -l bitrate -r -d 'Opus bitrate in bits/sec (e.g. 32000). Only with --format ogg-opus.'
|
package/completions/kesha.zsh
CHANGED
|
@@ -112,7 +112,7 @@ _kesha() {
|
|
|
112
112
|
'--lang=[Language code, e.g. en-us (default en-us; see docs/languages.md). With no --voice, routes to that language'\''s default voice and skips text-language detection.]:lang:' \
|
|
113
113
|
'--out=[Write audio to file instead of stdout]:out:' \
|
|
114
114
|
'--rate=[Speaking rate 0.5–2.0]:rate:' \
|
|
115
|
-
'--list-voices[List
|
|
115
|
+
'--list-voices[List the voices kesha knows and exit; some need kesha install --tts <lang> first]' \
|
|
116
116
|
'--ssml[Parse input as SSML (supports <speak>, <break>; strips unknown tags)]' \
|
|
117
117
|
'--format=[Output format: wav (default), ogg-opus (Telegram-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from --out extension when omitted.]:format:' \
|
|
118
118
|
'--bitrate=[Opus bitrate in bits/sec (e.g. 32000). Only with --format ogg-opus.]:bitrate:' \
|
package/man/kesha.1
CHANGED
|
@@ -191,7 +191,7 @@ Write audio to file instead of stdout
|
|
|
191
191
|
Speaking rate 0.5–2.0
|
|
192
192
|
.TP
|
|
193
193
|
.B \-\-list\-voices
|
|
194
|
-
List
|
|
194
|
+
List the voices kesha knows and exit; some need kesha install \-\-tts <lang> first
|
|
195
195
|
.TP
|
|
196
196
|
.B \-\-ssml
|
|
197
197
|
Parse input as SSML (supports <speak>, <break>; strips unknown tags)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "2.3.0-beta.
|
|
3
|
+
"version": "2.3.0-beta.3",
|
|
4
4
|
"description": "Kesha listens and talks back: local speech to text and text to speech for agents and scripts, fastest on Apple Silicon.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -109,20 +109,20 @@
|
|
|
109
109
|
},
|
|
110
110
|
"kesha": {
|
|
111
111
|
"engine": {
|
|
112
|
-
"version": "2.3.0-beta.
|
|
112
|
+
"version": "2.3.0-beta.3",
|
|
113
113
|
"sha256": {
|
|
114
|
-
"kesha-engine-darwin-arm64": "
|
|
115
|
-
"kesha-engine-linux-x64": "
|
|
116
|
-
"kesha-engine-windows-x64.exe": "
|
|
117
|
-
"say-avspeech-darwin-arm64": "
|
|
118
|
-
"kesha-textlang-darwin-arm64": "
|
|
114
|
+
"kesha-engine-darwin-arm64": "db444c1c0ccc368e72068f050807ab54975fb516055480b33c32d5b1f29c4e58",
|
|
115
|
+
"kesha-engine-linux-x64": "0ac69c63cc8e922aaaea5b6df221dd97f6add02d4d6fb09def2317c331019a89",
|
|
116
|
+
"kesha-engine-windows-x64.exe": "0ae3063b7d5ff00da7cd0f675cd557374d81ff94087787dc90d7c195f7347194",
|
|
117
|
+
"say-avspeech-darwin-arm64": "6c7e0708f27c1baf3d8013bfbeb6249a6c3acbb718b3d39081707c7a8aa22e5b",
|
|
118
|
+
"kesha-textlang-darwin-arm64": "cbe74ee1aebdb902e21d4c69769c71b6d49e037794a8623db11faa8cf2814420"
|
|
119
119
|
},
|
|
120
120
|
"size": {
|
|
121
|
-
"kesha-engine-darwin-arm64":
|
|
122
|
-
"kesha-engine-linux-x64":
|
|
123
|
-
"kesha-engine-windows-x64.exe":
|
|
124
|
-
"say-avspeech-darwin-arm64":
|
|
125
|
-
"kesha-textlang-darwin-arm64":
|
|
121
|
+
"kesha-engine-darwin-arm64": 73672784,
|
|
122
|
+
"kesha-engine-linux-x64": 67422216,
|
|
123
|
+
"kesha-engine-windows-x64.exe": 66576384,
|
|
124
|
+
"say-avspeech-darwin-arm64": 82896,
|
|
125
|
+
"kesha-textlang-darwin-arm64": 57488
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
}
|
package/src/cache-layout.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { existsSync, realpathSync, statSync } from "fs";
|
|
2
2
|
import { dirname, join, resolve, sep } from "path";
|
|
3
3
|
import { dirSizeBytes } from "./diagnostic-paths";
|
|
4
|
+
import { isDarwinArm64 } from "./engine-targets";
|
|
5
|
+
|
|
6
|
+
export const KOKORO_ONNX_UNUSED_NOTE = "not used on darwin-arm64, where Kokoro runs from the TTS (Kokoro ANE) bundle";
|
|
4
7
|
|
|
5
8
|
/** The sidecar executables the engine spawns from its own directory. */
|
|
6
9
|
const SIDECAR_FILES = ["say-avspeech", "kesha-textlang"] as const;
|
|
@@ -10,6 +13,7 @@ export interface CacheComponentSize {
|
|
|
10
13
|
path: string;
|
|
11
14
|
exists: boolean;
|
|
12
15
|
sizeBytes: number;
|
|
16
|
+
note?: string;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
interface EngineFootprint {
|
|
@@ -101,14 +105,33 @@ export function cacheComponents(
|
|
|
101
105
|
exists: existsSync(engine.path),
|
|
102
106
|
sizeBytes: footprintBytes(engine, engine.members),
|
|
103
107
|
};
|
|
108
|
+
const coremlRows = [
|
|
109
|
+
{ label: "FluidAudio (in cache)", path: join(cacheRoot, "fluidaudio") },
|
|
110
|
+
{ label: "Diarization (Sortformer)", path: join(cacheRoot, "models/diarize") },
|
|
111
|
+
];
|
|
112
|
+
const onnxRows = [
|
|
113
|
+
{ label: "ASR (Parakeet)", path: join(cacheRoot, "models/parakeet-tdt-v3") },
|
|
114
|
+
{ label: "G2P (CharsiuG2P)", path: join(cacheRoot, "models/g2p") },
|
|
115
|
+
];
|
|
116
|
+
const [usedRows, unusedRows] = coreml ? [coremlRows, onnxRows] : [onnxRows, coremlRows];
|
|
117
|
+
const sized = (row: { label: string; path: string }) => ({
|
|
118
|
+
...row,
|
|
119
|
+
exists: existsSync(row.path),
|
|
120
|
+
sizeBytes: dirSizeBytes(row.path),
|
|
121
|
+
});
|
|
104
122
|
const modelRows = [
|
|
105
|
-
...
|
|
106
|
-
? [{ label: "FluidAudio (in cache)", path: join(cacheRoot, "fluidaudio") }]
|
|
107
|
-
: [{ label: "ASR (Parakeet)", path: join(cacheRoot, "models/parakeet-tdt-v3") }]),
|
|
123
|
+
...usedRows,
|
|
108
124
|
{ label: "Language ID", path: join(cacheRoot, "models/lang-id-ecapa") },
|
|
109
125
|
{ label: "VAD (Silero)", path: join(cacheRoot, "models/silero-vad") },
|
|
110
|
-
{
|
|
126
|
+
{
|
|
127
|
+
label: "TTS (Kokoro)",
|
|
128
|
+
path: join(cacheRoot, "models/kokoro-82m"),
|
|
129
|
+
...(isDarwinArm64() ? { note: KOKORO_ONNX_UNUSED_NOTE } : {}),
|
|
130
|
+
},
|
|
111
131
|
{ label: "TTS (Vosk)", path: join(cacheRoot, "models/vosk-ru") },
|
|
112
|
-
].map(
|
|
113
|
-
|
|
132
|
+
].map(sized);
|
|
133
|
+
const leftoverRows = unusedRows
|
|
134
|
+
.map((row) => sized({ ...row, label: `${row.label}, unused by this engine, safe to delete` }))
|
|
135
|
+
.filter((row) => row.exists);
|
|
136
|
+
return [engineRow, ...modelRows, ...leftoverRows];
|
|
114
137
|
}
|
package/src/cli/context.ts
CHANGED
|
@@ -34,7 +34,8 @@ function stripBooleanFlag(
|
|
|
34
34
|
const valuedPrefixes = names.filter((n) => n.startsWith("--")).map((n) => `${n}=`);
|
|
35
35
|
let value = false;
|
|
36
36
|
const cleaned: string[] = [];
|
|
37
|
-
|
|
37
|
+
const end = rawArgs.indexOf("--");
|
|
38
|
+
for (const arg of end === -1 ? rawArgs : rawArgs.slice(0, end)) {
|
|
38
39
|
if (names.includes(arg)) {
|
|
39
40
|
value = true;
|
|
40
41
|
} else if (valuedPrefixes.some((prefix) => arg.startsWith(prefix))) {
|
|
@@ -43,7 +44,7 @@ function stripBooleanFlag(
|
|
|
43
44
|
cleaned.push(arg);
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
|
-
return { value, rawArgs: cleaned };
|
|
47
|
+
return { value, rawArgs: end === -1 ? cleaned : [...cleaned, ...rawArgs.slice(end)] };
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/**
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { renderUsage, runMain, type ArgsDef, type CommandDef } from "citty";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
3
|
import { stripVTControlCharacters } from "util";
|
|
4
|
+
import { exitCodeFor, KeshaError, SUPPORT_BUNDLE_HINT } from "../engine/events";
|
|
5
|
+
import { errorMessage } from "../error-utils";
|
|
4
6
|
import { isColorEnabled, log } from "../log";
|
|
5
7
|
import { packageVersion } from "../package-info";
|
|
6
8
|
import { guardStdoutWrites } from "../stdout-pipe";
|
|
@@ -117,6 +119,24 @@ function misplacedSubcommand(rawArgs: string[], subcommandKeys: string[]): strin
|
|
|
117
119
|
return rawArgs.slice(0, end === -1 ? undefined : end).find((arg) => subcommandKeys.includes(arg) && !existsSync(arg));
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
/** citty's runMain prints whatever `run` throws as a stack trace with exit 1; only `run` is wrapped, so a command with `subCommands` needs the same. */
|
|
123
|
+
function reportThrows(command: CommandDef<any>): CommandDef<any> {
|
|
124
|
+
const run = command.run;
|
|
125
|
+
if (!run) return command;
|
|
126
|
+
return {
|
|
127
|
+
...command,
|
|
128
|
+
async run(context) {
|
|
129
|
+
try {
|
|
130
|
+
return await run(context);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const coded = err instanceof KeshaError ? err : new KeshaError("E_INTERNAL", errorMessage(err), { hint: SUPPORT_BUNDLE_HINT });
|
|
133
|
+
log.error(coded.render());
|
|
134
|
+
process.exit(exitCodeFor(coded));
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
120
140
|
export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
|
121
141
|
guardStdoutWrites();
|
|
122
142
|
const context = resolveCliContext(argv);
|
|
@@ -136,7 +156,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
|
|
136
156
|
const argsDef = await resolveArgsDef(command);
|
|
137
157
|
rejectUnknownOptions(restArgs, argsDef);
|
|
138
158
|
if (firstArg !== "install" && firstArg !== "say") rejectExtraPositionals(restArgs, argsDef);
|
|
139
|
-
await runMain(command, { rawArgs: restArgs, showUsage });
|
|
159
|
+
await runMain(reportThrows(command), { rawArgs: restArgs, showUsage });
|
|
140
160
|
return;
|
|
141
161
|
}
|
|
142
162
|
|
|
@@ -163,7 +183,12 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
|
|
163
183
|
await showUsage(rawArgs.includes("--all") ? command : briefMainUsage(command));
|
|
164
184
|
return;
|
|
165
185
|
}
|
|
166
|
-
|
|
186
|
+
const options = rawArgs.slice(0, rawArgs.includes("--") ? rawArgs.indexOf("--") : undefined);
|
|
187
|
+
if (options.includes("--version") || options.includes("-v")) {
|
|
188
|
+
process.stdout.write(`${packageVersion}\n`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
await runMain(reportThrows(command), { rawArgs, showUsage });
|
|
167
192
|
}
|
|
168
193
|
}
|
|
169
194
|
}
|
package/src/cli/install.ts
CHANGED
|
@@ -140,8 +140,8 @@ export function selectBackend(coreml: boolean, onnx: boolean): BackendSelection
|
|
|
140
140
|
export function resolveBackendFlag(coreml: boolean, onnx: boolean): string | undefined {
|
|
141
141
|
const selection = selectBackend(coreml, onnx);
|
|
142
142
|
if (!selection.ok) {
|
|
143
|
-
log.error(selection.error);
|
|
144
|
-
process.exit(
|
|
143
|
+
log.error(renderInvalidArg(selection.error));
|
|
144
|
+
process.exit(2);
|
|
145
145
|
}
|
|
146
146
|
return selection.backend;
|
|
147
147
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { defineCommand, type ArgsDef, type CommandDef } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isDirectoryPath,
|
|
6
|
+
isSpecialFilePath,
|
|
7
|
+
missingFileHint,
|
|
8
|
+
SPECIAL_FILE_HINT,
|
|
9
|
+
transcribeWithSegments,
|
|
10
|
+
truncatedWavSeconds,
|
|
11
|
+
validateTranscribeRequest,
|
|
12
|
+
} from "../transcribe";
|
|
5
13
|
|
|
6
14
|
export { isDirectoryPath };
|
|
7
15
|
import { detectAudioLanguageEngine, detectTextLanguageEngine, isEngineInstalled } from "../engine";
|
|
@@ -28,7 +36,7 @@ import { runCommandSession, type CommandSession } from "./command-session";
|
|
|
28
36
|
import { USAGE_MESSAGE } from "./dispatch";
|
|
29
37
|
import type { CliContext } from "./context";
|
|
30
38
|
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
31
|
-
import { detectTextLanguageFallback, routeLanguage } from "../language-routing";
|
|
39
|
+
import { detectTextLanguageFallback, routeLanguage, unsupportedAudioLanguageWarning } from "../language-routing";
|
|
32
40
|
import { renderInvalidArg } from "./options";
|
|
33
41
|
|
|
34
42
|
interface MainCommandArgs {
|
|
@@ -81,14 +89,12 @@ export function resolveOutputFormat(input: {
|
|
|
81
89
|
error: `unknown --format '${input.format}'. supported: ${SUPPORTED_FORMATS.join(", ")}`,
|
|
82
90
|
};
|
|
83
91
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
(input.toon && input.format === "json")
|
|
88
|
-
) {
|
|
92
|
+
const jsonFlag = input.json ? "--json" : input.format === "json" ? "--format json" : null;
|
|
93
|
+
const toonFlag = input.toon ? "--toon" : input.format === "toon" ? "--format toon" : null;
|
|
94
|
+
if (jsonFlag && toonFlag) {
|
|
89
95
|
return {
|
|
90
96
|
ok: false,
|
|
91
|
-
error:
|
|
97
|
+
error: `${jsonFlag} and ${toonFlag} are mutually exclusive (pick one output format).`,
|
|
92
98
|
};
|
|
93
99
|
}
|
|
94
100
|
// `--format transcript` + boolean `--json` / `--toon` was previously
|
|
@@ -264,6 +270,11 @@ async function detectLanguages(
|
|
|
264
270
|
}
|
|
265
271
|
}
|
|
266
272
|
|
|
273
|
+
const unsupportedWarning = unsupportedAudioLanguageWarning(audioLanguage, textLanguage ?? tinyldResult);
|
|
274
|
+
if (unsupportedWarning) {
|
|
275
|
+
warnAboveProgress(progress, `${file}: ${unsupportedWarning}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
267
278
|
const route = routeLanguage({ audioLanguage, textLanguage: textLanguage ?? tinyldResult });
|
|
268
279
|
const { lang } = route;
|
|
269
280
|
|
|
@@ -316,8 +327,19 @@ async function processFile(
|
|
|
316
327
|
const { vadMode, timestamps, speakers, itn, wantsLangId, expectedLang, reportProgress } = options;
|
|
317
328
|
const { stats, diagnosticLog } = recorders;
|
|
318
329
|
|
|
330
|
+
if (file === "-") {
|
|
331
|
+
const err = new KeshaError("E_INVALID_ARG", "reading audio from stdin is not supported", { hint: SPECIAL_FILE_HINT });
|
|
332
|
+
stats.recordError("input", err, err.code);
|
|
333
|
+
diagnosticLog.event("input.invalid", {
|
|
334
|
+
command: "transcribe",
|
|
335
|
+
error_code: err.code,
|
|
336
|
+
});
|
|
337
|
+
log.error(`${file}: ${errorMessage(err)}`);
|
|
338
|
+
return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
|
|
339
|
+
}
|
|
340
|
+
|
|
319
341
|
if (!existsSync(file)) {
|
|
320
|
-
const err = new KeshaError("E_INPUT_NOT_FOUND", "File not found");
|
|
342
|
+
const err = new KeshaError("E_INPUT_NOT_FOUND", "File not found", { hint: missingFileHint(file) });
|
|
321
343
|
stats.recordError("input", err, err.code);
|
|
322
344
|
diagnosticLog.event("input.missing", {
|
|
323
345
|
command: "transcribe",
|
|
@@ -340,6 +362,26 @@ async function processFile(
|
|
|
340
362
|
return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
|
|
341
363
|
}
|
|
342
364
|
|
|
365
|
+
if (isSpecialFilePath(file)) {
|
|
366
|
+
const err = new KeshaError("E_INVALID_ARG", "is not a regular file (expected an audio file)", {
|
|
367
|
+
hint: SPECIAL_FILE_HINT,
|
|
368
|
+
});
|
|
369
|
+
stats.recordError("input", err, err.code);
|
|
370
|
+
diagnosticLog.event("input.invalid", {
|
|
371
|
+
command: "transcribe",
|
|
372
|
+
error_code: err.code,
|
|
373
|
+
});
|
|
374
|
+
log.error(`${file}: ${errorMessage(err)}`);
|
|
375
|
+
return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const truncated = truncatedWavSeconds(file);
|
|
379
|
+
if (truncated) {
|
|
380
|
+
log.warn(
|
|
381
|
+
`warning: ${file} is truncated: its header declares ${truncated.declared.toFixed(1)} s of audio but the file holds ${truncated.present.toFixed(1)} s; transcribing the part present`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
343
385
|
const inputArtifact = artifactFromFile(file, "input_audio");
|
|
344
386
|
if (inputArtifact) {
|
|
345
387
|
stats.recordArtifact(inputArtifact);
|
|
@@ -508,20 +550,25 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
|
|
|
508
550
|
" kesha init Guided first-time setup.\n" +
|
|
509
551
|
"\n" +
|
|
510
552
|
"Commands:\n" +
|
|
511
|
-
" completions
|
|
512
|
-
" doctor
|
|
513
|
-
" init
|
|
514
|
-
" install
|
|
515
|
-
" logs
|
|
516
|
-
" manpage
|
|
517
|
-
" mcp
|
|
518
|
-
" record
|
|
519
|
-
" status
|
|
520
|
-
" say
|
|
521
|
-
" stats
|
|
522
|
-
" support-bundle Create a redacted diagnostics archive
|
|
553
|
+
" completions Print shell completion script.\n" +
|
|
554
|
+
" doctor Collect support diagnostics.\n" +
|
|
555
|
+
" init Interactive setup guide for Kesha features.\n" +
|
|
556
|
+
" install Download engine and models.\n" +
|
|
557
|
+
" logs Manage local privacy-safe diagnostic logs.\n" +
|
|
558
|
+
" manpage Print the kesha(1) manpage.\n" +
|
|
559
|
+
" mcp Run an MCP server over stdio.\n" +
|
|
560
|
+
" record Record microphone audio to a WAV file.\n" +
|
|
561
|
+
" status Inspect installed backend.\n" +
|
|
562
|
+
" say Synthesize speech from text.\n" +
|
|
563
|
+
" stats Manage local anonymous performance stats.\n" +
|
|
564
|
+
" support-bundle Create a redacted diagnostics archive.\n",
|
|
523
565
|
},
|
|
524
566
|
args: {
|
|
567
|
+
audio_file: {
|
|
568
|
+
type: "positional",
|
|
569
|
+
required: false,
|
|
570
|
+
description: "Audio file(s) to transcribe",
|
|
571
|
+
},
|
|
525
572
|
json: {
|
|
526
573
|
type: "boolean",
|
|
527
574
|
description: "Output results as JSON",
|
package/src/cli/options.ts
CHANGED
|
@@ -71,6 +71,11 @@ function scanArgs(rawArgs: string[], argsDef: ArgsDef): { unknown: UnknownOption
|
|
|
71
71
|
return { unknown: null, positionals };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** The positionals in `rawArgs` as the user typed them; citty reads `-5 degrees` as a bundle of short flags (#1473 S2-5). */
|
|
75
|
+
export function positionalArgs(rawArgs: string[], argsDef: ArgsDef): string[] {
|
|
76
|
+
return scanArgs(rawArgs, argsDef).positionals;
|
|
77
|
+
}
|
|
78
|
+
|
|
74
79
|
/** The coded line for a usage error the CLI refuses before doing anything (S9-F2). */
|
|
75
80
|
export function renderInvalidArg(message: string): string {
|
|
76
81
|
return renderError({ code: "E_INVALID_ARG", message });
|
package/src/cli/record.ts
CHANGED
|
@@ -3,13 +3,13 @@ import { errorMessage } from "../error-utils";
|
|
|
3
3
|
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
4
4
|
import { renderInvalidArg } from "./options";
|
|
5
5
|
import {
|
|
6
|
+
engineMissingError,
|
|
6
7
|
isEngineInstalled,
|
|
7
8
|
recordEngine,
|
|
8
9
|
validateRecordRequest,
|
|
9
10
|
type LiveAutoStopOptions,
|
|
10
11
|
type RecordTarget,
|
|
11
12
|
} from "../engine";
|
|
12
|
-
import { installHint } from "../install-hint";
|
|
13
13
|
import { log } from "../log";
|
|
14
14
|
import { getPendingSignalExitCode, waitForPendingSignalCleanup } from "../process-tree";
|
|
15
15
|
|
|
@@ -142,12 +142,7 @@ function resolveThreshold(
|
|
|
142
142
|
|
|
143
143
|
/** Mirrors `src/transcribe.ts`'s "no transcription backend" guard message, worded for recording. */
|
|
144
144
|
export function noRecordingBackendMessage(): string {
|
|
145
|
-
return (
|
|
146
|
-
"Error: No recording backend is installed.\n\n" +
|
|
147
|
-
"Run the following to get started:\n\n" +
|
|
148
|
-
" bun add -g @drakulavich/kesha-voice-kit\n" +
|
|
149
|
-
` ${installHint()}`
|
|
150
|
-
);
|
|
145
|
+
return errorMessage(engineMissingError("recording"));
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
export const recordCommand = defineCommand({
|
package/src/cli/say.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { defineCommand } from "citty";
|
|
1
|
+
import { defineCommand, type ArgsDef } from "citty";
|
|
2
2
|
import { statSync } from "fs";
|
|
3
|
+
import { extname } from "path";
|
|
3
4
|
import { errorMessage } from "../error-utils";
|
|
5
|
+
import { engineMissingError, isEngineInstalled } from "../engine";
|
|
4
6
|
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
5
|
-
import { renderInvalidArg } from "./options";
|
|
7
|
+
import { positionalArgs, renderInvalidArg } from "./options";
|
|
6
8
|
import { log } from "../log";
|
|
7
9
|
import {
|
|
8
10
|
MAX_TEXT_CHARS,
|
|
11
|
+
TEXT_TOO_LONG_HINT,
|
|
9
12
|
listVoiceIds,
|
|
10
13
|
say,
|
|
11
14
|
SUPPORTED_SAMPLE_RATES,
|
|
@@ -35,6 +38,7 @@ async function resolveText(inline: string | undefined): Promise<string> {
|
|
|
35
38
|
throw new KeshaError(
|
|
36
39
|
"E_TEXT_TOO_LONG",
|
|
37
40
|
`text exceeds ${MAX_TEXT_CHARS} chars (stdin passed ${MAX_STDIN_BYTES} bytes)`,
|
|
41
|
+
{ hint: TEXT_TOO_LONG_HINT },
|
|
38
42
|
);
|
|
39
43
|
}
|
|
40
44
|
const merged = new Uint8Array(total);
|
|
@@ -128,6 +132,16 @@ function checkOpusOnlyFlags(
|
|
|
128
132
|
return "--bitrate and --sample-rate are only valid with --format ogg-opus";
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
const OUT_EXTENSIONS = [".wav", ".ogg", ".opus", ".oga", ".flac"];
|
|
136
|
+
|
|
137
|
+
/** Mirrors the engine's `format_from_extension`, which writes WAV under any name it does not know (#1473 S2-4). */
|
|
138
|
+
function checkOutExtension(outArg: string | undefined): string | null {
|
|
139
|
+
if (outArg === undefined) return null;
|
|
140
|
+
const ext = extname(outArg).toLowerCase();
|
|
141
|
+
if (ext === "" || OUT_EXTENSIONS.includes(ext)) return null;
|
|
142
|
+
return `--out ${outArg}: kesha cannot encode ${ext}; use one of ${OUT_EXTENSIONS.join(", ")}, or no extension for WAV`;
|
|
143
|
+
}
|
|
144
|
+
|
|
131
145
|
export type SayFlagArgs = {
|
|
132
146
|
voice?: unknown;
|
|
133
147
|
lang?: unknown;
|
|
@@ -179,6 +193,8 @@ export function resolveSayFlags(args: SayFlagArgs): ResolvedSayFlags {
|
|
|
179
193
|
const out = typeof args.out === "string" ? args.out : undefined;
|
|
180
194
|
const opusOnlyError = checkOpusOnlyFlags(bitrate.value, sampleRate.value, format.value, out);
|
|
181
195
|
if (opusOnlyError) return { ok: false, error: opusOnlyError };
|
|
196
|
+
const outExtError = checkOutExtension(out);
|
|
197
|
+
if (outExtError) return { ok: false, error: outExtError };
|
|
182
198
|
|
|
183
199
|
return {
|
|
184
200
|
ok: true,
|
|
@@ -253,6 +269,7 @@ async function synthesizeAndEmit(
|
|
|
253
269
|
const { stats } = session;
|
|
254
270
|
try {
|
|
255
271
|
const startedAt = performance.now();
|
|
272
|
+
if (!isEngineInstalled()) throw engineMissingError("speech synthesis", "--tts");
|
|
256
273
|
if (opts.out) {
|
|
257
274
|
const voiceLabel = opts.voice ?? "default voice";
|
|
258
275
|
log.status(`Synthesizing ${voiceLabel} -> ${opts.out}...`);
|
|
@@ -310,7 +327,7 @@ export const sayCommand = defineCommand({
|
|
|
310
327
|
},
|
|
311
328
|
out: { type: "string", description: "Write audio to file instead of stdout" },
|
|
312
329
|
rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
|
|
313
|
-
"list-voices": { type: "boolean", description: "List
|
|
330
|
+
"list-voices": { type: "boolean", description: "List the voices kesha knows and exit; some need kesha install --tts <lang> first" },
|
|
314
331
|
ssml: {
|
|
315
332
|
type: "boolean",
|
|
316
333
|
description: "Parse input as SSML (supports <speak>, <break>; strips unknown tags)",
|
|
@@ -374,7 +391,7 @@ export const sayCommand = defineCommand({
|
|
|
374
391
|
process.exit(2);
|
|
375
392
|
}
|
|
376
393
|
|
|
377
|
-
const positionals = (args
|
|
394
|
+
const positionals = positionalArgs(rawArgs, sayCommand.args as ArgsDef);
|
|
378
395
|
const inlineText =
|
|
379
396
|
positionals.length > 0 ? positionals.join(" ") : typeof args.text === "string" ? args.text : undefined;
|
|
380
397
|
const stdinIsTty = (process.stdin as { isTTY?: boolean }).isTTY;
|
package/src/cli/stats.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getRecentErrors,
|
|
7
7
|
getStatsStatus,
|
|
8
8
|
getWeekSummary,
|
|
9
|
+
MAX_RETENTION_DAYS,
|
|
9
10
|
renderErrors,
|
|
10
11
|
renderWeekSummary,
|
|
11
12
|
resetStats,
|
|
@@ -13,6 +14,9 @@ import {
|
|
|
13
14
|
vacuumStats,
|
|
14
15
|
type StatsExportFormat,
|
|
15
16
|
} from "../stats";
|
|
17
|
+
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
18
|
+
import { errorMessage } from "../error-utils";
|
|
19
|
+
import { log } from "../log";
|
|
16
20
|
import { emitActionResult, type ActionMessage, type ActionResult } from "./action-result";
|
|
17
21
|
|
|
18
22
|
export interface StatsCommandArgs {
|
|
@@ -113,7 +117,13 @@ function runRetentionAction(value: string | undefined): ActionResult {
|
|
|
113
117
|
return { ok: true, messages: info(`Kesha Stats retention: ${formatRetention(status.retentionDays)}`) };
|
|
114
118
|
}
|
|
115
119
|
const retention = parseRetention(value);
|
|
116
|
-
if (retention === undefined)
|
|
120
|
+
if (retention === undefined) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error: "usage: kesha stats retention <days|off>",
|
|
124
|
+
hint: `days is a whole number from 1 to ${MAX_RETENTION_DAYS}.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
117
127
|
setStatsRetentionDays(retention);
|
|
118
128
|
return { ok: true, messages: info(`Kesha Stats retention set to ${formatRetention(retention)}`) };
|
|
119
129
|
}
|
|
@@ -144,7 +154,15 @@ export const statsCommand = defineCommand({
|
|
|
144
154
|
},
|
|
145
155
|
},
|
|
146
156
|
run({ args, rawArgs }: { args: StatsCommandArgs; rawArgs: string[] }) {
|
|
147
|
-
|
|
157
|
+
let result: ActionResult;
|
|
158
|
+
try {
|
|
159
|
+
result = runStatsAction({ ...args, value: args.value ?? eatenNegativeNumber(rawArgs) });
|
|
160
|
+
} catch (err) {
|
|
161
|
+
const coded = err instanceof KeshaError ? err : new KeshaError("E_INTERNAL", errorMessage(err));
|
|
162
|
+
log.error(coded.render());
|
|
163
|
+
process.exit(exitCodeFor(coded));
|
|
164
|
+
}
|
|
165
|
+
emitActionResult(result);
|
|
148
166
|
},
|
|
149
167
|
});
|
|
150
168
|
|
|
@@ -161,7 +179,7 @@ function parseRetention(value: string): number | null | undefined {
|
|
|
161
179
|
const normalized = value.trim().toLowerCase();
|
|
162
180
|
if (normalized === "off" || normalized === "none" || normalized === "never") return null;
|
|
163
181
|
const days = Number(normalized);
|
|
164
|
-
if (Number.isInteger(days) && days >= 1) return days;
|
|
182
|
+
if (Number.isInteger(days) && days >= 1 && days <= MAX_RETENTION_DAYS) return days;
|
|
165
183
|
return undefined;
|
|
166
184
|
}
|
|
167
185
|
|
package/src/diagnostic-log.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { KeshaError } from "./engine/events";
|
|
1
2
|
import { errorMessage } from "./error-utils";
|
|
3
|
+
import { PATH_ERRNOS } from "./path-errnos";
|
|
2
4
|
import { resolveStatePaths } from "./state-paths";
|
|
3
5
|
import {
|
|
4
6
|
closeSync,
|
|
@@ -19,7 +21,7 @@ import { packageVersion } from "./package-info";
|
|
|
19
21
|
|
|
20
22
|
const ACTIVE_LOG_FILE = "kesha.ndjson";
|
|
21
23
|
const STATE_FILE = "diagnostic-logs.json";
|
|
22
|
-
const DEFAULT_MAX_BYTES =
|
|
24
|
+
const DEFAULT_MAX_BYTES = 10_000_000;
|
|
23
25
|
const DEFAULT_RETAIN = 5;
|
|
24
26
|
const DEFAULT_TAIL_BYTES = 64 * 1024;
|
|
25
27
|
const SAFE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
@@ -115,8 +117,16 @@ function positiveInt(value: unknown, fallback: number): number {
|
|
|
115
117
|
|
|
116
118
|
function writeConfig(config: DiagnosticLogConfig): void {
|
|
117
119
|
const statePath = resolveStatePath();
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
try {
|
|
121
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
122
|
+
writeFileSync(statePath, `${JSON.stringify(config, null, 2)}\n`);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const errno = (err as NodeJS.ErrnoException).code ?? "";
|
|
125
|
+
const why = PATH_ERRNOS[errno];
|
|
126
|
+
throw new KeshaError(why ? "E_INVALID_ARG" : "E_INTERNAL", `cannot write the diagnostic log settings ${statePath}: ${why ? `${why} (${errno})` : errorMessage(err)}`, {
|
|
127
|
+
hint: `make ${dirname(statePath)} writable, or set KESHA_LOG_DIR to a folder you can write to.`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
120
130
|
}
|
|
121
131
|
|
|
122
132
|
export function setDiagnosticLogMode(mode: DiagnosticLogMode): DiagnosticLogStatus {
|
|
@@ -311,12 +321,12 @@ function rotateIfNeeded(activePath: string, nextBytes: number, maxBytes: number,
|
|
|
311
321
|
}
|
|
312
322
|
|
|
313
323
|
export function humanBytes(bytes: number): string {
|
|
314
|
-
if (bytes <
|
|
324
|
+
if (bytes < 1000) return `${bytes} B`;
|
|
315
325
|
const units = ["KB", "MB", "GB", "TB"];
|
|
316
|
-
let value = bytes /
|
|
326
|
+
let value = bytes / 1000;
|
|
317
327
|
let unit = units[0];
|
|
318
|
-
for (let i = 1; i < units.length && value >=
|
|
319
|
-
value /=
|
|
328
|
+
for (let i = 1; i < units.length && value >= 1000; i++) {
|
|
329
|
+
value /= 1000;
|
|
320
330
|
unit = units[i];
|
|
321
331
|
}
|
|
322
332
|
return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`;
|