@drakulavich/kesha-voice-kit 2.2.0 → 2.3.0-beta.2
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 +1 -0
- package/completions/kesha.fish +1 -1
- package/completions/kesha.zsh +2 -2
- package/man/kesha.1 +1 -1
- package/model-plan.json +3 -0
- package/package.json +14 -15
- package/src/cli/context.ts +3 -2
- package/src/cli/dispatch.ts +22 -2
- package/src/cli/install.ts +2 -2
- package/src/cli/main.ts +34 -7
- package/src/cli/record.ts +5 -5
- package/src/cli/say.ts +2 -0
- package/src/cli/stats.ts +21 -3
- package/src/diagnostic-log.ts +12 -2
- 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 +13 -5
- package/src/engine.ts +37 -16
- package/src/format.ts +4 -3
- package/src/install-hint.ts +3 -1
- package/src/language-routing.ts +24 -1
- package/src/mcp/tools.ts +23 -7
- package/src/progress.ts +49 -18
- package/src/stats.ts +27 -3
- package/src/stdout-pipe.ts +7 -1
- package/src/synth.ts +47 -16
- package/src/transcribe.ts +16 -0
package/README.md
CHANGED
|
@@ -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
|
|
package/completions/kesha.fish
CHANGED
|
@@ -26,7 +26,7 @@ complete -c kesha -n '__fish_use_subcommand' -l format -r -d 'Output format: tra
|
|
|
26
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
|
-
complete -c kesha -n '__fish_use_subcommand' -l no-vad -d '
|
|
29
|
+
complete -c kesha -n '__fish_use_subcommand' -l no-vad -d 'Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers'
|
|
30
30
|
complete -c kesha -n '__fish_use_subcommand' -l quiet -d 'Suppress progress output; print only results and errors'
|
|
31
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)'
|
|
32
32
|
complete -c kesha -n '__fish_seen_subcommand_from completions' -l help -d 'Show help'
|
package/completions/kesha.zsh
CHANGED
|
@@ -35,7 +35,7 @@ _kesha() {
|
|
|
35
35
|
'--lang=[Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch]:lang:' \
|
|
36
36
|
'--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
|
|
37
37
|
'--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
|
|
38
|
-
'--no-vad[
|
|
38
|
+
'--no-vad[Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers]' \
|
|
39
39
|
'--quiet[Suppress progress output; print only results and errors]' \
|
|
40
40
|
'--no-color[Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)]'
|
|
41
41
|
else
|
|
@@ -154,7 +154,7 @@ _kesha() {
|
|
|
154
154
|
'--lang=[Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch]:lang:' \
|
|
155
155
|
'--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
|
|
156
156
|
'--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
|
|
157
|
-
'--no-vad[
|
|
157
|
+
'--no-vad[Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers]' \
|
|
158
158
|
'--quiet[Suppress progress output; print only results and errors]' \
|
|
159
159
|
'--no-color[Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)]' \
|
|
160
160
|
'*:audio file:_files'
|
package/man/kesha.1
CHANGED
|
@@ -82,7 +82,7 @@ Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)
|
|
|
82
82
|
Force Silero VAD preprocessing (kesha install \-\-vad first). Without this, VAD auto\-engages on audio ≥ 120s.
|
|
83
83
|
.TP
|
|
84
84
|
.B \-\-no\-vad
|
|
85
|
-
|
|
85
|
+
Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with \-\-speakers
|
|
86
86
|
.TP
|
|
87
87
|
.B \-\-quiet
|
|
88
88
|
Suppress progress output; print only results and errors
|
package/model-plan.json
CHANGED
|
@@ -161,6 +161,9 @@
|
|
|
161
161
|
{ "relPath": "g2pw/g2pw.mlmodelc/weights/weight.bin", "sizeBytes": 159294962 }
|
|
162
162
|
],
|
|
163
163
|
"aneZhG2p": [
|
|
164
|
+
{ "relPath": "g2p/jieba_hmm_emit.bin", "sizeBytes": 292640 },
|
|
165
|
+
{ "relPath": "g2p/jieba_hmm_start.bin", "sizeBytes": 16 },
|
|
166
|
+
{ "relPath": "g2p/jieba_hmm_trans.bin", "sizeBytes": 64 },
|
|
164
167
|
{ "relPath": "g2p/pinyin_phrases.bin", "sizeBytes": 9552083 },
|
|
165
168
|
{ "relPath": "g2p/pinyin_single.bin", "sizeBytes": 480169 }
|
|
166
169
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0-beta.2",
|
|
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": {
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"coverage:rust": "mkdir -p coverage/rust && cd rust && cargo llvm-cov nextest --features tts --lcov --output-path ../coverage/rust/lcov.info",
|
|
51
51
|
"coverage:check:rust": "bun .github/scripts/check-coverage.ts --preset=rust --lcov=coverage/rust/lcov.info --summary=coverage/rust/summary.md",
|
|
52
52
|
"generate:shell-artifacts": "bun scripts/generate-shell-artifacts.ts",
|
|
53
|
-
"lint": "
|
|
54
|
-
"check": "bun run lint && bun run check:versions && bun run check:recipes && bun run test:cli-fast",
|
|
53
|
+
"lint": "bun check",
|
|
54
|
+
"check:fast": "bun run lint && bun run check:versions && bun run check:recipes && bun run test:cli-fast",
|
|
55
55
|
"check:workflows": "bun .github/scripts/check-workflows.ts",
|
|
56
56
|
"check:recipes": "bun .github/scripts/check-recipes.ts",
|
|
57
57
|
"check:specs": "openspec validate --specs --strict",
|
|
@@ -89,7 +89,6 @@
|
|
|
89
89
|
"@fission-ai/openspec": "1.14.1",
|
|
90
90
|
"@types/bun": "latest",
|
|
91
91
|
"fast-xml-parser": "^5.8.0",
|
|
92
|
-
"typescript": "^7.0.2",
|
|
93
92
|
"yaml": "^2.9.0"
|
|
94
93
|
},
|
|
95
94
|
"dependencies": {
|
|
@@ -110,20 +109,20 @@
|
|
|
110
109
|
},
|
|
111
110
|
"kesha": {
|
|
112
111
|
"engine": {
|
|
113
|
-
"version": "2.
|
|
112
|
+
"version": "2.3.0-beta.2",
|
|
114
113
|
"sha256": {
|
|
115
|
-
"kesha-engine-darwin-arm64": "
|
|
116
|
-
"kesha-engine-linux-x64": "
|
|
117
|
-
"kesha-engine-windows-x64.exe": "
|
|
118
|
-
"say-avspeech-darwin-arm64": "
|
|
119
|
-
"kesha-textlang-darwin-arm64": "
|
|
114
|
+
"kesha-engine-darwin-arm64": "febd870e9f857be6452336c74daad161f2cef74dfedb64a21b49b6b1fc2ce94c",
|
|
115
|
+
"kesha-engine-linux-x64": "9c16e72e5290f774b1d93cbf44b2fc6eca3c4b30659038ccb80f5e98dc748d2a",
|
|
116
|
+
"kesha-engine-windows-x64.exe": "231bc45eb345ef359ca94685866de8cb4c7c76ae9bef1072c20a60640c458f63",
|
|
117
|
+
"say-avspeech-darwin-arm64": "6c7e0708f27c1baf3d8013bfbeb6249a6c3acbb718b3d39081707c7a8aa22e5b",
|
|
118
|
+
"kesha-textlang-darwin-arm64": "cbe74ee1aebdb902e21d4c69769c71b6d49e037794a8623db11faa8cf2814420"
|
|
120
119
|
},
|
|
121
120
|
"size": {
|
|
122
|
-
"kesha-engine-darwin-arm64":
|
|
123
|
-
"kesha-engine-linux-x64":
|
|
124
|
-
"kesha-engine-windows-x64.exe":
|
|
125
|
-
"say-avspeech-darwin-arm64":
|
|
126
|
-
"kesha-textlang-darwin-arm64":
|
|
121
|
+
"kesha-engine-darwin-arm64": 73672800,
|
|
122
|
+
"kesha-engine-linux-x64": 67418152,
|
|
123
|
+
"kesha-engine-windows-x64.exe": 66596864,
|
|
124
|
+
"say-avspeech-darwin-arm64": 82896,
|
|
125
|
+
"kesha-textlang-darwin-arm64": 57488
|
|
127
126
|
}
|
|
128
127
|
}
|
|
129
128
|
}
|
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,7 @@ 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
|
-
await runMain(command, { rawArgs, showUsage });
|
|
186
|
+
await runMain(reportThrows(command), { rawArgs, showUsage });
|
|
167
187
|
}
|
|
168
188
|
}
|
|
169
189
|
}
|
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,7 @@
|
|
|
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 { isDirectoryPath, transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
|
|
4
|
+
import { isDirectoryPath, isSpecialFilePath, SPECIAL_FILE_HINT, transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
|
|
5
5
|
|
|
6
6
|
export { isDirectoryPath };
|
|
7
7
|
import { detectAudioLanguageEngine, detectTextLanguageEngine, isEngineInstalled } from "../engine";
|
|
@@ -28,7 +28,7 @@ import { runCommandSession, type CommandSession } from "./command-session";
|
|
|
28
28
|
import { USAGE_MESSAGE } from "./dispatch";
|
|
29
29
|
import type { CliContext } from "./context";
|
|
30
30
|
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
31
|
-
import { detectTextLanguageFallback, routeLanguage } from "../language-routing";
|
|
31
|
+
import { detectTextLanguageFallback, routeLanguage, unsupportedAudioLanguageWarning } from "../language-routing";
|
|
32
32
|
import { renderInvalidArg } from "./options";
|
|
33
33
|
|
|
34
34
|
interface MainCommandArgs {
|
|
@@ -264,6 +264,11 @@ async function detectLanguages(
|
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
const unsupportedWarning = unsupportedAudioLanguageWarning(audioLanguage, textLanguage ?? tinyldResult);
|
|
268
|
+
if (unsupportedWarning) {
|
|
269
|
+
warnAboveProgress(progress, `${file}: ${unsupportedWarning}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
267
272
|
const route = routeLanguage({ audioLanguage, textLanguage: textLanguage ?? tinyldResult });
|
|
268
273
|
const { lang } = route;
|
|
269
274
|
|
|
@@ -295,6 +300,14 @@ type ProcessFileSuccess = { ok: true; result: TranscribeResult };
|
|
|
295
300
|
type ProcessFileFailure = { ok: false; error: TranscribeErrorRecord; exitCode: number };
|
|
296
301
|
|
|
297
302
|
/** An argument the CLI itself rejected exits by its code (2); every runtime failure keeps the batch's operational 1 (S9-F1). */
|
|
303
|
+
/** `kesha <dir>/*.ogg`, pasteable: a name starting with `-` would parse as a flag, and only Windows separates with `\`. */
|
|
304
|
+
export function folderGlobHint(dir: string): string {
|
|
305
|
+
const trimmed = dir.replace(process.platform === "win32" ? /[\\/]+$/ : /\/+$/, "");
|
|
306
|
+
const path = trimmed.startsWith("-") ? `./${trimmed}` : trimmed;
|
|
307
|
+
const quoted = /^[\w./-]+$/.test(path) ? path : `'${path.replaceAll("'", `'\\''`)}'`;
|
|
308
|
+
return `kesha ${quoted}/*.ogg`;
|
|
309
|
+
}
|
|
310
|
+
|
|
298
311
|
function failureExitCode(err: unknown): number {
|
|
299
312
|
const usage = err instanceof KeshaError && err.origin === "cli" && err.code === "E_INVALID_ARG";
|
|
300
313
|
return usage ? exitCodeFor(err) : 1;
|
|
@@ -320,7 +333,22 @@ async function processFile(
|
|
|
320
333
|
}
|
|
321
334
|
|
|
322
335
|
if (isDirectoryPath(file)) {
|
|
323
|
-
const err = new KeshaError("E_INVALID_ARG", "is a directory (expected an audio file)"
|
|
336
|
+
const err = new KeshaError("E_INVALID_ARG", "is a directory (expected an audio file)", {
|
|
337
|
+
hint: `pass the audio files in it instead, e.g. ${folderGlobHint(file)}`,
|
|
338
|
+
});
|
|
339
|
+
stats.recordError("input", err, err.code);
|
|
340
|
+
diagnosticLog.event("input.invalid", {
|
|
341
|
+
command: "transcribe",
|
|
342
|
+
error_code: err.code,
|
|
343
|
+
});
|
|
344
|
+
log.error(`${file}: ${errorMessage(err)}`);
|
|
345
|
+
return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (isSpecialFilePath(file)) {
|
|
349
|
+
const err = new KeshaError("E_INVALID_ARG", "is not a regular file (expected an audio file)", {
|
|
350
|
+
hint: SPECIAL_FILE_HINT,
|
|
351
|
+
});
|
|
324
352
|
stats.recordError("input", err, err.code);
|
|
325
353
|
diagnosticLog.event("input.invalid", {
|
|
326
354
|
command: "transcribe",
|
|
@@ -346,9 +374,8 @@ async function processFile(
|
|
|
346
374
|
// so the engine's progress has to land while it happens, not once the run is over.
|
|
347
375
|
const showProgressLine = (line: string) => {
|
|
348
376
|
if (log.quietEnabled) return;
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
else write();
|
|
377
|
+
if (progress) progress.status(line);
|
|
378
|
+
else process.stderr.write(`${line}\n`);
|
|
352
379
|
};
|
|
353
380
|
try {
|
|
354
381
|
await validateTranscribeRequest({ vad: vadMode, timestamps, speakers, itn });
|
|
@@ -451,7 +478,7 @@ export const MAIN_VAD_ARGS = {
|
|
|
451
478
|
"no-vad": {
|
|
452
479
|
type: "boolean",
|
|
453
480
|
description:
|
|
454
|
-
"
|
|
481
|
+
"Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers",
|
|
455
482
|
default: false,
|
|
456
483
|
},
|
|
457
484
|
} as const;
|
package/src/cli/record.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { errorMessage } from "../error-utils";
|
|
|
3
3
|
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
4
4
|
import { renderInvalidArg } from "./options";
|
|
5
5
|
import {
|
|
6
|
+
getEngineBinPath,
|
|
6
7
|
isEngineInstalled,
|
|
7
8
|
recordEngine,
|
|
8
9
|
validateRecordRequest,
|
|
@@ -142,11 +143,10 @@ function resolveThreshold(
|
|
|
142
143
|
|
|
143
144
|
/** Mirrors `src/transcribe.ts`'s "no transcription backend" guard message, worded for recording. */
|
|
144
145
|
export function noRecordingBackendMessage(): string {
|
|
145
|
-
return (
|
|
146
|
-
"
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
` ${installHint()}`
|
|
146
|
+
return errorMessage(
|
|
147
|
+
new KeshaError("E_ENGINE_SPAWN", `No recording backend is installed: no kesha-engine at ${getEngineBinPath()}`, {
|
|
148
|
+
hint: `bun add -g @drakulavich/kesha-voice-kit, then ${installHint()}`,
|
|
149
|
+
}),
|
|
150
150
|
);
|
|
151
151
|
}
|
|
152
152
|
|
package/src/cli/say.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { renderInvalidArg } from "./options";
|
|
|
6
6
|
import { log } from "../log";
|
|
7
7
|
import {
|
|
8
8
|
MAX_TEXT_CHARS,
|
|
9
|
+
TEXT_TOO_LONG_HINT,
|
|
9
10
|
listVoiceIds,
|
|
10
11
|
say,
|
|
11
12
|
SUPPORTED_SAMPLE_RATES,
|
|
@@ -35,6 +36,7 @@ async function resolveText(inline: string | undefined): Promise<string> {
|
|
|
35
36
|
throw new KeshaError(
|
|
36
37
|
"E_TEXT_TOO_LONG",
|
|
37
38
|
`text exceeds ${MAX_TEXT_CHARS} chars (stdin passed ${MAX_STDIN_BYTES} bytes)`,
|
|
39
|
+
{ hint: TEXT_TOO_LONG_HINT },
|
|
38
40
|
);
|
|
39
41
|
}
|
|
40
42
|
const merged = new Uint8Array(total);
|
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,
|
|
@@ -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 {
|
package/src/engine/describe.ts
CHANGED
|
@@ -197,7 +197,9 @@ export function validateArgv(argv: string[], doc: DescribeDocument): { argv: str
|
|
|
197
197
|
if (name === null) continue;
|
|
198
198
|
const flag = schema.flags[name];
|
|
199
199
|
if (!flag) {
|
|
200
|
-
throw new KeshaError("E_INVALID_ARG", `kesha-engine ${command} does not accept --${name}
|
|
200
|
+
throw new KeshaError("E_INVALID_ARG", `kesha-engine ${command} does not accept --${name}`, {
|
|
201
|
+
hint: `the installed kesha-engine is older than this CLI and lacks --${name}; run \`kesha install\` to fetch the engine this CLI expects`,
|
|
202
|
+
});
|
|
201
203
|
}
|
|
202
204
|
if (!gateSatisfied(flag.gate, doc.features)) {
|
|
203
205
|
const needs = `--${name} needs ${gateText(flag.gate)}, which this kesha-engine build (${build}) does not provide`;
|
package/src/engine/events.ts
CHANGED
|
@@ -228,8 +228,10 @@ export function exitCodeFor(err: unknown): number {
|
|
|
228
228
|
|
|
229
229
|
const MAX_QUOTED_LINE_CHARS = 200;
|
|
230
230
|
|
|
231
|
+
export const SUPPORT_BUNDLE_HINT = "re-run with KESHA_DEBUG=1, then file a bug with the `kesha support-bundle` archive";
|
|
232
|
+
|
|
231
233
|
/** A library line can carry the user's whole input back, so the quote is bounded and the transcript no longer repeats it (#T1-14). */
|
|
232
|
-
function quoteInvalidLine(line: string): string {
|
|
234
|
+
export function quoteInvalidLine(line: string): string {
|
|
233
235
|
const chars = Array.from(line);
|
|
234
236
|
return chars.length <= MAX_QUOTED_LINE_CHARS ? line : `${chars.slice(0, MAX_QUOTED_LINE_CHARS).join("")}…`;
|
|
235
237
|
}
|
|
@@ -247,9 +249,9 @@ export function engineFailure(command: string, outcome: StderrOutcome, exitCode:
|
|
|
247
249
|
return new KeshaError(
|
|
248
250
|
"E_INTERNAL",
|
|
249
251
|
offProtocolLine(command, outcome.invalid[0]!),
|
|
250
|
-
{ ...extra, stderr: rest },
|
|
252
|
+
{ ...extra, stderr: rest, hint: `the engine and CLI may not match: run \`kesha install\`; if it persists, ${SUPPORT_BUNDLE_HINT}` },
|
|
251
253
|
);
|
|
252
254
|
}
|
|
253
255
|
if (outcome.error) return new KeshaError(outcome.error.code, outcome.error.message, { ...extra, hint: outcome.error.hint });
|
|
254
|
-
return new KeshaError("E_INTERNAL", `kesha-engine ${command} exited with code ${exitCode}`, extra);
|
|
256
|
+
return new KeshaError("E_INTERNAL", `kesha-engine ${command} exited with code ${exitCode}`, { ...extra, hint: SUPPORT_BUNDLE_HINT });
|
|
255
257
|
}
|
package/src/engine/spawn.ts
CHANGED
|
@@ -83,6 +83,28 @@ export interface EngineProcessRun<T> {
|
|
|
83
83
|
events: StderrOutcome;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/** Fault signals, by number. SIGBUS is 10 on darwin but 7 on linux. */
|
|
87
|
+
const FAULT_SIGNALS: Record<number, string> = { 4: "SIGILL", 6: "SIGABRT", 8: "SIGFPE", 11: "SIGSEGV" };
|
|
88
|
+
|
|
89
|
+
/** The signal that killed the engine, or null; the wait status outranks Bun's signalCode (10 is SIGBUS on darwin, SIGUSR1 to Bun). */
|
|
90
|
+
export function engineSignal(exitCode: number, signalCode: string | null, platform: string = process.platform): string | null {
|
|
91
|
+
const number = exitCode > 128 ? exitCode - 128 : undefined;
|
|
92
|
+
const sigbus = platform === "darwin" ? 10 : 7;
|
|
93
|
+
const signal =
|
|
94
|
+
number === undefined
|
|
95
|
+
? (signalCode ?? undefined)
|
|
96
|
+
: number === sigbus
|
|
97
|
+
? "SIGBUS"
|
|
98
|
+
: (FAULT_SIGNALS[number] ?? signalCode ?? undefined);
|
|
99
|
+
return signal ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What a run that reported nothing ended with: the signal that killed it, else its exit status. */
|
|
103
|
+
export function engineExitCause(command: string, exitCode: number, signalCode: string | null): string {
|
|
104
|
+
const signal = engineSignal(exitCode, signalCode);
|
|
105
|
+
return signal ? `kesha-engine ${command} was killed by ${signal}` : `kesha-engine ${command} exited with code ${exitCode}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
/** Spawns the engine (or a sidecar) as its own process tree and waits for it; the tree is unregistered however the run ends. */
|
|
87
109
|
export async function runEngineProcess<T = undefined>(
|
|
88
110
|
binPath: string,
|
package/src/engine-install.ts
CHANGED
|
@@ -14,8 +14,8 @@ import {
|
|
|
14
14
|
targetKey,
|
|
15
15
|
} from "./engine-targets";
|
|
16
16
|
import { validateArgv } from "./engine/describe";
|
|
17
|
-
import { engineFailure, KeshaError, offProtocolLine } from "./engine/events";
|
|
18
|
-
import { runEngineProcess } from "./engine/spawn";
|
|
17
|
+
import { engineFailure, KeshaError, offProtocolLine, SUPPORT_BUNDLE_HINT } from "./engine/events";
|
|
18
|
+
import { engineExitCause, runEngineProcess } from "./engine/spawn";
|
|
19
19
|
import { acquireInstallLock } from "./install-lock";
|
|
20
20
|
import { log } from "./log";
|
|
21
21
|
import { engineVersion, injectedEnginePins } from "./package-info";
|
|
@@ -108,7 +108,12 @@ export function releaseChecksums(
|
|
|
108
108
|
return async (assetName) => {
|
|
109
109
|
if (version === pins?.version) {
|
|
110
110
|
const pinned = pins.sha256[assetName];
|
|
111
|
-
if (!pinned)
|
|
111
|
+
if (!pinned) {
|
|
112
|
+
throw new KeshaError("E_INTERNAL", `No pinned SHA-256 for ${assetName} of engine v${version}; this CLI build is incomplete`, {
|
|
113
|
+
exitCode: 1,
|
|
114
|
+
hint: `report it at https://github.com/${GITHUB_REPO}/issues`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
112
117
|
return { sha256: pinned, source: "its pinned SHA-256" };
|
|
113
118
|
}
|
|
114
119
|
sums ??= fetchSha256Sums(version, pins);
|
|
@@ -779,8 +784,11 @@ async function runEngineModelInstall(binPath: string, installArgs: string[]): Pr
|
|
|
779
784
|
}
|
|
780
785
|
if (events.error || events.invalid.length > 0) throw engineFailure("install", events, exitCode);
|
|
781
786
|
if (exitCode !== 0) {
|
|
782
|
-
|
|
783
|
-
|
|
787
|
+
throw new KeshaError("E_INTERNAL", `Failed to install models: ${engineExitCause("install", exitCode, run.signalCode)}`, {
|
|
788
|
+
exitCode: 1,
|
|
789
|
+
stderr: events.stderr.trim(),
|
|
790
|
+
hint: SUPPORT_BUNDLE_HINT,
|
|
791
|
+
});
|
|
784
792
|
}
|
|
785
793
|
}
|
|
786
794
|
|
package/src/engine.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { log } from "./log";
|
|
|
7
7
|
import { createLiveStatus } from "./progress";
|
|
8
8
|
import { defaultEngineBinPath, keshaCacheDir } from "./paths";
|
|
9
9
|
import { engineAbortError, interruptedRun } from "./process-tree";
|
|
10
|
-
import { runEngineProcess, spawnHint } from "./engine/spawn";
|
|
11
|
-
import { engineFailure, KeshaError, renderError, type ErrorEvent } from "./engine/events";
|
|
10
|
+
import { engineExitCause, engineSignal, runEngineProcess, spawnHint } from "./engine/spawn";
|
|
11
|
+
import { engineFailure, KeshaError, quoteInvalidLine, renderError, SUPPORT_BUNDLE_HINT, type ErrorEvent } from "./engine/events";
|
|
12
12
|
import { MIN_ENGINE_VERSION, predatesMinimumEngine, readInstalledEngineVersion } from "./engine-version-marker";
|
|
13
13
|
import {
|
|
14
14
|
describeToCapabilities,
|
|
@@ -90,6 +90,7 @@ interface EngineRun {
|
|
|
90
90
|
stdout: string;
|
|
91
91
|
stderr: string;
|
|
92
92
|
exitCode: number;
|
|
93
|
+
signalCode: string | null;
|
|
93
94
|
error: ErrorEvent | null;
|
|
94
95
|
invalid: string[];
|
|
95
96
|
}
|
|
@@ -113,7 +114,7 @@ async function runEngine(args: string[], opts: RunEngineOptions = {}): Promise<E
|
|
|
113
114
|
// #275 D4: warnings reach the user on success; on failure they travel inside the KeshaError.
|
|
114
115
|
if (run.exitCode === 0 && events.invalid.length === 0 && events.error === null && stderr.length > 0)
|
|
115
116
|
process.stderr.write(`${stderr}\n`);
|
|
116
|
-
return { stdout: run.stdout.trim(), stderr, exitCode: run.exitCode, error: events.error, invalid: events.invalid };
|
|
117
|
+
return { stdout: run.stdout.trim(), stderr, exitCode: run.exitCode, signalCode: run.signalCode, error: events.error, invalid: events.invalid };
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
/** The run failed and said so: a non-zero status, or an error event whatever the status. */
|
|
@@ -302,9 +303,10 @@ export function buildTranscribeArgs(
|
|
|
302
303
|
opts: TranscribeEngineOptions,
|
|
303
304
|
json = false,
|
|
304
305
|
): string[] {
|
|
305
|
-
const args = ["transcribe",
|
|
306
|
+
const args = ["transcribe", ...(json ? ["--json"] : []), ...vadArg(opts.vad)];
|
|
306
307
|
if (opts.itn) args.push("--itn");
|
|
307
308
|
if (json && opts.speakers) args.push("--speakers");
|
|
309
|
+
args.push("--", audioPath);
|
|
308
310
|
return args;
|
|
309
311
|
}
|
|
310
312
|
|
|
@@ -318,7 +320,7 @@ export function withVirtualMacHint(
|
|
|
318
320
|
platform: string = process.platform,
|
|
319
321
|
isVirtualMac: () => boolean = hostIsVirtualMac,
|
|
320
322
|
): KeshaError {
|
|
321
|
-
if (platform !== "darwin" ||
|
|
323
|
+
if (platform !== "darwin" || !err.message.includes("Swift bridge error") || !isVirtualMac()) return err;
|
|
322
324
|
return new KeshaError(err.code, err.message, {
|
|
323
325
|
exitCode: err.exitCode,
|
|
324
326
|
stderr: err.stderr,
|
|
@@ -327,10 +329,22 @@ export function withVirtualMacHint(
|
|
|
327
329
|
});
|
|
328
330
|
}
|
|
329
331
|
|
|
332
|
+
function transcribeFailure(command: string, run: EngineRun): KeshaError {
|
|
333
|
+
const failure = engineFailure(command, run, run.exitCode);
|
|
334
|
+
const signal = run.error === null && run.invalid.length === 0 ? engineSignal(run.exitCode, run.signalCode) : null;
|
|
335
|
+
if (!signal) return failure;
|
|
336
|
+
return new KeshaError("E_INTERNAL", `kesha-engine ${command} was killed by ${signal}`, {
|
|
337
|
+
exitCode: failure.exitCode,
|
|
338
|
+
stderr: failure.stderr,
|
|
339
|
+
origin: failure.origin,
|
|
340
|
+
hint: SUPPORT_BUNDLE_HINT,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
330
344
|
export async function transcribeEngine(audioPath: string, opts: TranscribeEngineOptions = {}): Promise<string> {
|
|
331
345
|
const args = await validatedArgs(buildTranscribeArgs(audioPath, opts), { signal: opts.signal });
|
|
332
346
|
const run = await runEngine(args, { signal: opts.signal, onProgressLine: opts.onProgressLine });
|
|
333
|
-
if (failed(run)) throw withVirtualMacHint(
|
|
347
|
+
if (failed(run)) throw withVirtualMacHint(transcribeFailure(args[0] ?? "", run));
|
|
334
348
|
return run.stdout;
|
|
335
349
|
}
|
|
336
350
|
|
|
@@ -388,12 +402,14 @@ export async function transcribeEngineWithSegments(
|
|
|
388
402
|
const args = await validatedArgs(buildTranscribeArgs(audioPath, opts, true), { signal: opts.signal });
|
|
389
403
|
if (opts.speakers) assertSpeakerModelsInstalled();
|
|
390
404
|
const run = await runEngine(args, { signal: opts.signal, onProgressLine: opts.onProgressLine });
|
|
391
|
-
if (failed(run)) throw withVirtualMacHint(
|
|
405
|
+
if (failed(run)) throw withVirtualMacHint(transcribeFailure(args[0] ?? "", run));
|
|
392
406
|
try {
|
|
393
407
|
return parseTranscriptionOutput(run.stdout);
|
|
394
408
|
} catch (err: unknown) {
|
|
395
409
|
const message = err instanceof Error ? err.message : String(err);
|
|
396
|
-
throw new KeshaError(err instanceof KeshaError ? err.code : "E_INTERNAL", `${message}: ${run.stdout}
|
|
410
|
+
throw new KeshaError(err instanceof KeshaError ? err.code : "E_INTERNAL", `${message}: ${quoteInvalidLine(run.stdout)}`, {
|
|
411
|
+
hint: SUPPORT_BUNDLE_HINT,
|
|
412
|
+
});
|
|
397
413
|
}
|
|
398
414
|
}
|
|
399
415
|
|
|
@@ -417,7 +433,7 @@ export type RecordTarget =
|
|
|
417
433
|
const SIGNALLED_LIVE_EXIT_CODES = new Set([130, 143]);
|
|
418
434
|
|
|
419
435
|
export function buildRecordArgs(target: RecordTarget, maxSeconds: number): string[] {
|
|
420
|
-
if (!target.live) return ["record",
|
|
436
|
+
if (!target.live) return ["record", `--out=${target.out}`, "--max-seconds", String(maxSeconds)];
|
|
421
437
|
const args = ["record", "--live", "--max-seconds", String(maxSeconds)];
|
|
422
438
|
if (target.autoStop) {
|
|
423
439
|
args.push(
|
|
@@ -511,7 +527,13 @@ export async function recordEngine(target: RecordTarget, maxSeconds: number): Pr
|
|
|
511
527
|
// so an error event beside that status is a real failure the signal must not excuse.
|
|
512
528
|
if (events.error || events.invalid.length > 0) throw withVirtualMacHint(engineFailure("record", events, exitCode));
|
|
513
529
|
// A silent non-zero exit named nothing, so it stays the operational 1 it has been since #1167.
|
|
514
|
-
if (!signalled && exitCode !== 0)
|
|
530
|
+
if (!signalled && exitCode !== 0) {
|
|
531
|
+
throw new KeshaError("E_INTERNAL", engineExitCause("record", exitCode, run.signalCode), {
|
|
532
|
+
exitCode: 1,
|
|
533
|
+
stderr: events.stderr.trim(),
|
|
534
|
+
hint: SUPPORT_BUNDLE_HINT,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
515
537
|
}
|
|
516
538
|
|
|
517
539
|
export function parseLangResult(stdout: string): LangDetectResult | null {
|
|
@@ -531,14 +553,13 @@ export async function detectAudioLanguageEngine(
|
|
|
531
553
|
opts: RunEngineOptions = {},
|
|
532
554
|
): Promise<LangDetectResult | null> {
|
|
533
555
|
if (!isEngineInstalled()) return null;
|
|
534
|
-
const run = await runEngine(["detect-lang", audioPath], opts);
|
|
556
|
+
const run = await runEngine(["detect-lang", "--", audioPath], opts);
|
|
535
557
|
// The tolerant form: a noisy onnxruntime warning must not blind a best-effort guess.
|
|
536
558
|
if (reportedFailure(run)) {
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
);
|
|
559
|
+
const crashed = !run.error && engineSignal(run.exitCode, run.signalCode) !== null;
|
|
560
|
+
const cause = run.error ? renderError(run.error) : engineExitCause("detect-lang", run.exitCode, run.signalCode);
|
|
561
|
+
const fix = crashed ? SUPPORT_BUNDLE_HINT : "run `kesha install` to reinstall the engine and its language-ID model.";
|
|
562
|
+
log.warn(`Audio language detection failed: ${cause}\n Fix: ${fix}`);
|
|
542
563
|
return null;
|
|
543
564
|
}
|
|
544
565
|
return parseLangResult(run.stdout);
|
package/src/format.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LANG_CONFIDENCE_FLOOR, routeLanguage } from "./language-routing";
|
|
1
|
+
import { LANG_CONFIDENCE_FLOOR, routeLanguage, unsupportedAudioLanguageWarning } from "./language-routing";
|
|
2
2
|
import type { TranscribeErrorRecord, TranscribeJsonOutput, TranscribeResult } from "./types";
|
|
3
3
|
|
|
4
4
|
const BELOW_FLOOR_NOTE = `below the ${LANG_CONFIDENCE_FLOOR} floor, ignored for lang`;
|
|
@@ -51,8 +51,9 @@ export function formatTranscriptOutput(results: TranscribeResult[]): string {
|
|
|
51
51
|
lines.push(`=== ${r.file} ===`);
|
|
52
52
|
}
|
|
53
53
|
lines.push(r.text);
|
|
54
|
-
const
|
|
55
|
-
const
|
|
54
|
+
const detected = unsupportedAudioLanguageWarning(r.audioLanguage, r.textLanguage) ? r.audioLanguage : (r.textLanguage ?? r.audioLanguage);
|
|
55
|
+
const lang = detected?.code || r.lang;
|
|
56
|
+
const confidence = detected?.confidence;
|
|
56
57
|
if (lang) lines.push(`[lang: ${lang}${confidence != null ? `, confidence: ${confidence.toFixed(2)}` : ""}]`);
|
|
57
58
|
return lines.join("\n");
|
|
58
59
|
})
|
package/src/install-hint.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// `--no-cache`) — they preselect modules in the interactive wizard rather than
|
|
8
8
|
// being install-only flags. Source of truth: `src/cli/init.ts::initCommand.args`.
|
|
9
9
|
export function installHint(...flags: string[]): string {
|
|
10
|
-
|
|
10
|
+
// `kesha init` refuses unless stdin and stdout are terminals (#1373), so a piped run gets `install` (#1380).
|
|
11
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY;
|
|
12
|
+
const verb = interactive ? "kesha init" : "kesha install";
|
|
11
13
|
return [verb, ...flags].join(" ");
|
|
12
14
|
}
|
package/src/language-routing.ts
CHANGED
|
@@ -5,6 +5,25 @@ import type { TextLangDetectResult } from "./types";
|
|
|
5
5
|
/** A guess scoring below this is kept in its raw field but never names the top-level `lang` (Exploratory S11-2, S11-4). */
|
|
6
6
|
export const LANG_CONFIDENCE_FLOOR = 0.5;
|
|
7
7
|
|
|
8
|
+
/** The speech-to-text languages in docs/languages.md; keep the two in step. */
|
|
9
|
+
const ASR_LANGUAGES = new Set([
|
|
10
|
+
"bg", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el", "hu", "it",
|
|
11
|
+
"lv", "lt", "mt", "pl", "pt", "ro", "ru", "sk", "sl", "es", "sv", "uk",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** Audio lang-id this sure of a non-ASR language outranks text detection on the transcript (#1463: zh audio 0.999 vs text 0.63). */
|
|
15
|
+
export const UNSUPPORTED_AUDIO_LANGUAGE_CONFIDENCE = 0.95;
|
|
16
|
+
|
|
17
|
+
export function unsupportedAudioLanguageWarning(
|
|
18
|
+
audioLanguage: LangDetectResult | undefined,
|
|
19
|
+
textLanguage: TextLangDetectResult | undefined,
|
|
20
|
+
): string | null {
|
|
21
|
+
if (!audioLanguage?.code || audioLanguage.confidence < UNSUPPORTED_AUDIO_LANGUAGE_CONFIDENCE) return null;
|
|
22
|
+
if (ASR_LANGUAGES.has(audioLanguage.code)) return null;
|
|
23
|
+
if (textLanguage && ASR_LANGUAGES.has(textLanguage.code) && textLanguage.confidence >= LANG_CONFIDENCE_FLOOR) return null;
|
|
24
|
+
return `warning: audio language "${audioLanguage.code}" is not one kesha can transcribe; the text is unreliable (supported: docs/languages.md)`;
|
|
25
|
+
}
|
|
26
|
+
|
|
8
27
|
export type LangRouteSource = "engine" | "tinyld" | "audio";
|
|
9
28
|
|
|
10
29
|
export interface LanguageRoute {
|
|
@@ -14,7 +33,8 @@ export interface LanguageRoute {
|
|
|
14
33
|
}
|
|
15
34
|
|
|
16
35
|
/**
|
|
17
|
-
* Picks the top-level `lang` from what the detectors returned:
|
|
36
|
+
* Picks the top-level `lang` from what the detectors returned: confident audio in a language outside the ASR set,
|
|
37
|
+
* else the Engine's text result as-is,
|
|
18
38
|
* else a `tinyld` guess that clears `LANG_CONFIDENCE_FLOOR`, else audio that clears it, else "".
|
|
19
39
|
*/
|
|
20
40
|
export function routeLanguage(input: {
|
|
@@ -28,6 +48,9 @@ export function routeLanguage(input: {
|
|
|
28
48
|
textLanguage.confidence < LANG_CONFIDENCE_FLOOR;
|
|
29
49
|
const audioBelowFloor = audioLanguage !== undefined && audioLanguage.confidence < LANG_CONFIDENCE_FLOOR;
|
|
30
50
|
const belowFloor = { audio: audioBelowFloor, text: textBelowFloor };
|
|
51
|
+
if (audioLanguage && unsupportedAudioLanguageWarning(audioLanguage, textLanguage)) {
|
|
52
|
+
return { lang: audioLanguage.code, source: "audio", belowFloor };
|
|
53
|
+
}
|
|
31
54
|
if (textLanguage?.code && !textBelowFloor) {
|
|
32
55
|
return { lang: textLanguage.code, source: textLanguage.source, belowFloor };
|
|
33
56
|
}
|
package/src/mcp/tools.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
|
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
3
|
import { KeshaError } from "../engine/events";
|
|
4
4
|
import { installHint } from "../install-hint";
|
|
5
|
+
import { PATH_ERRNOS } from "../path-errnos";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { chmodSync, existsSync, readFileSync, statSync } from "fs";
|
|
7
8
|
import { basename, isAbsolute, join } from "path";
|
|
@@ -29,7 +30,11 @@ export function registerTools(server: McpServer): void {
|
|
|
29
30
|
try {
|
|
30
31
|
bytes = readFileSync(filePath);
|
|
31
32
|
} catch {
|
|
32
|
-
|
|
33
|
+
// The SDK sends only `message`, so the code and hint go inside it.
|
|
34
|
+
const coded = new KeshaError("E_INPUT_NOT_FOUND", `synthesized-audio: file not found or already swept: ${name}`, {
|
|
35
|
+
hint: "synthesized files are deleted after 24 hours; call synthesize_speech again.",
|
|
36
|
+
});
|
|
37
|
+
throw new Error(errorMessage(coded));
|
|
33
38
|
}
|
|
34
39
|
return { contents: [{ uri: uri.href, mimeType: mimeForExt(name), blob: bytes.toString("base64") }] };
|
|
35
40
|
},
|
|
@@ -59,13 +64,24 @@ export function registerTools(server: McpServer): void {
|
|
|
59
64
|
},
|
|
60
65
|
async ({ text, voice, rate, format }, extra) => {
|
|
61
66
|
if (extra.signal?.aborted) {
|
|
62
|
-
return { isError: true, content: [{ type: "text" as const, text: "request cancelled" }] };
|
|
67
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(new KeshaError("E_INTERRUPTED", "request cancelled")) }] };
|
|
63
68
|
}
|
|
64
69
|
if (rate !== undefined && !(rate >= 0.5 && rate <= 2.0)) {
|
|
65
|
-
|
|
70
|
+
const err = new KeshaError("E_INVALID_ARG", `rate ${rate} out of range (0.5-2.0)`);
|
|
71
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
|
|
66
72
|
}
|
|
67
73
|
const fmt: SayFormat = format ?? "wav";
|
|
68
|
-
|
|
74
|
+
let outPath: string;
|
|
75
|
+
try {
|
|
76
|
+
outPath = allocAudioPath(fmt);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const errno = (err as NodeJS.ErrnoException).code ?? "";
|
|
79
|
+
const why = PATH_ERRNOS[errno];
|
|
80
|
+
const coded = new KeshaError(why ? "E_INVALID_ARG" : "E_INTERNAL", `cannot create the audio folder ${audioDir()}: ${why ? `${why} (${errno})` : errorMessage(err)}`, {
|
|
81
|
+
hint: "set KESHA_HOME to a directory you can write to.",
|
|
82
|
+
});
|
|
83
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(coded) }] };
|
|
84
|
+
}
|
|
69
85
|
try {
|
|
70
86
|
// An MCP caller has no stderr to read: the voice it is told is its only record (#942).
|
|
71
87
|
const resolvedVoice = (await resolveSayVoice(voice, undefined, text, { signal: extra.signal })) ?? DEFAULT_VOICE_ID;
|
|
@@ -121,7 +137,7 @@ export function registerTools(server: McpServer): void {
|
|
|
121
137
|
},
|
|
122
138
|
async ({ path, timestamps }, extra) => {
|
|
123
139
|
if (extra.signal?.aborted) {
|
|
124
|
-
return { isError: true, content: [{ type: "text" as const, text: "request cancelled" }] };
|
|
140
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(new KeshaError("E_INTERRUPTED", "request cancelled")) }] };
|
|
125
141
|
}
|
|
126
142
|
if (!existsSync(path)) {
|
|
127
143
|
const hint = isAbsolute(path)
|
|
@@ -172,7 +188,7 @@ export function registerTools(server: McpServer): void {
|
|
|
172
188
|
structuredContent: { voices },
|
|
173
189
|
};
|
|
174
190
|
} catch (err) {
|
|
175
|
-
return { isError: true, content: [{ type: "text" as const, text:
|
|
191
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
|
|
176
192
|
}
|
|
177
193
|
},
|
|
178
194
|
);
|
|
@@ -202,7 +218,7 @@ export function registerTools(server: McpServer): void {
|
|
|
202
218
|
structuredContent: { languages },
|
|
203
219
|
};
|
|
204
220
|
} catch (err) {
|
|
205
|
-
return { isError: true, content: [{ type: "text" as const, text:
|
|
221
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
|
|
206
222
|
}
|
|
207
223
|
},
|
|
208
224
|
);
|
package/src/progress.ts
CHANGED
|
@@ -36,12 +36,28 @@ export function estimatePercent(elapsedMs: number, estimatedTotalMs: number): nu
|
|
|
36
36
|
return Math.max(1, Math.min(99, Math.floor((elapsedMs / targetMs) * 99)));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Whatever was painted on the row before, by this bar or another, would show past a shorter frame (#1440).
|
|
40
|
+
const ERASE_TO_EOL = "\x1b[K";
|
|
41
|
+
|
|
42
|
+
/** Cuts `text` to `max` terminal columns, ending in `…` when cut; wide (CJK) characters count as two. */
|
|
43
|
+
function fitWidth(text: string, max: number): string {
|
|
44
|
+
if (Bun.stringWidth(text) <= max) return text;
|
|
45
|
+
if (max <= 0) return "";
|
|
46
|
+
let out = "";
|
|
47
|
+
for (const { segment } of new Intl.Segmenter().segment(text)) {
|
|
48
|
+
if (Bun.stringWidth(`${out}${segment}…`) > max) break;
|
|
49
|
+
out += segment;
|
|
50
|
+
}
|
|
51
|
+
return `${out}…`;
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
export function createPercentProgress(
|
|
40
55
|
label: string,
|
|
41
56
|
options: { estimatedTotalMs?: number; intervalMs?: number } = {},
|
|
42
57
|
): {
|
|
43
58
|
finish(finalLabel: string): void;
|
|
44
59
|
interrupt(writeLine: () => void): void;
|
|
60
|
+
status(line: string): void;
|
|
45
61
|
stop(): void;
|
|
46
62
|
} {
|
|
47
63
|
const isTTY = process.stderr.isTTY;
|
|
@@ -55,6 +71,9 @@ export function createPercentProgress(
|
|
|
55
71
|
interrupt(writeLine: () => void) {
|
|
56
72
|
writeLine();
|
|
57
73
|
},
|
|
74
|
+
status(line: string) {
|
|
75
|
+
process.stderr.write(`${line}\n`);
|
|
76
|
+
},
|
|
58
77
|
stop() {},
|
|
59
78
|
};
|
|
60
79
|
}
|
|
@@ -62,18 +81,23 @@ export function createPercentProgress(
|
|
|
62
81
|
const intervalMs = options.intervalMs ?? 250;
|
|
63
82
|
const estimatedTotalMs = options.estimatedTotalMs ?? DEFAULT_ESTIMATED_PROGRESS_MS;
|
|
64
83
|
const startedAt = performance.now();
|
|
65
|
-
let
|
|
84
|
+
let painted = false;
|
|
66
85
|
let timer: Timer | undefined;
|
|
67
86
|
let stopped = false;
|
|
68
87
|
let firstRender = true;
|
|
88
|
+
let statusText = "";
|
|
69
89
|
|
|
70
90
|
const render = () => {
|
|
71
91
|
if (stopped) return;
|
|
72
92
|
const percent = firstRender ? 0 : estimatePercent(performance.now() - startedAt, estimatedTotalMs);
|
|
73
93
|
firstRender = false;
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
process.stderr.
|
|
94
|
+
const rest = formatPercentProgress("", percent) + (statusText ? ` ${statusText}` : "");
|
|
95
|
+
// A row wider than the terminal wraps, and `\r` then repaints only its last part; the file name gives way first.
|
|
96
|
+
const columns = process.stderr.columns;
|
|
97
|
+
const name = columns ? fitWidth(label, columns - 1 - Bun.stringWidth(rest)) : label;
|
|
98
|
+
const line = columns ? fitWidth(name + rest, columns - 1) : name + rest;
|
|
99
|
+
painted = true;
|
|
100
|
+
process.stderr.write(`\r${line}${ERASE_TO_EOL}`);
|
|
77
101
|
};
|
|
78
102
|
|
|
79
103
|
const clearLine = (stopTimer: boolean) => {
|
|
@@ -82,9 +106,9 @@ export function createPercentProgress(
|
|
|
82
106
|
clearInterval(timer);
|
|
83
107
|
timer = undefined;
|
|
84
108
|
}
|
|
85
|
-
if (
|
|
86
|
-
process.stderr.write(`\r${
|
|
87
|
-
|
|
109
|
+
if (painted) {
|
|
110
|
+
process.stderr.write(`\r${ERASE_TO_EOL}`);
|
|
111
|
+
painted = false;
|
|
88
112
|
}
|
|
89
113
|
};
|
|
90
114
|
|
|
@@ -106,6 +130,14 @@ export function createPercentProgress(
|
|
|
106
130
|
render();
|
|
107
131
|
timer = setInterval(render, intervalMs);
|
|
108
132
|
},
|
|
133
|
+
status(line: string) {
|
|
134
|
+
if (stopped) {
|
|
135
|
+
process.stderr.write(`${line}\n`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
statusText = line;
|
|
139
|
+
render();
|
|
140
|
+
},
|
|
109
141
|
stop() {
|
|
110
142
|
clearLine(true);
|
|
111
143
|
},
|
|
@@ -119,11 +151,11 @@ export function createPercentProgress(
|
|
|
119
151
|
*/
|
|
120
152
|
export function createLiveStatus(): { update(line: string): void; clear(): void } {
|
|
121
153
|
const isTTY = process.stderr.isTTY && !log.quietEnabled;
|
|
122
|
-
let painted =
|
|
154
|
+
let painted = false;
|
|
123
155
|
const erase = () => {
|
|
124
|
-
if (painted
|
|
125
|
-
process.stderr.write(`\r${
|
|
126
|
-
painted =
|
|
156
|
+
if (!painted) return;
|
|
157
|
+
process.stderr.write(`\r${ERASE_TO_EOL}`);
|
|
158
|
+
painted = false;
|
|
127
159
|
};
|
|
128
160
|
// Ctrl-C exits through `process.exit` without unwinding the spawn's `finally` (src/process-tree.ts),
|
|
129
161
|
// so without this the painted row survives into the shell prompt.
|
|
@@ -131,13 +163,12 @@ export function createLiveStatus(): { update(line: string): void; clear(): void
|
|
|
131
163
|
return {
|
|
132
164
|
update(line: string) {
|
|
133
165
|
if (!isTTY) return;
|
|
134
|
-
if (painted
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
painted = line.length;
|
|
166
|
+
if (!painted) process.once("exit", onExit);
|
|
167
|
+
process.stderr.write(`\r${line}${ERASE_TO_EOL}`);
|
|
168
|
+
painted = true;
|
|
138
169
|
},
|
|
139
170
|
clear() {
|
|
140
|
-
if (painted
|
|
171
|
+
if (!painted) return;
|
|
141
172
|
erase();
|
|
142
173
|
process.removeListener("exit", onExit);
|
|
143
174
|
},
|
|
@@ -303,11 +334,11 @@ export function createProgressBar(label: string, totalBytes: number): {
|
|
|
303
334
|
if (pct === lastPct) return;
|
|
304
335
|
lastPct = pct;
|
|
305
336
|
const line = formatProgressBar(label, current, totalBytes);
|
|
306
|
-
process.stderr.write(`\r${line}`);
|
|
337
|
+
process.stderr.write(`\r${line}${ERASE_TO_EOL}`);
|
|
307
338
|
},
|
|
308
339
|
finish() {
|
|
309
340
|
const line = formatProgressBar(label, totalBytes, totalBytes);
|
|
310
|
-
process.stderr.write(`\r${line}\n`);
|
|
341
|
+
process.stderr.write(`\r${line}${ERASE_TO_EOL}\n`);
|
|
311
342
|
},
|
|
312
343
|
};
|
|
313
344
|
}
|
package/src/stats.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { KeshaError } from "./engine/events";
|
|
2
3
|
import { errorMessage } from "./error-utils";
|
|
3
4
|
import { humanBytes } from "./format";
|
|
4
5
|
import { existsSync, mkdirSync, statSync } from "fs";
|
|
@@ -11,6 +12,8 @@ import { resolveStatePaths } from "./state-paths";
|
|
|
11
12
|
const SCHEMA_VERSION = 1;
|
|
12
13
|
const MAX_ERROR_MESSAGE_CHARS = 300;
|
|
13
14
|
const DEFAULT_RETENTION_DAYS = 90;
|
|
15
|
+
// A Date reaches back only 100,000,000 days before 1970: a longer retention makes the cutoff an Invalid Date.
|
|
16
|
+
export const MAX_RETENTION_DAYS = 100_000_000;
|
|
14
17
|
const APP_VERSION = readPackageVersion();
|
|
15
18
|
|
|
16
19
|
export type StatsCommandName = "transcribe" | "say";
|
|
@@ -148,7 +151,9 @@ let warnedStatsWriteFailed = false;
|
|
|
148
151
|
|
|
149
152
|
function warnStatsWriteFailed(err: unknown): void {
|
|
150
153
|
if (!warnedStatsWriteFailed) {
|
|
151
|
-
log.warn(
|
|
154
|
+
log.warn(
|
|
155
|
+
`warning: failed to write Kesha Stats at ${resolveStatsDbPath()} (${errorMessage(err)}); continuing without stats for this event; fix that, or run \`kesha stats disable\` to stop recording.`,
|
|
156
|
+
);
|
|
152
157
|
warnedStatsWriteFailed = true;
|
|
153
158
|
}
|
|
154
159
|
const message = errorMessage(err);
|
|
@@ -175,9 +180,28 @@ export function createStatsRecorder(command: StatsCommandName): StatsRecorder {
|
|
|
175
180
|
|
|
176
181
|
/** Open the stats DB (creating it if needed), run fn, always close. */
|
|
177
182
|
function withStatsDb<T>(fn: (db: Database) => T): T {
|
|
178
|
-
const
|
|
183
|
+
const dbPath = resolveStatsDbPath();
|
|
184
|
+
let db: Database;
|
|
185
|
+
try {
|
|
186
|
+
db = openStatsDatabase(dbPath);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const code = (err as { code?: unknown }).code;
|
|
189
|
+
const corrupt = code === "SQLITE_NOTADB" || code === "SQLITE_CORRUPT";
|
|
190
|
+
throw new KeshaError("E_INTERNAL", `cannot open the Kesha Stats database ${dbPath}: ${errorMessage(err)}`, {
|
|
191
|
+
hint: corrupt
|
|
192
|
+
? `delete ${dbPath} to start over; Stats stays off until \`kesha stats enable\` creates a new one.`
|
|
193
|
+
: `check that ${dbPath} and its folder can be read and written, or point KESHA_STATS_DB at another file.`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
179
196
|
try {
|
|
180
197
|
return fn(db);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const busy = String((err as { code?: unknown }).code ?? "").startsWith("SQLITE_BUSY");
|
|
200
|
+
throw new KeshaError("E_INTERNAL", `cannot read or write the Kesha Stats database ${dbPath}: ${errorMessage(err)}`, {
|
|
201
|
+
hint: busy
|
|
202
|
+
? "another kesha command is writing to it; retry when that command finishes."
|
|
203
|
+
: `check that ${dbPath} and its folder can be written and that its disk has free space, or point KESHA_STATS_DB at another file.`,
|
|
204
|
+
});
|
|
181
205
|
} finally {
|
|
182
206
|
db.close();
|
|
183
207
|
}
|
|
@@ -958,7 +982,7 @@ function parseRetentionSetting(raw: string): number | null {
|
|
|
958
982
|
const normalized = raw.trim().toLowerCase();
|
|
959
983
|
if (normalized === "off" || normalized === "none" || normalized === "never") return null;
|
|
960
984
|
const days = Number(normalized);
|
|
961
|
-
if (Number.isInteger(days) && days >= 1) return days;
|
|
985
|
+
if (Number.isInteger(days) && days >= 1 && days <= MAX_RETENTION_DAYS) return days;
|
|
962
986
|
return DEFAULT_RETENTION_DAYS;
|
|
963
987
|
}
|
|
964
988
|
|
package/src/stdout-pipe.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { renderError } from "./engine/events";
|
|
1
2
|
import { errorMessage } from "./error-utils";
|
|
2
3
|
import { log } from "./log";
|
|
3
4
|
|
|
@@ -9,6 +10,7 @@ let guarded = false;
|
|
|
9
10
|
* try/catch around the write, so it escaped as an uncaught stack trace and turned a
|
|
10
11
|
* successful synthesis into exit 1 (#1001). EPIPE alone means the reader left; every other
|
|
11
12
|
* write failure still reports and exits non-zero.
|
|
13
|
+
* A stdout closed before launch (`>&-`) never errors here: Bun reopens fd 1 as /dev/null (#1471).
|
|
12
14
|
*/
|
|
13
15
|
export function guardStdoutWrites(): void {
|
|
14
16
|
if (guarded) return;
|
|
@@ -19,7 +21,11 @@ export function guardStdoutWrites(): void {
|
|
|
19
21
|
return;
|
|
20
22
|
}
|
|
21
23
|
log.error(
|
|
22
|
-
|
|
24
|
+
renderError({
|
|
25
|
+
code: "E_INTERNAL",
|
|
26
|
+
message: `failed writing to stdout: ${errorMessage(err)}`,
|
|
27
|
+
hint: "free disk space or check what stdout is redirected to.",
|
|
28
|
+
}),
|
|
23
29
|
);
|
|
24
30
|
process.exitCode = 1;
|
|
25
31
|
});
|
package/src/synth.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getDescribe, getEngineBinPath, isEngineInstalled } from "./engine";
|
|
2
|
-
import { runEngineProcess } from "./engine/spawn";
|
|
2
|
+
import { engineSignal, runEngineProcess } from "./engine/spawn";
|
|
3
3
|
import { validateArgv } from "./engine/describe";
|
|
4
4
|
import { engineFailure, exitCodeFor, KeshaError, type EventSinks } from "./engine/events";
|
|
5
5
|
import { installHint } from "./install-hint";
|
|
@@ -18,6 +18,7 @@ export type SayFormat = "wav" | "ogg-opus" | "flac";
|
|
|
18
18
|
export const SUPPORTED_SAMPLE_RATES = [8000, 12000, 16000, 24000, 48000] as const;
|
|
19
19
|
export type SupportedSampleRate = (typeof SUPPORTED_SAMPLE_RATES)[number];
|
|
20
20
|
export const MAX_TEXT_CHARS = 5000;
|
|
21
|
+
export const TEXT_TOO_LONG_HINT = `split the text into requests of at most ${MAX_TEXT_CHARS} characters`;
|
|
21
22
|
|
|
22
23
|
type SayBase = {
|
|
23
24
|
/**
|
|
@@ -57,7 +58,7 @@ export function buildSayArgs(o: SayOptions): string[] {
|
|
|
57
58
|
const args: string[] = ["say"];
|
|
58
59
|
if (o.voice) args.push("--voice", o.voice);
|
|
59
60
|
if (o.lang) args.push("--lang", o.lang);
|
|
60
|
-
if (o.out) args.push(
|
|
61
|
+
if (o.out) args.push(`--out=${o.out}`);
|
|
61
62
|
if (o.rate !== undefined && o.rate !== 1.0) args.push("--rate", String(o.rate));
|
|
62
63
|
if (o.ssml) args.push("--ssml");
|
|
63
64
|
if (o.format) args.push("--format", o.format);
|
|
@@ -68,23 +69,13 @@ export function buildSayArgs(o: SayOptions): string[] {
|
|
|
68
69
|
return args;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
/**
|
|
72
|
-
const FAULT_SIGNALS: Record<number, string> = { 4: "SIGILL", 6: "SIGABRT", 8: "SIGFPE", 11: "SIGSEGV" };
|
|
73
|
-
|
|
74
|
-
/** Explains a signal death, which leaves no error event; the wait status outranks Bun's signalCode (10 is SIGBUS on darwin, SIGUSR1 to Bun). */
|
|
72
|
+
/** Explains a signal death, which leaves no error event. */
|
|
75
73
|
export function engineCrashMessage(
|
|
76
74
|
exitCode: number,
|
|
77
75
|
signalCode: string | null,
|
|
78
76
|
platform: string = process.platform,
|
|
79
77
|
): string | null {
|
|
80
|
-
const
|
|
81
|
-
const sigbus = platform === "darwin" ? 10 : 7;
|
|
82
|
-
const signal =
|
|
83
|
-
number === undefined
|
|
84
|
-
? (signalCode ?? undefined)
|
|
85
|
-
: number === sigbus
|
|
86
|
-
? "SIGBUS"
|
|
87
|
-
: (FAULT_SIGNALS[number] ?? signalCode ?? undefined);
|
|
78
|
+
const signal = engineSignal(exitCode, signalCode, platform);
|
|
88
79
|
if (!signal) return null;
|
|
89
80
|
const base = `kesha-engine was killed by ${signal} and produced no audio`;
|
|
90
81
|
// Every physical Apple Silicon Mac has a Neural Engine; virtualised macOS has
|
|
@@ -102,6 +93,42 @@ export function engineCrashMessage(
|
|
|
102
93
|
return `${base}.`;
|
|
103
94
|
}
|
|
104
95
|
|
|
96
|
+
// ©, ® and ™ stay, emoji presentation (U+FE0F) or not: read aloud they still mean something.
|
|
97
|
+
const KEPT_SYMBOL = /([©®™])️/g;
|
|
98
|
+
const EMOJI = /\p{RGI_Emoji}|[\p{Extended_Pictographic}--[©®™]]/gv;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Emoji are decoration in a reply; a voice would read out their Unicode names (#1388).
|
|
102
|
+
* Emoji-only text, SSML tags aside, goes through unchanged, so the engine still refuses it as E_SCRIPT_UNSUPPORTED.
|
|
103
|
+
*/
|
|
104
|
+
export function dropEmoji(text: string, ssml = false): string {
|
|
105
|
+
const spoken = (ssml ? decodeCharRefs(text) : text).replace(KEPT_SYMBOL, "$1").replace(EMOJI, "");
|
|
106
|
+
const speech = ssml ? spoken.replace(CDATA, "$1").replace(/<[^>]*>/g, "") : spoken;
|
|
107
|
+
return speech.trim().length > 0 ? spoken : text;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const CDATA = /<!\[CDATA\[([\s\S]*?)\]\]>/g;
|
|
111
|
+
const NUMERIC_REF = /&#(?:x([0-9a-f]+)|(\d+));/gi;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The engine decodes SSML character references, so `😊` is an emoji too. Decoding them first lets the
|
|
115
|
+
* emoji filter see sequences spelled across references; CDATA keeps its text literal, and markup characters stay escaped.
|
|
116
|
+
*/
|
|
117
|
+
function decodeCharRefs(ssml: string): string {
|
|
118
|
+
return ssml
|
|
119
|
+
.split(/(<!\[CDATA\[[\s\S]*?\]\]>)/)
|
|
120
|
+
.map((part) =>
|
|
121
|
+
part.startsWith("<![CDATA[")
|
|
122
|
+
? part
|
|
123
|
+
: part.replace(NUMERIC_REF, (ref, hex?: string, dec?: string) => {
|
|
124
|
+
const cp = hex !== undefined ? parseInt(hex, 16) : Number(dec);
|
|
125
|
+
const plain = cp >= 0x20 && cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff) && !`<>&'"`.includes(String.fromCodePoint(cp));
|
|
126
|
+
return plain ? String.fromCodePoint(cp) : ref;
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
.join("");
|
|
130
|
+
}
|
|
131
|
+
|
|
105
132
|
/**
|
|
106
133
|
* The text contract both doors enforce, before anything that costs a subprocess.
|
|
107
134
|
* Throws `KeshaError`; the CLI runs it before voice routing, `say()` for programmatic callers.
|
|
@@ -112,7 +139,11 @@ export function validateSayText(text: string): void {
|
|
|
112
139
|
}
|
|
113
140
|
const chars = Array.from(text).length;
|
|
114
141
|
if (chars > MAX_TEXT_CHARS) {
|
|
115
|
-
throw new KeshaError("E_TEXT_TOO_LONG", `text exceeds ${MAX_TEXT_CHARS} chars (${chars})`, {
|
|
142
|
+
throw new KeshaError("E_TEXT_TOO_LONG", `text exceeds ${MAX_TEXT_CHARS} chars (${chars})`, {
|
|
143
|
+
exitCode: 5,
|
|
144
|
+
stderr: "",
|
|
145
|
+
hint: TEXT_TOO_LONG_HINT,
|
|
146
|
+
});
|
|
116
147
|
}
|
|
117
148
|
if (text.includes("\0")) {
|
|
118
149
|
throw new KeshaError("E_INVALID_ARG", "text contains a NUL byte", { exitCode: 2, stderr: "" });
|
|
@@ -133,7 +164,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
|
|
|
133
164
|
for (const warning of warnings) log.warn(warning);
|
|
134
165
|
const { exitCode, signalCode, aborted, stdout, events } = await runEngineProcess(getEngineBinPath(), args, {
|
|
135
166
|
stdio: ["pipe", "pipe", "pipe"],
|
|
136
|
-
stdin: text,
|
|
167
|
+
stdin: dropEmoji(text, opts.ssml),
|
|
137
168
|
readStdout: (stream) => new Response(stream).arrayBuffer(),
|
|
138
169
|
signal: opts.signal,
|
|
139
170
|
});
|
package/src/transcribe.ts
CHANGED
|
@@ -24,6 +24,19 @@ export function isDirectoryPath(path: string): boolean {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export const SPECIAL_FILE_HINT =
|
|
28
|
+
"kesha reads its input more than once, so a named pipe, /dev/stdin or <(...) cannot work; save the audio to a file and pass that.";
|
|
29
|
+
|
|
30
|
+
/** True when `path` exists and is neither a regular file nor a directory: the engine opens its input more than once, so a FIFO, `/dev/stdin` or `<(...)` hangs it (#1468). */
|
|
31
|
+
export function isSpecialFilePath(path: string): boolean {
|
|
32
|
+
try {
|
|
33
|
+
const st = statSync(path);
|
|
34
|
+
return !st.isFile() && !st.isDirectory();
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
export interface TranscribeOptions {
|
|
28
41
|
/** Silero VAD preprocessing selector. Defaults to `"auto"`. */
|
|
29
42
|
vad?: VadMode;
|
|
@@ -54,6 +67,9 @@ export function assertAudioFileArgument(audioPath: string): void {
|
|
|
54
67
|
if (isDirectoryPath(audioPath)) {
|
|
55
68
|
throw new KeshaError("E_INVALID_ARG", `${audioPath}: is a directory (expected an audio file)`);
|
|
56
69
|
}
|
|
70
|
+
if (isSpecialFilePath(audioPath)) {
|
|
71
|
+
throw new KeshaError("E_INVALID_ARG", `${audioPath}: is not a regular file (expected an audio file)`, { hint: SPECIAL_FILE_HINT });
|
|
72
|
+
}
|
|
57
73
|
}
|
|
58
74
|
|
|
59
75
|
/** The CLI's gate before any progress UI: the engine, its describe document, the request's flags, and the model files a request needs; the argv actually sent is validated again at the spawn. */
|