@drakulavich/kesha-voice-kit 2.3.0-beta.2 → 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 +2 -2
- package/completions/kesha.fish +1 -1
- package/completions/kesha.zsh +1 -1
- package/man/kesha.1 +1 -1
- package/package.json +8 -8
- package/src/cache-layout.ts +29 -6
- package/src/cli/dispatch.ts +5 -0
- package/src/cli/main.ts +49 -20
- package/src/cli/options.ts +5 -0
- package/src/cli/record.ts +2 -7
- package/src/cli/say.ts +19 -4
- package/src/diagnostic-log.ts +5 -5
- package/src/doctor.ts +8 -5
- package/src/engine-install.ts +24 -4
- package/src/engine.ts +12 -0
- package/src/format.ts +5 -5
- package/src/install-plan.ts +2 -2
- package/src/mcp/tools.ts +7 -7
- package/src/mcp/voices.ts +1 -0
- package/src/progress.ts +1 -1
- package/src/support-bundle.ts +13 -0
- package/src/synth.ts +3 -7
- package/src/transcribe.ts +44 -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">
|
|
@@ -127,7 +127,7 @@ kesha say "Hello" --format ogg-opus --out hi.ogg # OGG/Opus — messenger voice
|
|
|
127
127
|
kesha say "Hello" --format flac --out hi.flac # FLAC — lossless, plays in every browser incl. Safari/iOS
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
`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)**.
|
|
131
131
|
|
|
132
132
|
## Languages
|
|
133
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,18 +109,18 @@
|
|
|
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": "
|
|
114
|
+
"kesha-engine-darwin-arm64": "db444c1c0ccc368e72068f050807ab54975fb516055480b33c32d5b1f29c4e58",
|
|
115
|
+
"kesha-engine-linux-x64": "0ac69c63cc8e922aaaea5b6df221dd97f6add02d4d6fb09def2317c331019a89",
|
|
116
|
+
"kesha-engine-windows-x64.exe": "0ae3063b7d5ff00da7cd0f675cd557374d81ff94087787dc90d7c195f7347194",
|
|
117
117
|
"say-avspeech-darwin-arm64": "6c7e0708f27c1baf3d8013bfbeb6249a6c3acbb718b3d39081707c7a8aa22e5b",
|
|
118
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":
|
|
121
|
+
"kesha-engine-darwin-arm64": 73672784,
|
|
122
|
+
"kesha-engine-linux-x64": 67422216,
|
|
123
|
+
"kesha-engine-windows-x64.exe": 66576384,
|
|
124
124
|
"say-avspeech-darwin-arm64": 82896,
|
|
125
125
|
"kesha-textlang-darwin-arm64": 57488
|
|
126
126
|
}
|
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/dispatch.ts
CHANGED
|
@@ -183,6 +183,11 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
|
|
183
183
|
await showUsage(rawArgs.includes("--all") ? command : briefMainUsage(command));
|
|
184
184
|
return;
|
|
185
185
|
}
|
|
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
|
+
}
|
|
186
191
|
await runMain(reportThrows(command), { rawArgs, showUsage });
|
|
187
192
|
}
|
|
188
193
|
}
|
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";
|
|
@@ -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
|
|
@@ -321,8 +327,19 @@ async function processFile(
|
|
|
321
327
|
const { vadMode, timestamps, speakers, itn, wantsLangId, expectedLang, reportProgress } = options;
|
|
322
328
|
const { stats, diagnosticLog } = recorders;
|
|
323
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
|
+
|
|
324
341
|
if (!existsSync(file)) {
|
|
325
|
-
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) });
|
|
326
343
|
stats.recordError("input", err, err.code);
|
|
327
344
|
diagnosticLog.event("input.missing", {
|
|
328
345
|
command: "transcribe",
|
|
@@ -358,6 +375,13 @@ async function processFile(
|
|
|
358
375
|
return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
|
|
359
376
|
}
|
|
360
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
|
+
|
|
361
385
|
const inputArtifact = artifactFromFile(file, "input_audio");
|
|
362
386
|
if (inputArtifact) {
|
|
363
387
|
stats.recordArtifact(inputArtifact);
|
|
@@ -526,20 +550,25 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
|
|
|
526
550
|
" kesha init Guided first-time setup.\n" +
|
|
527
551
|
"\n" +
|
|
528
552
|
"Commands:\n" +
|
|
529
|
-
" completions
|
|
530
|
-
" doctor
|
|
531
|
-
" init
|
|
532
|
-
" install
|
|
533
|
-
" logs
|
|
534
|
-
" manpage
|
|
535
|
-
" mcp
|
|
536
|
-
" record
|
|
537
|
-
" status
|
|
538
|
-
" say
|
|
539
|
-
" stats
|
|
540
|
-
" 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",
|
|
541
565
|
},
|
|
542
566
|
args: {
|
|
567
|
+
audio_file: {
|
|
568
|
+
type: "positional",
|
|
569
|
+
required: false,
|
|
570
|
+
description: "Audio file(s) to transcribe",
|
|
571
|
+
},
|
|
543
572
|
json: {
|
|
544
573
|
type: "boolean",
|
|
545
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,14 +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
|
-
|
|
6
|
+
engineMissingError,
|
|
7
7
|
isEngineInstalled,
|
|
8
8
|
recordEngine,
|
|
9
9
|
validateRecordRequest,
|
|
10
10
|
type LiveAutoStopOptions,
|
|
11
11
|
type RecordTarget,
|
|
12
12
|
} from "../engine";
|
|
13
|
-
import { installHint } from "../install-hint";
|
|
14
13
|
import { log } from "../log";
|
|
15
14
|
import { getPendingSignalExitCode, waitForPendingSignalCleanup } from "../process-tree";
|
|
16
15
|
|
|
@@ -143,11 +142,7 @@ function resolveThreshold(
|
|
|
143
142
|
|
|
144
143
|
/** Mirrors `src/transcribe.ts`'s "no transcription backend" guard message, worded for recording. */
|
|
145
144
|
export function noRecordingBackendMessage(): string {
|
|
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
|
-
);
|
|
145
|
+
return errorMessage(engineMissingError("recording"));
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
export const recordCommand = defineCommand({
|
package/src/cli/say.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
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,
|
|
@@ -130,6 +132,16 @@ function checkOpusOnlyFlags(
|
|
|
130
132
|
return "--bitrate and --sample-rate are only valid with --format ogg-opus";
|
|
131
133
|
}
|
|
132
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
|
+
|
|
133
145
|
export type SayFlagArgs = {
|
|
134
146
|
voice?: unknown;
|
|
135
147
|
lang?: unknown;
|
|
@@ -181,6 +193,8 @@ export function resolveSayFlags(args: SayFlagArgs): ResolvedSayFlags {
|
|
|
181
193
|
const out = typeof args.out === "string" ? args.out : undefined;
|
|
182
194
|
const opusOnlyError = checkOpusOnlyFlags(bitrate.value, sampleRate.value, format.value, out);
|
|
183
195
|
if (opusOnlyError) return { ok: false, error: opusOnlyError };
|
|
196
|
+
const outExtError = checkOutExtension(out);
|
|
197
|
+
if (outExtError) return { ok: false, error: outExtError };
|
|
184
198
|
|
|
185
199
|
return {
|
|
186
200
|
ok: true,
|
|
@@ -255,6 +269,7 @@ async function synthesizeAndEmit(
|
|
|
255
269
|
const { stats } = session;
|
|
256
270
|
try {
|
|
257
271
|
const startedAt = performance.now();
|
|
272
|
+
if (!isEngineInstalled()) throw engineMissingError("speech synthesis", "--tts");
|
|
258
273
|
if (opts.out) {
|
|
259
274
|
const voiceLabel = opts.voice ?? "default voice";
|
|
260
275
|
log.status(`Synthesizing ${voiceLabel} -> ${opts.out}...`);
|
|
@@ -312,7 +327,7 @@ export const sayCommand = defineCommand({
|
|
|
312
327
|
},
|
|
313
328
|
out: { type: "string", description: "Write audio to file instead of stdout" },
|
|
314
329
|
rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
|
|
315
|
-
"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" },
|
|
316
331
|
ssml: {
|
|
317
332
|
type: "boolean",
|
|
318
333
|
description: "Parse input as SSML (supports <speak>, <break>; strips unknown tags)",
|
|
@@ -376,7 +391,7 @@ export const sayCommand = defineCommand({
|
|
|
376
391
|
process.exit(2);
|
|
377
392
|
}
|
|
378
393
|
|
|
379
|
-
const positionals = (args
|
|
394
|
+
const positionals = positionalArgs(rawArgs, sayCommand.args as ArgsDef);
|
|
380
395
|
const inlineText =
|
|
381
396
|
positionals.length > 0 ? positionals.join(" ") : typeof args.text === "string" ? args.text : undefined;
|
|
382
397
|
const stdinIsTty = (process.stdin as { isTTY?: boolean }).isTTY;
|
package/src/diagnostic-log.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { packageVersion } from "./package-info";
|
|
|
21
21
|
|
|
22
22
|
const ACTIVE_LOG_FILE = "kesha.ndjson";
|
|
23
23
|
const STATE_FILE = "diagnostic-logs.json";
|
|
24
|
-
const DEFAULT_MAX_BYTES =
|
|
24
|
+
const DEFAULT_MAX_BYTES = 10_000_000;
|
|
25
25
|
const DEFAULT_RETAIN = 5;
|
|
26
26
|
const DEFAULT_TAIL_BYTES = 64 * 1024;
|
|
27
27
|
const SAFE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
@@ -321,12 +321,12 @@ function rotateIfNeeded(activePath: string, nextBytes: number, maxBytes: number,
|
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
export function humanBytes(bytes: number): string {
|
|
324
|
-
if (bytes <
|
|
324
|
+
if (bytes < 1000) return `${bytes} B`;
|
|
325
325
|
const units = ["KB", "MB", "GB", "TB"];
|
|
326
|
-
let value = bytes /
|
|
326
|
+
let value = bytes / 1000;
|
|
327
327
|
let unit = units[0];
|
|
328
|
-
for (let i = 1; i < units.length && value >=
|
|
329
|
-
value /=
|
|
328
|
+
for (let i = 1; i < units.length && value >= 1000; i++) {
|
|
329
|
+
value /= 1000;
|
|
330
330
|
unit = units[i];
|
|
331
331
|
}
|
|
332
332
|
return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`;
|
package/src/doctor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
-
import { cacheComponents, cacheTotalBytes } from "./cache-layout";
|
|
2
|
+
import { cacheComponents, cacheTotalBytes, KOKORO_ONNX_UNUSED_NOTE } from "./cache-layout";
|
|
3
3
|
import { errorMessage } from "./error-utils";
|
|
4
4
|
import { humanBytes } from "./format";
|
|
5
5
|
import { dirname, join } from "path";
|
|
@@ -17,6 +17,7 @@ import { engineVersion, packageName, packageVersion } from "./package-info";
|
|
|
17
17
|
import { getStatsStatus, type StatsStatus } from "./stats";
|
|
18
18
|
import { kokoroAneComponents, kokoroTtsLanguages } from "./kokoro-ane";
|
|
19
19
|
import { isCoremlBackend } from "./fluid-asr-cache";
|
|
20
|
+
import { isDarwinArm64 } from "./engine-targets";
|
|
20
21
|
import {
|
|
21
22
|
fluidExternalRoots,
|
|
22
23
|
fluidExternalTotalBytes,
|
|
@@ -36,6 +37,7 @@ const KNOWN_ENV_KEYS = [
|
|
|
36
37
|
"KESHA_CACHE_DIR",
|
|
37
38
|
"KESHA_MODEL_MIRROR",
|
|
38
39
|
"KESHA_STATS_DB",
|
|
40
|
+
"KESHA_LOG_DIR",
|
|
39
41
|
"KESHA_DEBUG",
|
|
40
42
|
"KESHA_KOKORO_COMPUTE_UNITS",
|
|
41
43
|
"KESHA_DIARIZE_COMPUTE_UNITS",
|
|
@@ -58,6 +60,7 @@ interface PathSummary {
|
|
|
58
60
|
|
|
59
61
|
interface CacheComponent extends PathSummary {
|
|
60
62
|
label: string;
|
|
63
|
+
note?: string;
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
interface OptionalComponent extends PathSummary {
|
|
@@ -318,12 +321,12 @@ async function collectOptionalComponents(
|
|
|
318
321
|
},
|
|
319
322
|
{
|
|
320
323
|
name: "TTS (Kokoro)",
|
|
321
|
-
note: "enabled with `kesha install --tts`",
|
|
324
|
+
note: isDarwinArm64() ? KOKORO_ONNX_UNUSED_NOTE : "enabled with `kesha install --tts`",
|
|
322
325
|
...pathSummary(join(cache, "models/kokoro-82m")),
|
|
323
326
|
},
|
|
324
327
|
{
|
|
325
328
|
name: "TTS (Vosk RU)",
|
|
326
|
-
note: "enabled with `kesha install --tts`",
|
|
329
|
+
note: "enabled with `kesha install --tts ru`",
|
|
327
330
|
...pathSummary(join(cache, "models/vosk-ru")),
|
|
328
331
|
},
|
|
329
332
|
...kokoroAneComponents({ homeDir, cacheRoot: cache }).map((c) => ({
|
|
@@ -395,7 +398,7 @@ function collectDiagnosticLogs(redact: boolean): DoctorReport["diagnosticLogs"]
|
|
|
395
398
|
rotatedFiles: [],
|
|
396
399
|
totalSizeBytes: 0,
|
|
397
400
|
mode: "retain-on-failure",
|
|
398
|
-
maxBytes:
|
|
401
|
+
maxBytes: 10_000_000,
|
|
399
402
|
retain: 5,
|
|
400
403
|
error:
|
|
401
404
|
redactString("diagnosticLogsError", errorMessage(err), redact) ??
|
|
@@ -501,7 +504,7 @@ function formatCapabilities(engine: DoctorReport["engine"]): string {
|
|
|
501
504
|
function formatCacheSection(cache: DoctorReport["cache"]): string[] {
|
|
502
505
|
const header = `Cache: ${cache.path} (${cache.exists ? humanBytes(cache.totalBytes) : "missing"})`;
|
|
503
506
|
const rows = cache.components.map(
|
|
504
|
-
(c) => ` ${c.label}: ${c.exists ? humanBytes(c.sizeBytes) : "missing"}`,
|
|
507
|
+
(c) => ` ${c.label}: ${c.exists ? humanBytes(c.sizeBytes) : "missing"}${c.note ? ` - ${c.note}` : ""}`,
|
|
505
508
|
);
|
|
506
509
|
return [header, ...rows, ...formatExternalRootsSection(cache)];
|
|
507
510
|
}
|
package/src/engine-install.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { dirname, join, resolve, sep } from "path";
|
|
2
2
|
import { errorMessage } from "./error-utils";
|
|
3
3
|
import { homedir, tmpdir } from "os";
|
|
4
|
-
import { existsSync, mkdirSync, chmodSync, accessSync, constants, rmSync } from "fs";
|
|
4
|
+
import { existsSync, mkdirSync, chmodSync, accessSync, constants, readFileSync, rmSync, writeFileSync } from "fs";
|
|
5
5
|
import { getDescribe, getEngineBinPath } from "./engine";
|
|
6
6
|
import { engineFunctionalHealth, probeExecutable, readExecutableVersion } from "./engine-health";
|
|
7
7
|
import {
|
|
@@ -159,6 +159,26 @@ async function rejectMismatchedDownload(
|
|
|
159
159
|
);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** `<pin> <sha256 after codesign>` for a download that matched its pin before `darwinTrustBinary` rewrote it. */
|
|
163
|
+
function signedShaPath(path: string): string {
|
|
164
|
+
return `${path}.signed-sha256`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readSignedSha(path: string): string | null {
|
|
168
|
+
try {
|
|
169
|
+
return readFileSync(signedShaPath(path), "utf8").trim();
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function trustVerifiedDownload(path: string, displayName: string, pinnedSha: string): Promise<void> {
|
|
176
|
+
darwinTrustBinary(path, displayName);
|
|
177
|
+
if (process.platform !== "darwin") return;
|
|
178
|
+
rmSync(signedShaPath(path), { recursive: true, force: true });
|
|
179
|
+
writeFileSync(signedShaPath(path), `${pinnedSha} ${await sha256OfFile(path)}\n`);
|
|
180
|
+
}
|
|
181
|
+
|
|
162
182
|
/**
|
|
163
183
|
* Whether a cached asset of the pinned release still hashes to its pin; one that does not is
|
|
164
184
|
* deleted so the verified download replaces it, or refused when its directory is read-only.
|
|
@@ -180,7 +200,7 @@ async function cachedAssetMatchesPin(
|
|
|
180
200
|
} catch (e) {
|
|
181
201
|
throw canRepair ? e : unreadableInReadOnlyDir(path, e);
|
|
182
202
|
}
|
|
183
|
-
if (actual === expected.sha256) return true;
|
|
203
|
+
if (actual === expected.sha256 || readSignedSha(path) === `${expected.sha256} ${actual}`) return true;
|
|
184
204
|
const mismatch =
|
|
185
205
|
`Installed ${what} ${assetName} at ${path} does not match ${expected.source} ` +
|
|
186
206
|
`(expected sha256 ${expected.sha256}, got ${actual})`;
|
|
@@ -369,7 +389,7 @@ async function downloadSidecar(
|
|
|
369
389
|
return;
|
|
370
390
|
}
|
|
371
391
|
chmodSync(sidecarPath, 0o755);
|
|
372
|
-
|
|
392
|
+
await trustVerifiedDownload(sidecarPath, spec.displayName, expected.sha256);
|
|
373
393
|
log.progress(`${spec.displayName} installed (${spec.availableHint}).`);
|
|
374
394
|
} catch (e) {
|
|
375
395
|
log.warn(
|
|
@@ -674,7 +694,7 @@ async function fetchEngineBinary(
|
|
|
674
694
|
throw new KeshaError("E_CACHE_CORRUPT", refusal, { hint: MISMATCH_HINT });
|
|
675
695
|
}
|
|
676
696
|
chmodSync(binPath, 0o755);
|
|
677
|
-
|
|
697
|
+
await trustVerifiedDownload(binPath, "kesha-engine binary", expected.sha256);
|
|
678
698
|
// Marker last: writing it first sends the retry down the cacheValid branch, which never waits.
|
|
679
699
|
await waitUntilSpawnable(binPath);
|
|
680
700
|
writeInstalledEngineVersion(binPath, version);
|
package/src/engine.ts
CHANGED
|
@@ -7,6 +7,7 @@ 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 { resolveStatePaths } from "./state-paths";
|
|
10
11
|
import { engineExitCause, engineSignal, runEngineProcess, spawnHint } from "./engine/spawn";
|
|
11
12
|
import { engineFailure, KeshaError, quoteInvalidLine, renderError, SUPPORT_BUNDLE_HINT, type ErrorEvent } from "./engine/events";
|
|
12
13
|
import { MIN_ENGINE_VERSION, predatesMinimumEngine, readInstalledEngineVersion } from "./engine-version-marker";
|
|
@@ -76,6 +77,17 @@ export function isEngineInstalled(): boolean {
|
|
|
76
77
|
return existsSync(getEngineBinPath());
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/** The E_ENGINE_SPAWN for a missing engine: it names the path looked at and the variable that chose it. */
|
|
81
|
+
export function engineMissingError(backend: string, ...installFlags: string[]): KeshaError {
|
|
82
|
+
const cacheSource = resolveStatePaths().cacheDir.source;
|
|
83
|
+
const hint = process.env.KESHA_ENGINE_BIN
|
|
84
|
+
? spawnHint()
|
|
85
|
+
: cacheSource === "default"
|
|
86
|
+
? `bun add -g @drakulavich/kesha-voice-kit, then ${installHint(...installFlags)}`
|
|
87
|
+
: `${cacheSource} is set; run \`${installHint(...installFlags)}\` to install there, or unset it`;
|
|
88
|
+
return new KeshaError("E_ENGINE_SPAWN", `No ${backend} backend is installed: no kesha-engine at ${getEngineBinPath()}`, { hint });
|
|
89
|
+
}
|
|
90
|
+
|
|
79
91
|
export interface RunEngineOptions {
|
|
80
92
|
signal?: AbortSignal;
|
|
81
93
|
/** Text for the subprocess's stdin. Nothing a user typed may become an argv element: a NUL byte or a megabyte of it makes the spawn itself fail (#T1-1). */
|
package/src/format.ts
CHANGED
|
@@ -69,14 +69,14 @@ export function formatJsonOutput(
|
|
|
69
69
|
return JSON.stringify(payload, null, 2) + "\n";
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/** Render a byte count as a human-readable size (e.g. "1.5 MB"). */
|
|
72
|
+
/** Render a byte count as a human-readable decimal size (e.g. "1.5 MB" for 1,500,000 bytes). */
|
|
73
73
|
export function humanBytes(bytes: number): string {
|
|
74
|
-
if (bytes <
|
|
74
|
+
if (bytes < 1000) return `${bytes} B`;
|
|
75
75
|
const units = ["KB", "MB", "GB", "TB"];
|
|
76
|
-
let n = bytes /
|
|
76
|
+
let n = bytes / 1000;
|
|
77
77
|
let i = 0;
|
|
78
|
-
while (n >=
|
|
79
|
-
n /=
|
|
78
|
+
while (n >= 1000 && i < units.length - 1) {
|
|
79
|
+
n /= 1000;
|
|
80
80
|
i++;
|
|
81
81
|
}
|
|
82
82
|
return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
|
package/src/install-plan.ts
CHANGED
|
@@ -116,7 +116,7 @@ function releaseAssetSize(assetName: string, version: string): number | null {
|
|
|
116
116
|
function filesCached(cacheRoot: string, files: PlanFile[]): boolean {
|
|
117
117
|
return files.every((file) => {
|
|
118
118
|
const path = join(cacheRoot, file.relPath);
|
|
119
|
-
return existsSync(path) && statSync(path).size
|
|
119
|
+
return existsSync(path) && statSync(path).size === file.sizeBytes;
|
|
120
120
|
});
|
|
121
121
|
}
|
|
122
122
|
|
|
@@ -467,7 +467,7 @@ function renderBehaviorLines(ttsLangs: string[], wantsAnyKokoro: boolean): strin
|
|
|
467
467
|
isDarwinArm64()
|
|
468
468
|
? " - macOS install signs/unquarantines downloaded binaries for Gatekeeper."
|
|
469
469
|
: " - No macOS Gatekeeper signing step on this platform.",
|
|
470
|
-
" - install warms the ASR backend after downloads; CoreML warm-up
|
|
470
|
+
" - install warms the ASR backend after downloads; CoreML warm-up typically takes 1-2 min, ONNX warm-up is about 500 ms.",
|
|
471
471
|
];
|
|
472
472
|
if (ttsLangs.length > 0 && wantsAnyKokoro && isDarwinArm64()) {
|
|
473
473
|
lines.push(
|
package/src/mcp/tools.ts
CHANGED
|
@@ -30,11 +30,11 @@ export function registerTools(server: McpServer): void {
|
|
|
30
30
|
try {
|
|
31
31
|
bytes = readFileSync(filePath);
|
|
32
32
|
} catch {
|
|
33
|
-
// The SDK sends only `message
|
|
33
|
+
// The SDK sends only `message` and `code`; McpError would prepend "MCP error -32002:" to the message.
|
|
34
34
|
const coded = new KeshaError("E_INPUT_NOT_FOUND", `synthesized-audio: file not found or already swept: ${name}`, {
|
|
35
35
|
hint: "synthesized files are deleted after 24 hours; call synthesize_speech again.",
|
|
36
36
|
});
|
|
37
|
-
throw new Error(errorMessage(coded));
|
|
37
|
+
throw Object.assign(new Error(errorMessage(coded)), { code: -32002 });
|
|
38
38
|
}
|
|
39
39
|
return { contents: [{ uri: uri.href, mimeType: mimeForExt(name), blob: bytes.toString("base64") }] };
|
|
40
40
|
},
|
|
@@ -46,7 +46,7 @@ export function registerTools(server: McpServer): void {
|
|
|
46
46
|
title: "Synthesize speech",
|
|
47
47
|
description:
|
|
48
48
|
"Synthesize speech from text into an audio file and return a resource link. " +
|
|
49
|
-
"Omit voice to auto-route by language (male defaults en-am_michael / ru-vosk-m02).",
|
|
49
|
+
"Omit voice (or pass an empty string) to auto-route by language (male defaults en-am_michael / ru-vosk-m02).",
|
|
50
50
|
inputSchema: {
|
|
51
51
|
text: z.string().min(1).describe("Text to speak"),
|
|
52
52
|
voice: z.string().optional().describe("Voice id, e.g. en-am_michael"),
|
|
@@ -84,7 +84,7 @@ export function registerTools(server: McpServer): void {
|
|
|
84
84
|
}
|
|
85
85
|
try {
|
|
86
86
|
// An MCP caller has no stderr to read: the voice it is told is its only record (#942).
|
|
87
|
-
const resolvedVoice = (await resolveSayVoice(voice, undefined, text, { signal: extra.signal })) ?? DEFAULT_VOICE_ID;
|
|
87
|
+
const resolvedVoice = (await resolveSayVoice(voice || undefined, undefined, text, { signal: extra.signal })) ?? DEFAULT_VOICE_ID;
|
|
88
88
|
await say({ text, voice: resolvedVoice, rate, format: fmt, out: outPath, signal: extra.signal });
|
|
89
89
|
chmodSync(outPath, 0o600);
|
|
90
90
|
const bytes = statSync(outPath).size;
|
|
@@ -163,7 +163,7 @@ export function registerTools(server: McpServer): void {
|
|
|
163
163
|
"list_voices",
|
|
164
164
|
{
|
|
165
165
|
title: "List voices",
|
|
166
|
-
description: "List
|
|
166
|
+
description: "List the TTS voices kesha knows, with their engine and language. Some need `kesha install --tts <lang>` before they synthesize.",
|
|
167
167
|
inputSchema: {},
|
|
168
168
|
outputSchema: {
|
|
169
169
|
voices: z.array(
|
|
@@ -182,7 +182,7 @@ export function registerTools(server: McpServer): void {
|
|
|
182
182
|
async () => {
|
|
183
183
|
try {
|
|
184
184
|
const voices = await listVoices();
|
|
185
|
-
const text = voices.length === 0 ? `0 voices installed. Run: ${installHint("--tts")}` : `${voices.length} voices
|
|
185
|
+
const text = voices.length === 0 ? `0 voices installed. Run: ${installHint("--tts")}` : `${voices.length} voices known; some need kesha install --tts <lang> before use.`;
|
|
186
186
|
return {
|
|
187
187
|
content: [{ type: "text" as const, text }],
|
|
188
188
|
structuredContent: { voices },
|
|
@@ -197,7 +197,7 @@ export function registerTools(server: McpServer): void {
|
|
|
197
197
|
"list_languages",
|
|
198
198
|
{
|
|
199
199
|
title: "List languages",
|
|
200
|
-
description: "List languages available for synthesis, derived from
|
|
200
|
+
description: "List languages available for synthesis, derived from the voices list_voices reports.",
|
|
201
201
|
inputSchema: {},
|
|
202
202
|
outputSchema: {
|
|
203
203
|
languages: z.array(
|
package/src/mcp/voices.ts
CHANGED
|
@@ -106,6 +106,7 @@ export async function listVoices(): Promise<VoiceInfo[]> {
|
|
|
106
106
|
export function aggregateLanguages(voices: VoiceInfo[]): LanguageInfo[] {
|
|
107
107
|
const map = new Map<string, LanguageInfo>();
|
|
108
108
|
for (const v of voices) {
|
|
109
|
+
if (!v.languageCode) continue;
|
|
109
110
|
const existing = map.get(v.languageCode);
|
|
110
111
|
if (existing) existing.voiceCount++;
|
|
111
112
|
else map.set(v.languageCode, { languageCode: v.languageCode, languageName: v.languageName, voiceCount: 1 });
|
package/src/progress.ts
CHANGED
|
@@ -8,7 +8,7 @@ const BAR_WIDTH = 20;
|
|
|
8
8
|
const DEFAULT_ESTIMATED_PROGRESS_MS = 30 * 60 * 1000;
|
|
9
9
|
|
|
10
10
|
export function formatBytes(bytes: number): string {
|
|
11
|
-
return `${(bytes /
|
|
11
|
+
return `${(bytes / 1000 / 1000).toFixed(1)}MB`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function bar(pct: number): string {
|
package/src/support-bundle.ts
CHANGED
|
@@ -110,6 +110,14 @@ function existsAsLink(path: string): boolean {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
function isRegularFile(path: string): boolean {
|
|
114
|
+
try {
|
|
115
|
+
return statSync(path).isFile();
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
113
121
|
function fileInTheWay(dir: string): string | null {
|
|
114
122
|
for (let d = dir; ; d = dirname(d)) {
|
|
115
123
|
try {
|
|
@@ -168,6 +176,11 @@ export async function createSupportBundle(
|
|
|
168
176
|
): Promise<SupportBundleResult> {
|
|
169
177
|
const now = options.now ?? new Date();
|
|
170
178
|
const outputPath = resolve(options.output ?? defaultBundlePath(now));
|
|
179
|
+
if (isRegularFile(outputPath)) {
|
|
180
|
+
throw new KeshaError("E_INVALID_ARG", `Cannot write the support bundle to ${outputPath}: the file already exists`, {
|
|
181
|
+
hint: `pass --output a path that does not exist yet, or delete ${outputPath} first.`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
171
184
|
const redact = options.redact ?? true;
|
|
172
185
|
const report = await collectDoctorReport({ redact });
|
|
173
186
|
const root = basename(outputPath).replace(/\.tar\.gz$/, "");
|
package/src/synth.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { getDescribe, getEngineBinPath, isEngineInstalled } from "./engine";
|
|
1
|
+
import { engineMissingError, getDescribe, getEngineBinPath, isEngineInstalled } from "./engine";
|
|
2
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
|
-
import { installHint } from "./install-hint";
|
|
6
5
|
import { log } from "./log";
|
|
7
6
|
import { engineAbortError, interruptedRun } from "./process-tree";
|
|
8
7
|
|
|
@@ -155,10 +154,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
|
|
|
155
154
|
validateSayText(text);
|
|
156
155
|
|
|
157
156
|
if (!isEngineInstalled()) {
|
|
158
|
-
throw
|
|
159
|
-
exitCode: 1,
|
|
160
|
-
stderr: "",
|
|
161
|
-
});
|
|
157
|
+
throw engineMissingError("speech synthesis", "--tts");
|
|
162
158
|
}
|
|
163
159
|
const { argv: args, warnings } = validateArgv(buildSayArgs({ ...opts, text: undefined }), await getDescribe({ signal: opts.signal }));
|
|
164
160
|
for (const warning of warnings) log.warn(warning);
|
|
@@ -195,7 +191,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
|
|
|
195
191
|
/** Installed voice ids as the engine lists them, one per line; a missing engine, a build without tts or a failed run is a KeshaError. */
|
|
196
192
|
export async function listVoiceIds(sinks: EventSinks = {}, signal?: AbortSignal): Promise<string[]> {
|
|
197
193
|
if (!isEngineInstalled()) {
|
|
198
|
-
throw
|
|
194
|
+
throw engineMissingError("speech synthesis");
|
|
199
195
|
}
|
|
200
196
|
if (signal?.aborted) throw engineAbortError();
|
|
201
197
|
const { argv, warnings } = validateArgv(["say", "--list-voices"], await getDescribe({ signal }));
|
package/src/transcribe.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assertSpeakerModelsInstalled,
|
|
3
3
|
buildTranscribeArgs,
|
|
4
|
+
engineMissingError,
|
|
4
5
|
getDescribe,
|
|
5
6
|
isEngineInstalled,
|
|
6
7
|
transcribeEngine,
|
|
@@ -10,8 +11,8 @@ import {
|
|
|
10
11
|
} from "./engine";
|
|
11
12
|
import { validateArgv } from "./engine/describe";
|
|
12
13
|
import { KeshaError } from "./engine/events";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
14
|
+
import { closeSync, existsSync, fstatSync, openSync, readSync, statSync } from "fs";
|
|
15
|
+
import { isAbsolute } from "path";
|
|
15
16
|
|
|
16
17
|
export type { VadMode };
|
|
17
18
|
|
|
@@ -24,11 +25,18 @@ export function isDirectoryPath(path: string): boolean {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** The next step for E_INPUT_NOT_FOUND, shared by the CLI and the Core API. */
|
|
29
|
+
export function missingFileHint(path: string): string {
|
|
30
|
+
return isAbsolute(path) ? "check the path" : `check the path, relative to the current directory ${process.cwd()}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
27
33
|
export const SPECIAL_FILE_HINT =
|
|
28
34
|
"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
35
|
|
|
30
36
|
/** 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
37
|
export function isSpecialFilePath(path: string): boolean {
|
|
38
|
+
// stat follows /dev/stdin to whatever stdin holds, so `kesha /dev/stdin < en.wav` would look like a regular file.
|
|
39
|
+
if (path === "/dev/stdin" || path.startsWith("/dev/fd/")) return true;
|
|
32
40
|
try {
|
|
33
41
|
const st = statSync(path);
|
|
34
42
|
return !st.isFile() && !st.isDirectory();
|
|
@@ -37,6 +45,38 @@ export function isSpecialFilePath(path: string): boolean {
|
|
|
37
45
|
}
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
/** Seconds a RIFF WAV's data chunk declares and seconds the file holds, when the file is shorter than its header says; null otherwise. */
|
|
49
|
+
export function truncatedWavSeconds(path: string): { declared: number; present: number } | null {
|
|
50
|
+
let fd: number | undefined;
|
|
51
|
+
try {
|
|
52
|
+
fd = openSync(path, "r");
|
|
53
|
+
const size = fstatSync(fd).size;
|
|
54
|
+
const buf = Buffer.alloc(20);
|
|
55
|
+
if (readSync(fd, buf, 0, 12, 0) < 12 || buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WAVE") {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
let byteRate = 0;
|
|
59
|
+
for (let offset = 12; offset + 8 <= size; ) {
|
|
60
|
+
readSync(fd, buf, 0, 20, offset);
|
|
61
|
+
const id = buf.toString("ascii", 0, 4);
|
|
62
|
+
const length = buf.readUInt32LE(4);
|
|
63
|
+
if (id === "fmt ") byteRate = buf.readUInt32LE(16);
|
|
64
|
+
if (id === "data") {
|
|
65
|
+
const present = size - offset - 8;
|
|
66
|
+
// Streaming writers leave 0xFFFFFFFF in the size field when they cannot seek back to fill it in.
|
|
67
|
+
if (length === 0xffffffff || byteRate === 0 || length <= present) return null;
|
|
68
|
+
return { declared: length / byteRate, present: present / byteRate };
|
|
69
|
+
}
|
|
70
|
+
offset += 8 + length + (length % 2);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
} finally {
|
|
76
|
+
if (fd !== undefined) closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
40
80
|
export interface TranscribeOptions {
|
|
41
81
|
/** Silero VAD preprocessing selector. Defaults to `"auto"`. */
|
|
42
82
|
vad?: VadMode;
|
|
@@ -62,7 +102,7 @@ export interface TranscribeOptions {
|
|
|
62
102
|
/** The refusals the CLI makes before it spawns anything, so an agent branching on the documented code sees it on every surface. */
|
|
63
103
|
export function assertAudioFileArgument(audioPath: string): void {
|
|
64
104
|
if (!existsSync(audioPath)) {
|
|
65
|
-
throw new KeshaError("E_INPUT_NOT_FOUND", `File not found: ${audioPath}
|
|
105
|
+
throw new KeshaError("E_INPUT_NOT_FOUND", `File not found: ${audioPath}`, { hint: missingFileHint(audioPath) });
|
|
66
106
|
}
|
|
67
107
|
if (isDirectoryPath(audioPath)) {
|
|
68
108
|
throw new KeshaError("E_INVALID_ARG", `${audioPath}: is a directory (expected an audio file)`);
|
|
@@ -75,9 +115,7 @@ export function assertAudioFileArgument(audioPath: string): void {
|
|
|
75
115
|
/** 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. */
|
|
76
116
|
export async function validateTranscribeRequest(opts: TranscribeOptions = {}): Promise<void> {
|
|
77
117
|
if (!isEngineInstalled()) {
|
|
78
|
-
throw
|
|
79
|
-
hint: `bun add -g @drakulavich/kesha-voice-kit, then ${installHint()}`,
|
|
80
|
-
});
|
|
118
|
+
throw engineMissingError("transcription");
|
|
81
119
|
}
|
|
82
120
|
validateArgv(
|
|
83
121
|
buildTranscribeArgs(
|