@drakulavich/kesha-voice-kit 1.30.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/SKILL.md +1 -1
- package/completions/kesha.bash +1 -1
- package/completions/kesha.fish +0 -1
- package/completions/kesha.zsh +3 -1
- package/docs/diagnostic-logs.md +36 -3
- package/docs/product-positioning.md +3 -3
- package/model-plan.json +130 -0
- package/package.json +21 -7
- package/src/cache-layout.ts +78 -8
- package/src/cli/action-result.ts +2 -1
- package/src/cli/command-session.ts +14 -3
- package/src/cli/completions.ts +4 -2
- package/src/cli/dispatch.ts +37 -5
- package/src/cli/init.ts +4 -4
- package/src/cli/install.ts +9 -6
- package/src/cli/main.ts +61 -46
- package/src/cli/mcp.ts +2 -0
- package/src/cli/options.ts +79 -0
- package/src/cli/record.ts +2 -1
- package/src/cli/say.ts +92 -11
- package/src/cli/stats.ts +9 -4
- package/src/cli/support-bundle.ts +3 -3
- package/src/cli.ts +1 -1
- package/src/diagnostic-log.ts +2 -9
- package/src/doctor.ts +62 -11
- package/src/engine/events.ts +20 -1
- package/src/engine/spawn.ts +114 -0
- package/src/engine-health.ts +25 -56
- package/src/engine-install.ts +230 -109
- package/src/engine-targets.ts +53 -5
- package/src/engine.ts +87 -102
- package/src/fluid-roots.ts +5 -0
- package/src/format.ts +11 -5
- package/src/install-lock.ts +45 -11
- package/src/install-plan.ts +96 -48
- package/src/kokoro-ane.ts +67 -4
- package/src/language-routing.ts +51 -0
- package/src/lib.ts +96 -61
- package/src/log.ts +2 -0
- package/src/mcp/audio-output.ts +2 -2
- package/src/mcp/tools.ts +10 -14
- package/src/package-info.ts +28 -2
- package/src/paths.ts +2 -2
- package/src/process-tree.ts +70 -18
- package/src/semver.mjs +7 -10
- package/src/shell-artifacts.ts +4 -3
- package/src/state-paths.ts +84 -0
- package/src/stats.ts +2 -10
- package/src/status.ts +39 -42
- package/src/synth.ts +58 -79
- package/src/transcribe.ts +18 -3
- package/src/voice-inventory.ts +51 -0
- package/src/voice-routing.ts +97 -6
- package/tsconfig.json +1 -1
package/src/cli/init.ts
CHANGED
|
@@ -32,16 +32,16 @@ export type TtsLangPrompt = (preselect: string[]) => Promise<string[]>;
|
|
|
32
32
|
/** Signature of the interactive yes/no prompt (injectable for tests). */
|
|
33
33
|
export type ConfirmPrompt = (message: string, initialValue: boolean) => Promise<boolean>;
|
|
34
34
|
|
|
35
|
-
/** Ctrl-C at any prompt ends init without downloading,
|
|
35
|
+
/** Ctrl-C at any prompt ends init without downloading, exiting 130 like an interrupted install so `kesha init && …` stops (S4-F1). */
|
|
36
36
|
function exitIfCancelled<T>(answer: T | symbol): T {
|
|
37
37
|
if (isCancel(answer)) {
|
|
38
38
|
cancel("Init cancelled.");
|
|
39
|
-
process.exit(
|
|
39
|
+
process.exit(130);
|
|
40
40
|
}
|
|
41
41
|
return answer as T;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
async function promptTtsLangs(preselect: string[]): Promise<string[]> {
|
|
44
|
+
export async function promptTtsLangs(preselect: string[]): Promise<string[]> {
|
|
45
45
|
// A corrupt engine must leave init offering the fallback list rather than aborting the
|
|
46
46
|
// guided setup that would replace it (#770).
|
|
47
47
|
const caps = await getEngineCapabilities().catch(() => null);
|
|
@@ -57,7 +57,7 @@ async function promptTtsLangs(preselect: string[]): Promise<string[]> {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/** #677: keep every prompt on clack — its close() unpipes stdin, deadlocking any readline sharing it. */
|
|
60
|
-
async function promptConfirm(message: string, initialValue: boolean): Promise<boolean> {
|
|
60
|
+
export async function promptConfirm(message: string, initialValue: boolean): Promise<boolean> {
|
|
61
61
|
return exitIfCancelled(await confirm({ message, initialValue }));
|
|
62
62
|
}
|
|
63
63
|
|
package/src/cli/install.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { getEngineBinPath, getEngineCapabilities, type EngineCapabilities } from
|
|
|
6
6
|
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
7
7
|
import { renderInstallPlan } from "../install-plan";
|
|
8
8
|
import { maybeAskForStar } from "../star";
|
|
9
|
+
import { renderInvalidArg } from "./options";
|
|
9
10
|
import { log } from "../log";
|
|
10
11
|
import { packageVersion } from "../package-info";
|
|
11
12
|
import { isSemver } from "../semver.mjs";
|
|
@@ -85,10 +86,12 @@ export function resolveEngineVersionFlag(raw: unknown): string | undefined {
|
|
|
85
86
|
const value = String(raw).trim();
|
|
86
87
|
if (isSemver(value)) return value;
|
|
87
88
|
const hint = /^v\d/.test(value)
|
|
88
|
-
? '
|
|
89
|
-
:
|
|
90
|
-
throw new
|
|
91
|
-
|
|
89
|
+
? 'drop the leading "v" — that belongs to the release tag, not the version.'
|
|
90
|
+
: undefined;
|
|
91
|
+
throw new KeshaError(
|
|
92
|
+
"E_INVALID_ARG",
|
|
93
|
+
`--engine-version needs an exact SemVer 2.0 version like 1.24.8 or 1.24.8-alpha.1, got "${value}".`,
|
|
94
|
+
hint === undefined ? {} : { hint },
|
|
92
95
|
);
|
|
93
96
|
}
|
|
94
97
|
|
|
@@ -321,8 +324,8 @@ export const installCommand = defineCommand({
|
|
|
321
324
|
try {
|
|
322
325
|
ttsLangs = resolveTtsLangs({ tts: args.tts === true, positionals }, supported);
|
|
323
326
|
} catch (err) {
|
|
324
|
-
log.error(errorMessage(err));
|
|
325
|
-
process.exit(
|
|
327
|
+
log.error(renderInvalidArg(errorMessage(err)));
|
|
328
|
+
process.exit(2);
|
|
326
329
|
}
|
|
327
330
|
await performInstall({
|
|
328
331
|
noCache: resolveNoCacheFlag(args, rawArgs),
|
package/src/cli/main.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
|
-
import { existsSync
|
|
4
|
-
import {
|
|
5
|
-
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { isDirectoryPath, transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
|
|
5
|
+
|
|
6
|
+
export { isDirectoryPath };
|
|
6
7
|
import { detectAudioLanguageEngine, detectTextLanguageEngine } from "../engine";
|
|
7
8
|
import type { LangDetectResult } from "../engine";
|
|
8
9
|
import { log } from "../log";
|
|
@@ -11,19 +12,21 @@ import {
|
|
|
11
12
|
formatJsonOutput,
|
|
12
13
|
formatTextOutput,
|
|
13
14
|
formatTranscriptOutput,
|
|
14
|
-
|
|
15
|
+
formatVerboseDiagnostics,
|
|
15
16
|
} from "../format";
|
|
16
17
|
import { packageVersion } from "../package-info";
|
|
17
18
|
import { formatToonOutput } from "../toon";
|
|
18
19
|
import { artifactFromFile, type StatsRecorder } from "../stats";
|
|
19
20
|
import { createPercentProgress } from "../progress";
|
|
20
|
-
import { getPendingSignalExitCode, waitForPendingSignalCleanup } from "../process-tree";
|
|
21
|
+
import { getPendingSignalExitCode, pendingInterruption, waitForPendingSignalCleanup } from "../process-tree";
|
|
21
22
|
import type { TranscriptionSegment } from "../types";
|
|
22
23
|
import { diagnosticSizeBucket } from "../diagnostic-events";
|
|
23
24
|
import { runCommandSession, type CommandSession } from "./command-session";
|
|
24
25
|
import { USAGE_MESSAGE } from "./dispatch";
|
|
25
26
|
import type { CliContext } from "./context";
|
|
26
|
-
import { KeshaError } from "../engine/events";
|
|
27
|
+
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
28
|
+
import { detectTextLanguageFallback, routeLanguage } from "../language-routing";
|
|
29
|
+
import { renderInvalidArg } from "./options";
|
|
27
30
|
|
|
28
31
|
interface MainCommandArgs {
|
|
29
32
|
_: string[];
|
|
@@ -43,32 +46,12 @@ interface MainCommandArgs {
|
|
|
43
46
|
"no-color": boolean;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
* The CLI-side `tinyld` fallback, tagged `source: "tinyld"` so a consumer can
|
|
48
|
-
* tell it apart from an Engine detection instead of reading the placeholder
|
|
49
|
-
* `confidence: 0` it used to carry as a genuine zero (#941). The score is
|
|
50
|
-
* tinyld's own and is not on the Engine's scale.
|
|
51
|
-
*/
|
|
52
|
-
export function detectTextLanguageFallback(text: string): TextLangDetectResult | undefined {
|
|
53
|
-
if (!text) return undefined;
|
|
54
|
-
const [best] = detectAll(text);
|
|
55
|
-
if (!best) return undefined;
|
|
56
|
-
return { code: best.lang, confidence: best.accuracy, source: "tinyld" };
|
|
57
|
-
}
|
|
49
|
+
export { detectTextLanguageFallback };
|
|
58
50
|
|
|
59
51
|
export function detectLanguage(text: string): string {
|
|
60
52
|
return detectTextLanguageFallback(text)?.code ?? "";
|
|
61
53
|
}
|
|
62
54
|
|
|
63
|
-
/** True when `path` exists and is a directory. Used to reject directory positionals before any progress/engine spawn. */
|
|
64
|
-
export function isDirectoryPath(path: string): boolean {
|
|
65
|
-
try {
|
|
66
|
-
return statSync(path).isDirectory();
|
|
67
|
-
} catch {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
55
|
/**
|
|
73
56
|
* Pulled out of the citty `run` handler so the contract is unit-testable.
|
|
74
57
|
* Mutex check happens AFTER format validation so unknown `--format` surfaces
|
|
@@ -121,8 +104,14 @@ export function resolveOutputFormat(input: {
|
|
|
121
104
|
return { ok: true, format: input.json ? "json" : input.toon ? "toon" : (input.format ?? "text") };
|
|
122
105
|
}
|
|
123
106
|
|
|
107
|
+
/** Case-folded primary subtag: `en-US`, `EN` and `en_us` all reduce to `en`; a three-letter `eng` stays `eng`. */
|
|
108
|
+
export function primaryLanguageSubtag(code: string): string {
|
|
109
|
+
return code.trim().toLowerCase().split(/[-_]/, 1)[0] ?? "";
|
|
110
|
+
}
|
|
111
|
+
|
|
124
112
|
export function checkLanguageMismatch(expected: string | undefined, detected: string): string | null {
|
|
125
|
-
if (!expected || !detected
|
|
113
|
+
if (!expected || !detected) return null;
|
|
114
|
+
if (primaryLanguageSubtag(expected) === primaryLanguageSubtag(detected)) return null;
|
|
126
115
|
return `warning: expected language "${expected}" but detected "${detected}"`;
|
|
127
116
|
}
|
|
128
117
|
|
|
@@ -214,13 +203,21 @@ export function validateTranscribeArgs(
|
|
|
214
203
|
return { ok: true, vadMode, outputFormat: fmt.format };
|
|
215
204
|
}
|
|
216
205
|
|
|
206
|
+
type FileProgress = ReturnType<typeof createPercentProgress> | null;
|
|
207
|
+
|
|
208
|
+
/** A warning is not progress: it must reach stderr under --quiet, where no bar exists to interrupt. */
|
|
209
|
+
function warnAboveProgress(progress: FileProgress, message: string): void {
|
|
210
|
+
if (progress) progress.interrupt(() => log.warn(message));
|
|
211
|
+
else log.warn(message);
|
|
212
|
+
}
|
|
213
|
+
|
|
217
214
|
async function detectLanguages(
|
|
218
215
|
file: string,
|
|
219
216
|
text: string,
|
|
220
217
|
options: {
|
|
221
218
|
wantsLangId: boolean;
|
|
222
219
|
expectedLang?: string;
|
|
223
|
-
progress:
|
|
220
|
+
progress: FileProgress;
|
|
224
221
|
stats: StatsRecorder;
|
|
225
222
|
transcriptDurationSeconds: number | null;
|
|
226
223
|
},
|
|
@@ -250,7 +247,7 @@ async function detectLanguages(
|
|
|
250
247
|
if (audioLanguage && expectedLang && audioLanguage.confidence > 0.8) {
|
|
251
248
|
const mismatch = checkLanguageMismatch(expectedLang, audioLanguage.code);
|
|
252
249
|
if (mismatch) {
|
|
253
|
-
progress
|
|
250
|
+
warnAboveProgress(progress, `${file}: ${mismatch} (from audio)`);
|
|
254
251
|
}
|
|
255
252
|
}
|
|
256
253
|
|
|
@@ -264,11 +261,13 @@ async function detectLanguages(
|
|
|
264
261
|
}
|
|
265
262
|
}
|
|
266
263
|
|
|
267
|
-
const
|
|
264
|
+
const route = routeLanguage({ audioLanguage, textLanguage: textLanguage ?? tinyldResult });
|
|
265
|
+
const { lang } = route;
|
|
268
266
|
|
|
269
|
-
|
|
267
|
+
// A lang taken from audio was already judged by the audio check above; warning again would double it.
|
|
268
|
+
const mismatchWarning = route.source === "audio" ? null : checkLanguageMismatch(expectedLang, lang);
|
|
270
269
|
if (mismatchWarning) {
|
|
271
|
-
progress
|
|
270
|
+
warnAboveProgress(progress, `${file}: ${mismatchWarning}`);
|
|
272
271
|
}
|
|
273
272
|
|
|
274
273
|
return {
|
|
@@ -290,7 +289,13 @@ type ProcessFileOptions = {
|
|
|
290
289
|
};
|
|
291
290
|
|
|
292
291
|
type ProcessFileSuccess = { ok: true; result: TranscribeResult };
|
|
293
|
-
type ProcessFileFailure = { ok: false; error: TranscribeErrorRecord };
|
|
292
|
+
type ProcessFileFailure = { ok: false; error: TranscribeErrorRecord; exitCode: number };
|
|
293
|
+
|
|
294
|
+
/** An argument the CLI itself rejected exits by its code (2); every runtime failure keeps the batch's operational 1 (S9-F1). */
|
|
295
|
+
function failureExitCode(err: unknown): number {
|
|
296
|
+
const usage = err instanceof KeshaError && err.origin === "cli" && err.code === "E_INVALID_ARG";
|
|
297
|
+
return usage ? exitCodeFor(err) : 1;
|
|
298
|
+
}
|
|
294
299
|
|
|
295
300
|
async function processFile(
|
|
296
301
|
file: string,
|
|
@@ -308,7 +313,7 @@ async function processFile(
|
|
|
308
313
|
error_code: err.code,
|
|
309
314
|
});
|
|
310
315
|
log.error(`${file}: ${errorMessage(err)}`);
|
|
311
|
-
return { ok: false, error: { file, code: err.code, message: err.message } };
|
|
316
|
+
return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
|
|
312
317
|
}
|
|
313
318
|
|
|
314
319
|
if (isDirectoryPath(file)) {
|
|
@@ -319,7 +324,7 @@ async function processFile(
|
|
|
319
324
|
error_code: err.code,
|
|
320
325
|
});
|
|
321
326
|
log.error(`${file}: ${errorMessage(err)}`);
|
|
322
|
-
return { ok: false, error: { file, code: err.code, message: err.message } };
|
|
327
|
+
return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
|
|
323
328
|
}
|
|
324
329
|
|
|
325
330
|
const inputArtifact = artifactFromFile(file, "input_audio");
|
|
@@ -337,6 +342,7 @@ async function processFile(
|
|
|
337
342
|
// #1002: the bar sitting at 0% through a ~100 s diarization model load reads as a hang,
|
|
338
343
|
// so the engine's progress has to land while it happens, not once the run is over.
|
|
339
344
|
const showProgressLine = (line: string) => {
|
|
345
|
+
if (log.quietEnabled) return;
|
|
340
346
|
const write = () => process.stderr.write(`${line}\n`);
|
|
341
347
|
if (progress) progress.interrupt(write);
|
|
342
348
|
else write();
|
|
@@ -403,17 +409,18 @@ async function processFile(
|
|
|
403
409
|
error_code: code,
|
|
404
410
|
});
|
|
405
411
|
log.error(`${file}: ${stderrText}`);
|
|
406
|
-
return { ok: false, error: { file, code, message: stderrText } };
|
|
412
|
+
return { ok: false, error: { file, code, message: stderrText }, exitCode: failureExitCode(err) };
|
|
407
413
|
}
|
|
408
414
|
}
|
|
409
415
|
|
|
410
|
-
/**
|
|
416
|
+
/** `--verbose` diagnostics are stderr's whatever the format; stdout carries results only. */
|
|
411
417
|
function writeOutput(
|
|
412
418
|
results: TranscribeResult[],
|
|
413
419
|
errors: TranscribeErrorRecord[],
|
|
414
420
|
format: ValidatedTranscribeArgs["outputFormat"],
|
|
415
421
|
opts: { includeErrors: boolean; verbose: boolean },
|
|
416
422
|
): void {
|
|
423
|
+
if (opts.verbose) process.stderr.write(formatVerboseDiagnostics(results));
|
|
417
424
|
const errorEnvelope = (format === "json" || format === "toon") && opts.includeErrors;
|
|
418
425
|
// #773: `[]` reads as "ran fine, nothing found" to a consumer ignoring the exit code.
|
|
419
426
|
if (results.length === 0 && errors.length > 0 && !errorEnvelope) return;
|
|
@@ -424,8 +431,6 @@ function writeOutput(
|
|
|
424
431
|
process.stdout.write(formatToonOutput(results, opts.includeErrors ? errors : undefined));
|
|
425
432
|
} else if (format === "transcript") {
|
|
426
433
|
process.stdout.write(formatTranscriptOutput(results));
|
|
427
|
-
} else if (opts.verbose) {
|
|
428
|
-
process.stdout.write(formatVerboseOutput(results));
|
|
429
434
|
} else {
|
|
430
435
|
process.stdout.write(formatTextOutput(results));
|
|
431
436
|
}
|
|
@@ -550,20 +555,21 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
|
|
|
550
555
|
format: args.format,
|
|
551
556
|
});
|
|
552
557
|
if (!fmt.ok) {
|
|
553
|
-
log.error(fmt.error);
|
|
558
|
+
log.error(renderInvalidArg(fmt.error));
|
|
554
559
|
process.exit(2);
|
|
555
560
|
}
|
|
556
561
|
|
|
557
562
|
const validated = validateTranscribeArgs(args, rawArgs, fmt);
|
|
558
563
|
if (!validated.ok) {
|
|
559
|
-
log.error(validated.error);
|
|
564
|
+
log.error(renderInvalidArg(validated.error));
|
|
560
565
|
process.exit(2);
|
|
561
566
|
}
|
|
562
567
|
const { vadMode, outputFormat } = validated;
|
|
563
568
|
|
|
564
569
|
if (files.length === 0) {
|
|
565
|
-
log.
|
|
566
|
-
process.
|
|
570
|
+
log.error(renderInvalidArg("no input file"));
|
|
571
|
+
process.stderr.write(`${USAGE_MESSAGE}\n`);
|
|
572
|
+
process.exit(2);
|
|
567
573
|
}
|
|
568
574
|
|
|
569
575
|
const wantsLangId = !!(args.lang || args.verbose || outputFormat !== "text");
|
|
@@ -574,7 +580,7 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
|
|
|
574
580
|
quiet: context.quiet,
|
|
575
581
|
});
|
|
576
582
|
|
|
577
|
-
const { status } = await runCommandSession(
|
|
583
|
+
const { status, exitCode } = await runCommandSession(
|
|
578
584
|
"transcribe",
|
|
579
585
|
{
|
|
580
586
|
itemCount: files.length,
|
|
@@ -589,8 +595,15 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
|
|
|
589
595
|
async (session) => {
|
|
590
596
|
const results: TranscribeResult[] = [];
|
|
591
597
|
const errors: TranscribeErrorRecord[] = [];
|
|
598
|
+
let exitCode = 0;
|
|
592
599
|
|
|
593
600
|
for (const file of files) {
|
|
601
|
+
const interrupted = pendingInterruption();
|
|
602
|
+
if (interrupted) {
|
|
603
|
+
log.error(`${file}: ${errorMessage(interrupted)}`);
|
|
604
|
+
errors.push({ file, code: interrupted.code, message: interrupted.message });
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
594
607
|
const outcome = await processFile(
|
|
595
608
|
file,
|
|
596
609
|
{
|
|
@@ -608,6 +621,7 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
|
|
|
608
621
|
results.push(outcome.result);
|
|
609
622
|
} else {
|
|
610
623
|
errors.push(outcome.error);
|
|
624
|
+
exitCode = Math.max(exitCode, outcome.exitCode);
|
|
611
625
|
}
|
|
612
626
|
}
|
|
613
627
|
|
|
@@ -619,6 +633,7 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
|
|
|
619
633
|
return {
|
|
620
634
|
status: errors.length > 0 ? "failed" : "success",
|
|
621
635
|
itemCount: files.length,
|
|
636
|
+
exitCode: errors.length > 0 ? exitCode : undefined,
|
|
622
637
|
finishFields: {
|
|
623
638
|
itemCount: files.length,
|
|
624
639
|
resultCount: results.length,
|
|
@@ -634,7 +649,7 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
|
|
|
634
649
|
await waitForPendingSignalCleanup();
|
|
635
650
|
process.exit(signalExitCode);
|
|
636
651
|
}
|
|
637
|
-
process.exit(1);
|
|
652
|
+
process.exit(exitCode ?? 1);
|
|
638
653
|
}
|
|
639
654
|
},
|
|
640
655
|
});
|
package/src/cli/mcp.ts
CHANGED
|
@@ -13,5 +13,7 @@ export const mcpCommand = defineCommand({
|
|
|
13
13
|
const server = createKeshaMcpServer();
|
|
14
14
|
const transport = new StdioServerTransport();
|
|
15
15
|
await server.connect(transport);
|
|
16
|
+
// The SDK transport never watches for EOF, so a client that died mid-call left the engine running (exploratory S7-2).
|
|
17
|
+
process.stdin.once("end", () => void server.close());
|
|
16
18
|
},
|
|
17
19
|
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ArgsDef } from "citty";
|
|
2
|
+
import { renderError } from "../engine/events";
|
|
3
|
+
import { log } from "../log";
|
|
4
|
+
import { suggestCommand } from "../suggest-command";
|
|
5
|
+
|
|
6
|
+
export interface UnknownOption {
|
|
7
|
+
name: string;
|
|
8
|
+
suggestion: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const RUNNER_LONG = ["help", "version"];
|
|
12
|
+
const RUNNER_SHORT = ["h", "v"];
|
|
13
|
+
|
|
14
|
+
function camelCase(name: string): string {
|
|
15
|
+
return name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The first flag `argsDef` does not declare, or null; citty parses non-strict, so the flag would otherwise vanish and its value become a positional (S9-F3). */
|
|
19
|
+
export function findUnknownOption(rawArgs: string[], argsDef: ArgsDef): UnknownOption | null {
|
|
20
|
+
const long = new Set(RUNNER_LONG);
|
|
21
|
+
const short = new Set(RUNNER_SHORT);
|
|
22
|
+
const valued = new Set<string>();
|
|
23
|
+
const suggestable: string[] = [];
|
|
24
|
+
for (const [name, def] of Object.entries(argsDef)) {
|
|
25
|
+
if (def.type === "positional") continue;
|
|
26
|
+
const alias = "alias" in def ? def.alias : undefined;
|
|
27
|
+
const aliases = alias === undefined ? [] : Array.isArray(alias) ? alias : [alias];
|
|
28
|
+
for (const spelling of [name, ...aliases]) {
|
|
29
|
+
if (spelling.length === 1) {
|
|
30
|
+
short.add(spelling);
|
|
31
|
+
} else {
|
|
32
|
+
long.add(spelling);
|
|
33
|
+
long.add(camelCase(spelling));
|
|
34
|
+
suggestable.push(spelling);
|
|
35
|
+
}
|
|
36
|
+
if (def.type === "boolean") long.add(`no-${spelling}`);
|
|
37
|
+
else valued.add(spelling);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const suggest = (name: string): string | null => {
|
|
41
|
+
const match = suggestCommand(name, suggestable);
|
|
42
|
+
return match === null ? null : `--${match}`;
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
45
|
+
const arg = rawArgs[i]!;
|
|
46
|
+
if (arg === "--") break;
|
|
47
|
+
if (!arg.startsWith("-") || arg === "-" || /^-\d/.test(arg)) continue;
|
|
48
|
+
if (arg.startsWith("--")) {
|
|
49
|
+
const eq = arg.indexOf("=");
|
|
50
|
+
const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
|
51
|
+
if (!long.has(name)) return { name: `--${name}`, suggestion: suggest(name) };
|
|
52
|
+
if (eq === -1 && valued.has(name)) i++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
for (const letter of arg.slice(1)) {
|
|
56
|
+
if (!short.has(letter)) return { name: `-${letter}`, suggestion: null };
|
|
57
|
+
}
|
|
58
|
+
if (valued.has(arg[arg.length - 1]!)) i++;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The coded line for a usage error the CLI refuses before doing anything (S9-F2). */
|
|
64
|
+
export function renderInvalidArg(message: string): string {
|
|
65
|
+
return renderError({ code: "E_INVALID_ARG", message });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function renderUnknownOption(option: UnknownOption): string {
|
|
69
|
+
const tail = option.suggestion === null ? "" : ` (did you mean ${option.suggestion}?)`;
|
|
70
|
+
return renderInvalidArg(`unknown option ${option.name}${tail}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Exits 2 with one coded stderr line when `rawArgs` carries a flag the command does not declare. */
|
|
74
|
+
export function rejectUnknownOptions(rawArgs: string[], argsDef: ArgsDef): void {
|
|
75
|
+
const unknown = findUnknownOption(rawArgs, argsDef);
|
|
76
|
+
if (unknown === null) return;
|
|
77
|
+
log.error(renderUnknownOption(unknown));
|
|
78
|
+
process.exit(2);
|
|
79
|
+
}
|
package/src/cli/record.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
3
|
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
4
|
+
import { renderInvalidArg } from "./options";
|
|
4
5
|
import {
|
|
5
6
|
isEngineInstalled,
|
|
6
7
|
recordEngine,
|
|
@@ -197,7 +198,7 @@ export const recordCommand = defineCommand({
|
|
|
197
198
|
if (args.debug) log.debugEnabled = true;
|
|
198
199
|
const resolved = resolveRecordArgs(args as RecordArgs);
|
|
199
200
|
if (!resolved.ok) {
|
|
200
|
-
log.error(resolved.error);
|
|
201
|
+
log.error(renderInvalidArg(resolved.error));
|
|
201
202
|
process.exit(2);
|
|
202
203
|
}
|
|
203
204
|
if (!isEngineInstalled()) {
|
package/src/cli/say.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
+
import { statSync } from "fs";
|
|
2
3
|
import { errorMessage } from "../error-utils";
|
|
3
4
|
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
5
|
+
import { renderInvalidArg } from "./options";
|
|
4
6
|
import { log } from "../log";
|
|
5
7
|
import {
|
|
8
|
+
MAX_TEXT_CHARS,
|
|
6
9
|
listVoiceIds,
|
|
7
10
|
say,
|
|
8
11
|
SUPPORTED_SAMPLE_RATES,
|
|
12
|
+
validateSayText,
|
|
9
13
|
type SayFormat,
|
|
10
14
|
type SayOptions,
|
|
11
15
|
} from "../synth";
|
|
@@ -14,13 +18,25 @@ import { resolveSayVoice } from "../voice-routing";
|
|
|
14
18
|
import { diagnosticCharBucket, diagnosticSizeBucket } from "../diagnostic-events";
|
|
15
19
|
import { runCommandSession, type CommandOutcome, type CommandSession } from "./command-session";
|
|
16
20
|
|
|
21
|
+
/** Four bytes per allowed character: a pipe that streams past it is refused without waiting for EOF. */
|
|
22
|
+
const MAX_STDIN_BYTES = MAX_TEXT_CHARS * 4 + 4;
|
|
23
|
+
|
|
24
|
+
/** A positional the user gave is the text, empty or not; only an absent one falls through to stdin (#T1-2). */
|
|
17
25
|
async function resolveText(inline: string | undefined): Promise<string> {
|
|
18
|
-
if (inline !== undefined
|
|
26
|
+
if (inline !== undefined) return inline;
|
|
19
27
|
const chunks: Uint8Array[] = [];
|
|
28
|
+
let total = 0;
|
|
20
29
|
for await (const chunk of Bun.stdin.stream()) {
|
|
21
30
|
chunks.push(chunk);
|
|
31
|
+
total += chunk.byteLength;
|
|
32
|
+
if (total > MAX_STDIN_BYTES) break;
|
|
33
|
+
}
|
|
34
|
+
if (total > MAX_STDIN_BYTES) {
|
|
35
|
+
throw new KeshaError(
|
|
36
|
+
"E_TEXT_TOO_LONG",
|
|
37
|
+
`text exceeds ${MAX_TEXT_CHARS} chars (stdin passed ${MAX_STDIN_BYTES} bytes)`,
|
|
38
|
+
);
|
|
22
39
|
}
|
|
23
|
-
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
|
|
24
40
|
const merged = new Uint8Array(total);
|
|
25
41
|
let offset = 0;
|
|
26
42
|
for (const c of chunks) {
|
|
@@ -34,7 +50,7 @@ export function shouldRejectMissingSayText(
|
|
|
34
50
|
inlineText: string | undefined,
|
|
35
51
|
stdinIsTty: boolean | undefined,
|
|
36
52
|
): boolean {
|
|
37
|
-
return
|
|
53
|
+
return inlineText === undefined && stdinIsTty === true;
|
|
38
54
|
}
|
|
39
55
|
|
|
40
56
|
type Parsed<T> = { ok: true; value: T | undefined } | { ok: false; error: string };
|
|
@@ -58,12 +74,19 @@ function parseRateFlag(value: unknown): Parsed<number> {
|
|
|
58
74
|
return rate;
|
|
59
75
|
}
|
|
60
76
|
|
|
77
|
+
/** The Opus encoder's own range, mirrored here so a caller error never costs a synthesis (#T1-5). */
|
|
78
|
+
const MIN_OPUS_BITRATE = 6000;
|
|
79
|
+
const MAX_OPUS_BITRATE = 510000;
|
|
80
|
+
|
|
61
81
|
function parseBitrateFlag(value: unknown): Parsed<number> {
|
|
62
82
|
const bitrate = parseFiniteNumberFlag("--bitrate", value);
|
|
63
83
|
if (!bitrate.ok || bitrate.value === undefined) return bitrate;
|
|
64
84
|
if (!Number.isInteger(bitrate.value) || bitrate.value <= 0) {
|
|
65
85
|
return { ok: false, error: "--bitrate must be a positive integer." };
|
|
66
86
|
}
|
|
87
|
+
if (bitrate.value < MIN_OPUS_BITRATE || bitrate.value > MAX_OPUS_BITRATE) {
|
|
88
|
+
return { ok: false, error: `--bitrate must be between ${MIN_OPUS_BITRATE} and ${MAX_OPUS_BITRATE} bps.` };
|
|
89
|
+
}
|
|
67
90
|
return bitrate;
|
|
68
91
|
}
|
|
69
92
|
|
|
@@ -106,6 +129,8 @@ function checkOpusOnlyFlags(
|
|
|
106
129
|
}
|
|
107
130
|
|
|
108
131
|
export type SayFlagArgs = {
|
|
132
|
+
voice?: unknown;
|
|
133
|
+
lang?: unknown;
|
|
109
134
|
format?: unknown;
|
|
110
135
|
rate?: unknown;
|
|
111
136
|
bitrate?: unknown;
|
|
@@ -116,6 +141,9 @@ export type SayFlagArgs = {
|
|
|
116
141
|
export type ResolvedSayFlags =
|
|
117
142
|
| {
|
|
118
143
|
ok: true;
|
|
144
|
+
voice: string | undefined;
|
|
145
|
+
lang: string | undefined;
|
|
146
|
+
out: string | undefined;
|
|
119
147
|
format: SayFormat | undefined;
|
|
120
148
|
rate: number | undefined;
|
|
121
149
|
bitrate: number | undefined;
|
|
@@ -123,8 +151,22 @@ export type ResolvedSayFlags =
|
|
|
123
151
|
}
|
|
124
152
|
| { ok: false; error: string };
|
|
125
153
|
|
|
154
|
+
const STRING_FLAGS = ["voice", "lang", "out", "format", "rate", "bitrate", "sample-rate"] as const;
|
|
155
|
+
|
|
156
|
+
/** citty returns a trailing valueless string flag as `true` and `--out=` as `""`; both used to be discarded silently (#T1-3). */
|
|
157
|
+
function missingFlagValue(args: SayFlagArgs): string | null {
|
|
158
|
+
for (const name of STRING_FLAGS) {
|
|
159
|
+
const value = (args as Record<string, unknown>)[name];
|
|
160
|
+
if (value === true || value === "") return `--${name} needs a value`;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
126
165
|
/** Validates the encoder flags before the engine is spawned: faster failure in scripts (the engine repeats them authoritatively). */
|
|
127
166
|
export function resolveSayFlags(args: SayFlagArgs): ResolvedSayFlags {
|
|
167
|
+
const missing = missingFlagValue(args);
|
|
168
|
+
if (missing) return { ok: false, error: missing };
|
|
169
|
+
|
|
128
170
|
const format = parseFormatFlag(args.format);
|
|
129
171
|
if (!format.ok) return format;
|
|
130
172
|
const rate = parseRateFlag(args.rate);
|
|
@@ -140,6 +182,9 @@ export function resolveSayFlags(args: SayFlagArgs): ResolvedSayFlags {
|
|
|
140
182
|
|
|
141
183
|
return {
|
|
142
184
|
ok: true,
|
|
185
|
+
voice: typeof args.voice === "string" ? args.voice : undefined,
|
|
186
|
+
lang: typeof args.lang === "string" ? args.lang : undefined,
|
|
187
|
+
out,
|
|
143
188
|
format: format.value,
|
|
144
189
|
rate: rate.value,
|
|
145
190
|
bitrate: bitrate.value,
|
|
@@ -147,6 +192,24 @@ export function resolveSayFlags(args: SayFlagArgs): ResolvedSayFlags {
|
|
|
147
192
|
};
|
|
148
193
|
}
|
|
149
194
|
|
|
195
|
+
const STDIO_ALIASES = ["/dev/stdout", "/dev/stderr", "/dev/stdin"];
|
|
196
|
+
|
|
197
|
+
/** `/dev/fd/N` stats as whatever the descriptor points at, so the name decides where stat cannot. */
|
|
198
|
+
function isDeviceDestination(out: string): boolean {
|
|
199
|
+
if (STDIO_ALIASES.includes(out) || /^\/dev\/fd\/\d+$/.test(out)) return true;
|
|
200
|
+
try {
|
|
201
|
+
return statSync(out).isCharacterDevice();
|
|
202
|
+
} catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The engine writes `--out` from its own process, whose stdout is the CLI's pipe, so a device destination swallows the audio and still reports success (#T1-15). */
|
|
208
|
+
export function deviceOutRefusal(out: string | undefined): string | null {
|
|
209
|
+
if (out === undefined || !isDeviceDestination(out)) return null;
|
|
210
|
+
return `--out ${out} is a character device, where the audio would be discarded; omit --out to write it to stdout`;
|
|
211
|
+
}
|
|
212
|
+
|
|
150
213
|
type SayOpts = {
|
|
151
214
|
text: string;
|
|
152
215
|
voice: string | undefined;
|
|
@@ -177,6 +240,11 @@ function recordOutputArtifact(
|
|
|
177
240
|
return { outputFormat: opts.format ?? "wav", outputSizeBytes: audio.byteLength };
|
|
178
241
|
}
|
|
179
242
|
|
|
243
|
+
/** citty's parser reads `--no-expand-abbrev` as the negation of a boolean `expand-abbrev`, so the declared key is never set (same trap `--no-vad` works around in main.ts). */
|
|
244
|
+
export function noExpandAbbrevRequested(args: Record<string, unknown>, rawArgs: string[]): boolean {
|
|
245
|
+
return rawArgs.includes("--no-expand-abbrev") || args["no-expand-abbrev"] === true;
|
|
246
|
+
}
|
|
247
|
+
|
|
180
248
|
async function synthesizeAndEmit(
|
|
181
249
|
opts: SayOpts,
|
|
182
250
|
verbose: boolean,
|
|
@@ -280,7 +348,7 @@ export const sayCommand = defineCommand({
|
|
|
280
348
|
default: false,
|
|
281
349
|
},
|
|
282
350
|
},
|
|
283
|
-
async run({ args }) {
|
|
351
|
+
async run({ args, rawArgs }) {
|
|
284
352
|
if (args.debug) log.debugEnabled = true;
|
|
285
353
|
if (args["list-voices"]) {
|
|
286
354
|
let ids: string[];
|
|
@@ -296,32 +364,45 @@ export const sayCommand = defineCommand({
|
|
|
296
364
|
|
|
297
365
|
const flags = resolveSayFlags(args);
|
|
298
366
|
if (!flags.ok) {
|
|
299
|
-
log.error(flags.error);
|
|
367
|
+
log.error(renderInvalidArg(flags.error));
|
|
368
|
+
process.exit(2);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const deviceOut = deviceOutRefusal(flags.out);
|
|
372
|
+
if (deviceOut) {
|
|
373
|
+
log.error(renderInvalidArg(deviceOut));
|
|
300
374
|
process.exit(2);
|
|
301
375
|
}
|
|
302
376
|
|
|
303
377
|
const inlineText = typeof args.text === "string" ? args.text : undefined;
|
|
304
378
|
const stdinIsTty = (process.stdin as { isTTY?: boolean }).isTTY;
|
|
305
379
|
if (shouldRejectMissingSayText(inlineText, stdinIsTty)) {
|
|
306
|
-
log.error("kesha say requires text or piped stdin. Usage: kesha say <text>");
|
|
380
|
+
log.error(renderInvalidArg("kesha say requires text or piped stdin. Usage: kesha say <text>"));
|
|
307
381
|
process.exit(2);
|
|
308
382
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
383
|
+
let text: string;
|
|
384
|
+
try {
|
|
385
|
+
text = await resolveText(inlineText);
|
|
386
|
+
validateSayText(text);
|
|
387
|
+
} catch (err) {
|
|
388
|
+
log.error(errorMessage(err));
|
|
389
|
+
process.exit(exitCodeFor(err));
|
|
390
|
+
}
|
|
391
|
+
const explicitVoice = flags.voice;
|
|
392
|
+
const langHint = flags.lang;
|
|
312
393
|
const voice = await resolveSayVoice(explicitVoice, langHint, text);
|
|
313
394
|
|
|
314
395
|
const opts: SayOpts = {
|
|
315
396
|
text,
|
|
316
397
|
voice,
|
|
317
398
|
lang: langHint,
|
|
318
|
-
out:
|
|
399
|
+
out: flags.out,
|
|
319
400
|
rate: flags.rate,
|
|
320
401
|
ssml: Boolean(args.ssml),
|
|
321
402
|
format: flags.format,
|
|
322
403
|
bitrate: flags.bitrate,
|
|
323
404
|
sampleRate: flags.sampleRate,
|
|
324
|
-
noExpandAbbrev:
|
|
405
|
+
noExpandAbbrev: noExpandAbbrevRequested(args, rawArgs),
|
|
325
406
|
};
|
|
326
407
|
|
|
327
408
|
const outcome = await runCommandSession(
|