@drakulavich/kesha-voice-kit 1.29.1 → 1.30.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/NOTICES.md +1 -1
- package/package.json +4 -3
- package/src/cli/command-session.ts +11 -0
- package/src/cli/init.ts +6 -4
- package/src/cli/install.ts +17 -11
- package/src/cli/main.ts +14 -15
- package/src/cli/record.ts +10 -3
- package/src/cli/say.ts +13 -31
- package/src/doctor.ts +2 -1
- package/src/engine/describe.ts +227 -0
- package/src/engine/events.ts +235 -0
- package/src/engine-health.ts +14 -14
- package/src/engine-install.ts +132 -96
- package/src/engine-targets.ts +3 -3
- package/src/engine.ts +244 -380
- package/src/error-utils.ts +4 -1
- package/src/fluid-asr-cache.ts +3 -3
- package/src/fluid-roots.ts +4 -4
- package/src/install-lock.ts +10 -9
- package/src/kokoro-ane.ts +4 -4
- package/src/lib.ts +4 -2
- package/src/mcp/voices.ts +3 -37
- package/src/progress.ts +34 -2
- package/src/status.ts +9 -7
- package/src/synth.ts +68 -91
- package/src/transcribe.ts +18 -25
- package/src/error-codes.ts +0 -45
package/NOTICES.md
CHANGED
|
@@ -19,7 +19,7 @@ see [speechbrain/lang-id-voxlingua107-ecapa](https://huggingface.co/speechbrain/
|
|
|
19
19
|
### Silero VAD v5 (opt-in: `kesha install --vad`)
|
|
20
20
|
|
|
21
21
|
Voice activity detection. MIT. Authored by [Silero Team](https://github.com/snakers4/silero-vad).
|
|
22
|
-
Pinned to the `v6.2.1` tag.
|
|
22
|
+
Pinned to a commit (the `v6.2.1` tag's target at the time of pinning).
|
|
23
23
|
|
|
24
24
|
## Models downloaded by `kesha install --tts`
|
|
25
25
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.0",
|
|
4
4
|
"description": "Give your tools a voice — speech to text and back, 25 languages, up to ~19× faster than Whisper. On your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
}
|
|
41
41
|
},
|
|
42
42
|
"keshaEngine": {
|
|
43
|
-
"version": "1.
|
|
43
|
+
"version": "1.25.0"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"test": "bun test --timeout 30000",
|
|
@@ -109,6 +109,7 @@
|
|
|
109
109
|
"overrides": {
|
|
110
110
|
"hono": "^4.12.25",
|
|
111
111
|
"fast-uri": "^3.1.5",
|
|
112
|
-
"ip-address": "^10.3.1"
|
|
112
|
+
"ip-address": "^10.3.1",
|
|
113
|
+
"smol-toml": "1.7.2"
|
|
113
114
|
}
|
|
114
115
|
}
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
DiagnosticLogSession,
|
|
5
5
|
DiagnosticSessionStatus,
|
|
6
6
|
} from "../diagnostic-log";
|
|
7
|
+
import { setEngineDebugSink } from "../engine/events";
|
|
7
8
|
import { errorMessage } from "../error-utils";
|
|
8
9
|
import { log } from "../log";
|
|
9
10
|
import { createStatsRecorder } from "../stats";
|
|
@@ -46,12 +47,22 @@ export async function runCommandSession(
|
|
|
46
47
|
};
|
|
47
48
|
session.diagnosticLog.event("command.start", { command, ...startFields });
|
|
48
49
|
|
|
50
|
+
setEngineDebugSink((event) =>
|
|
51
|
+
session.diagnosticLog.event("engine.debug", {
|
|
52
|
+
t_ms: event.t_ms,
|
|
53
|
+
event: event.event ?? null,
|
|
54
|
+
message: event.message,
|
|
55
|
+
fields: event.fields === undefined ? null : JSON.stringify(event.fields),
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
49
58
|
let outcome: CommandOutcome;
|
|
50
59
|
try {
|
|
51
60
|
outcome = await body(session);
|
|
52
61
|
} catch (err) {
|
|
53
62
|
closeSessionQuietly(session, command, { status: "failed", itemCount: 0 });
|
|
54
63
|
throw err;
|
|
64
|
+
} finally {
|
|
65
|
+
setEngineDebugSink(null);
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
// A flush error must not displace a failure the command already has to report (Greptile P1/P2 on #607).
|
package/src/cli/init.ts
CHANGED
|
@@ -2,13 +2,15 @@ import { defineCommand } from "citty";
|
|
|
2
2
|
import { confirm, multiselect, isCancel, cancel } from "@clack/prompts";
|
|
3
3
|
import { installCommandTokens, renderInstallPlan } from "../install-plan";
|
|
4
4
|
import { log } from "../log";
|
|
5
|
+
import { errorMessage } from "../error-utils";
|
|
6
|
+
import { exitCodeFor } from "../engine/events";
|
|
5
7
|
import { getEngineCapabilities } from "../engine";
|
|
6
8
|
import {
|
|
7
9
|
installableTtsLangs,
|
|
8
10
|
performInstall,
|
|
9
11
|
resolveBackendFlag,
|
|
10
12
|
resolveNoCacheFlag,
|
|
11
|
-
|
|
13
|
+
unavailableBackendRefusal,
|
|
12
14
|
} from "./install";
|
|
13
15
|
import type { SharedInstallArgs } from "./types";
|
|
14
16
|
|
|
@@ -176,10 +178,10 @@ export async function promptInitSelection(
|
|
|
176
178
|
|
|
177
179
|
/** Mirrors the guard in `performInstall`: a preview must not describe an install this platform rejects (#684). */
|
|
178
180
|
async function printPlan(selection: InitSelection): Promise<boolean> {
|
|
179
|
-
const backendError =
|
|
181
|
+
const backendError = unavailableBackendRefusal(selection.backend);
|
|
180
182
|
if (backendError) {
|
|
181
|
-
log.error(backendError);
|
|
182
|
-
process.exitCode =
|
|
183
|
+
log.error(errorMessage(backendError));
|
|
184
|
+
process.exitCode = exitCodeFor(backendError);
|
|
183
185
|
return false;
|
|
184
186
|
}
|
|
185
187
|
log.info(
|
package/src/cli/install.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
|
-
import { installEngine } from "../engine-install";
|
|
3
|
+
import { assertPlatformCanInstall, installEngine } from "../engine-install";
|
|
4
4
|
import { engineTarget, isDarwinArm64 } from "../engine-targets";
|
|
5
5
|
import { getEngineBinPath, getEngineCapabilities, type EngineCapabilities } from "../engine";
|
|
6
|
+
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
6
7
|
import { renderInstallPlan } from "../install-plan";
|
|
7
8
|
import { maybeAskForStar } from "../star";
|
|
8
9
|
import { log } from "../log";
|
|
@@ -171,6 +172,12 @@ function finishInstallDiagnostic(
|
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
/** The refusal `install`, `install --plan` and `init --plan` all render the same way (#684). */
|
|
176
|
+
export function unavailableBackendRefusal(backend: string | undefined): KeshaError | null {
|
|
177
|
+
const message = unavailableBackendError(backend);
|
|
178
|
+
return message ? new KeshaError("E_INVALID_ARG", message) : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
174
181
|
/** Null when the requested backend is installable here. `KESHA_ENGINE_BIN` opts out — the user supplied their own engine. */
|
|
175
182
|
export function unavailableBackendError(backend: string | undefined): string | null {
|
|
176
183
|
const platformBackend = defaultBackendForPlatform();
|
|
@@ -197,11 +204,11 @@ export async function performInstall(options: PerformInstallOptions) {
|
|
|
197
204
|
const { noCache, backend, ttsLangs, vad = false, diarize = false, plan = false, engineVersion } =
|
|
198
205
|
options;
|
|
199
206
|
// A plan for an unavailable backend previews what its own printed command rejects (#684).
|
|
200
|
-
const backendError =
|
|
207
|
+
const backendError = unavailableBackendRefusal(backend);
|
|
201
208
|
if (plan) {
|
|
202
209
|
if (backendError) {
|
|
203
|
-
log.error(backendError);
|
|
204
|
-
process.exitCode =
|
|
210
|
+
log.error(errorMessage(backendError));
|
|
211
|
+
process.exitCode = exitCodeFor(backendError);
|
|
205
212
|
return;
|
|
206
213
|
}
|
|
207
214
|
log.info(
|
|
@@ -225,16 +232,15 @@ export async function performInstall(options: PerformInstallOptions) {
|
|
|
225
232
|
engineVersionOverride: engineVersion !== undefined,
|
|
226
233
|
});
|
|
227
234
|
|
|
228
|
-
|
|
235
|
+
try {
|
|
236
|
+
assertPlatformCanInstall({ diarize });
|
|
237
|
+
} catch (err) {
|
|
229
238
|
errorKind = "validation_failed";
|
|
230
|
-
throw
|
|
231
|
-
"--diarize is currently darwin-arm64 only " +
|
|
232
|
-
"(see https://github.com/drakulavich/kesha-voice-kit/issues/199).",
|
|
233
|
-
);
|
|
239
|
+
throw err;
|
|
234
240
|
}
|
|
235
241
|
if (backendError) {
|
|
236
242
|
errorKind = "validation_failed";
|
|
237
|
-
throw
|
|
243
|
+
throw backendError;
|
|
238
244
|
}
|
|
239
245
|
await installEngine({ noCache, backend, ttsLangs, vad, diarize, version: engineVersion });
|
|
240
246
|
await maybeAskForStar(getEngineBinPath(), packageVersion, log);
|
|
@@ -248,7 +254,7 @@ export async function performInstall(options: PerformInstallOptions) {
|
|
|
248
254
|
const message = errorMessage(err);
|
|
249
255
|
finishInstallDiagnostic(diagnosticLog, startedAt, "failed", errorKind);
|
|
250
256
|
log.error(message);
|
|
251
|
-
process.exit(1);
|
|
257
|
+
process.exit(err instanceof KeshaError ? exitCodeFor(err) : 1);
|
|
252
258
|
}
|
|
253
259
|
}
|
|
254
260
|
|
package/src/cli/main.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { defineCommand } from "citty";
|
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
3
|
import { existsSync, statSync } from "fs";
|
|
4
4
|
import { detectAll } from "tinyld";
|
|
5
|
-
import {
|
|
5
|
+
import { transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
|
|
6
6
|
import { detectAudioLanguageEngine, detectTextLanguageEngine } from "../engine";
|
|
7
7
|
import type { LangDetectResult } from "../engine";
|
|
8
8
|
import { log } from "../log";
|
|
@@ -23,7 +23,7 @@ import { diagnosticSizeBucket } from "../diagnostic-events";
|
|
|
23
23
|
import { runCommandSession, type CommandSession } from "./command-session";
|
|
24
24
|
import { USAGE_MESSAGE } from "./dispatch";
|
|
25
25
|
import type { CliContext } from "./context";
|
|
26
|
-
import {
|
|
26
|
+
import { KeshaError } from "../engine/events";
|
|
27
27
|
|
|
28
28
|
interface MainCommandArgs {
|
|
29
29
|
_: string[];
|
|
@@ -301,26 +301,25 @@ async function processFile(
|
|
|
301
301
|
const { stats, diagnosticLog } = recorders;
|
|
302
302
|
|
|
303
303
|
if (!existsSync(file)) {
|
|
304
|
-
|
|
304
|
+
const err = new KeshaError("E_INPUT_NOT_FOUND", "File not found");
|
|
305
|
+
stats.recordError("input", err, err.code);
|
|
305
306
|
diagnosticLog.event("input.missing", {
|
|
306
307
|
command: "transcribe",
|
|
307
|
-
error_code:
|
|
308
|
+
error_code: err.code,
|
|
308
309
|
});
|
|
309
|
-
log.error(`${file}:
|
|
310
|
-
return { ok: false, error: { file, code:
|
|
310
|
+
log.error(`${file}: ${errorMessage(err)}`);
|
|
311
|
+
return { ok: false, error: { file, code: err.code, message: err.message } };
|
|
311
312
|
}
|
|
312
313
|
|
|
313
314
|
if (isDirectoryPath(file)) {
|
|
314
|
-
|
|
315
|
+
const err = new KeshaError("E_INVALID_ARG", "is a directory (expected an audio file)");
|
|
316
|
+
stats.recordError("input", err, err.code);
|
|
315
317
|
diagnosticLog.event("input.invalid", {
|
|
316
318
|
command: "transcribe",
|
|
317
|
-
error_code:
|
|
319
|
+
error_code: err.code,
|
|
318
320
|
});
|
|
319
|
-
log.error(`${file}:
|
|
320
|
-
return {
|
|
321
|
-
ok: false,
|
|
322
|
-
error: { file, code: TS_NATIVE_CODES.INVALID_ARG, message: "is a directory (expected an audio file)" },
|
|
323
|
-
};
|
|
321
|
+
log.error(`${file}: ${errorMessage(err)}`);
|
|
322
|
+
return { ok: false, error: { file, code: err.code, message: err.message } };
|
|
324
323
|
}
|
|
325
324
|
|
|
326
325
|
const inputArtifact = artifactFromFile(file, "input_audio");
|
|
@@ -343,7 +342,7 @@ async function processFile(
|
|
|
343
342
|
else write();
|
|
344
343
|
};
|
|
345
344
|
try {
|
|
346
|
-
await
|
|
345
|
+
await validateTranscribeRequest({ vad: vadMode, timestamps, speakers, itn });
|
|
347
346
|
progress = reportProgress
|
|
348
347
|
? createPercentProgress(`Transcribing ${file}`, {
|
|
349
348
|
estimatedTotalMs: speakers ? 60 * 60 * 1000 : 30 * 60 * 1000,
|
|
@@ -394,8 +393,8 @@ async function processFile(
|
|
|
394
393
|
return { ok: true, result };
|
|
395
394
|
} catch (err: unknown) {
|
|
396
395
|
progress?.stop();
|
|
396
|
+
const code = err instanceof KeshaError ? err.code : "E_TRANSCRIBE_FAILED";
|
|
397
397
|
const stderrText = errorMessage(err);
|
|
398
|
-
const code = extractEngineErrorCode(stderrText) ?? ENGINE_CODES.TRANSCRIBE_FAILED;
|
|
399
398
|
stats.recordError("transcribe", err, code);
|
|
400
399
|
diagnosticLog.event("engine.exit", {
|
|
401
400
|
command: "transcribe",
|
package/src/cli/record.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
|
+
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
3
4
|
import {
|
|
4
5
|
isEngineInstalled,
|
|
5
|
-
preflightRecordLive,
|
|
6
6
|
recordEngine,
|
|
7
|
+
validateRecordRequest,
|
|
7
8
|
type LiveAutoStopOptions,
|
|
8
9
|
type RecordTarget,
|
|
9
10
|
} from "../engine";
|
|
10
11
|
import { installHint } from "../install-hint";
|
|
11
12
|
import { log } from "../log";
|
|
13
|
+
import { getPendingSignalExitCode, waitForPendingSignalCleanup } from "../process-tree";
|
|
12
14
|
|
|
13
15
|
export interface RecordArgs {
|
|
14
16
|
out?: string;
|
|
@@ -203,11 +205,16 @@ export const recordCommand = defineCommand({
|
|
|
203
205
|
process.exit(1);
|
|
204
206
|
}
|
|
205
207
|
try {
|
|
206
|
-
|
|
208
|
+
await validateRecordRequest(resolved.target, resolved.maxSeconds);
|
|
207
209
|
await recordEngine(resolved.target, resolved.maxSeconds);
|
|
208
210
|
} catch (err) {
|
|
211
|
+
const signalExitCode = getPendingSignalExitCode();
|
|
212
|
+
if (signalExitCode !== null) {
|
|
213
|
+
await waitForPendingSignalCleanup();
|
|
214
|
+
process.exit(signalExitCode);
|
|
215
|
+
}
|
|
209
216
|
log.error(errorMessage(err));
|
|
210
|
-
process.exit(1);
|
|
217
|
+
process.exit(err instanceof KeshaError ? exitCodeFor(err) : 1);
|
|
211
218
|
}
|
|
212
219
|
},
|
|
213
220
|
});
|
package/src/cli/say.ts
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
|
-
import {
|
|
4
|
-
getEngineBinPath,
|
|
5
|
-
isEngineInstalled,
|
|
6
|
-
spawnEngineProcess,
|
|
7
|
-
spawnStdioWithDebugFd,
|
|
8
|
-
} from "../engine";
|
|
9
|
-
import { installHint } from "../install-hint";
|
|
10
|
-
import { registerProcessTree } from "../process-tree";
|
|
3
|
+
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
11
4
|
import { log } from "../log";
|
|
12
5
|
import {
|
|
6
|
+
listVoiceIds,
|
|
13
7
|
say,
|
|
14
|
-
SayError,
|
|
15
8
|
SUPPORTED_SAMPLE_RATES,
|
|
16
9
|
type SayFormat,
|
|
17
10
|
type SayOptions,
|
|
@@ -216,16 +209,16 @@ async function synthesizeAndEmit(
|
|
|
216
209
|
},
|
|
217
210
|
};
|
|
218
211
|
} catch (err) {
|
|
219
|
-
const code = err instanceof
|
|
220
|
-
const exitCode = err
|
|
212
|
+
const code = err instanceof KeshaError ? err.code : "E_INTERNAL";
|
|
213
|
+
const exitCode = exitCodeFor(err);
|
|
221
214
|
stats.recordError("tts", err, code);
|
|
222
|
-
log.error(
|
|
215
|
+
log.error(errorMessage(err));
|
|
223
216
|
return {
|
|
224
217
|
status: "failed",
|
|
225
218
|
itemCount: 1,
|
|
226
219
|
exitCode,
|
|
227
220
|
finishFields: {
|
|
228
|
-
errorKind: err instanceof
|
|
221
|
+
errorKind: err instanceof KeshaError ? "say_error" : "error",
|
|
229
222
|
exitCode,
|
|
230
223
|
error_code: code,
|
|
231
224
|
},
|
|
@@ -290,26 +283,15 @@ export const sayCommand = defineCommand({
|
|
|
290
283
|
async run({ args }) {
|
|
291
284
|
if (args.debug) log.debugEnabled = true;
|
|
292
285
|
if (args["list-voices"]) {
|
|
293
|
-
|
|
294
|
-
log.error(`kesha-engine not installed. run: ${installHint()}`);
|
|
295
|
-
process.exit(1);
|
|
296
|
-
}
|
|
297
|
-
// The engine prints the list directly — just relay its stdout + exit code.
|
|
298
|
-
const proc = spawnEngineProcess(
|
|
299
|
-
getEngineBinPath(),
|
|
300
|
-
["say", "--list-voices"],
|
|
301
|
-
spawnStdioWithDebugFd(["inherit", "inherit", "inherit"]),
|
|
302
|
-
);
|
|
303
|
-
// Register so a Ctrl-C during a cold Engine load terminates it and exits 130/143 (#939);
|
|
304
|
-
// dispose before the process.exit below so the registration never outlives the run.
|
|
305
|
-
const tree = registerProcessTree(proc);
|
|
306
|
-
let exitCode: number;
|
|
286
|
+
let ids: string[];
|
|
307
287
|
try {
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
|
|
288
|
+
ids = await listVoiceIds({ onProgress: (line) => log.status(line) });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
log.error(errorMessage(err));
|
|
291
|
+
process.exit(exitCodeFor(err));
|
|
311
292
|
}
|
|
312
|
-
|
|
293
|
+
await Bun.write(Bun.stdout, ids.map((id) => `${id}\n`).join(""));
|
|
294
|
+
process.exit(0);
|
|
313
295
|
}
|
|
314
296
|
|
|
315
297
|
const flags = resolveSayFlags(args);
|
package/src/doctor.ts
CHANGED
|
@@ -34,7 +34,6 @@ const KNOWN_ENV_KEYS = [
|
|
|
34
34
|
"KESHA_MODEL_MIRROR",
|
|
35
35
|
"KESHA_STATS_DB",
|
|
36
36
|
"KESHA_DEBUG",
|
|
37
|
-
"KESHA_DEBUG_FD",
|
|
38
37
|
"KESHA_KOKORO_COMPUTE_UNITS",
|
|
39
38
|
"KESHA_DIARIZE_COMPUTE_UNITS",
|
|
40
39
|
"KESHA_DIARIZE_TIMEOUT_SECS",
|
|
@@ -221,6 +220,8 @@ async function collectEngine(redact: boolean): Promise<DoctorReport["engine"]> {
|
|
|
221
220
|
probeError = `binary is present but does not run (${health.detail}); re-run \`kesha install\``;
|
|
222
221
|
} else if (health.status === "mute") {
|
|
223
222
|
probeError = `${health.detail}; re-run \`kesha install\``;
|
|
223
|
+
} else if (health.status === "protocol") {
|
|
224
|
+
probeError = health.detail;
|
|
224
225
|
} else if (health.status === "ok") {
|
|
225
226
|
capabilities = health.capabilities;
|
|
226
227
|
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { KeshaError } from "./events";
|
|
2
|
+
|
|
3
|
+
export const PROTOCOL_VERSION = 4;
|
|
4
|
+
|
|
5
|
+
export type Gate = null | string | string[];
|
|
6
|
+
|
|
7
|
+
export interface FlagSchema {
|
|
8
|
+
gate: Gate;
|
|
9
|
+
requires?: string[];
|
|
10
|
+
conflicts?: string[];
|
|
11
|
+
whenUngated?: "reject" | "drop";
|
|
12
|
+
values?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CommandSchema {
|
|
16
|
+
flags: Record<string, FlagSchema>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TtsLanguageCapability {
|
|
20
|
+
code: string;
|
|
21
|
+
engines: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EngineCapabilities {
|
|
25
|
+
protocolVersion: number;
|
|
26
|
+
backend: string;
|
|
27
|
+
features: string[];
|
|
28
|
+
tts?: { languages: TtsLanguageCapability[] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DescribeDocument {
|
|
32
|
+
protocolVersion: number;
|
|
33
|
+
backend: string;
|
|
34
|
+
profile: string;
|
|
35
|
+
features: string[];
|
|
36
|
+
commands: Record<string, CommandSchema>;
|
|
37
|
+
tts?: { languages: TtsLanguageCapability[] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const UPGRADE_CLI = "bun add -g @drakulavich/kesha-voice-kit@latest";
|
|
41
|
+
|
|
42
|
+
/** Remedies validateArgv attaches when a flag's gate is missing; keyed `<command> --<flag>`. */
|
|
43
|
+
const GATE_HINTS: Record<string, string> = {
|
|
44
|
+
"transcribe --speakers":
|
|
45
|
+
"speaker diarization is darwin-arm64 only (https://github.com/drakulavich/kesha-voice-kit/issues/199)",
|
|
46
|
+
"transcribe --itn": `the installed engine predates the written-form pass; run \`${UPGRADE_CLI}\`, then \`kesha install\``,
|
|
47
|
+
"record --live":
|
|
48
|
+
"live transcription needs the CoreML engine on Apple Silicon; elsewhere record to a file, then transcribe it: `kesha record --out note.wav` and `kesha note.wav`",
|
|
49
|
+
"record --auto-stop": "live auto-stop needs a newer CoreML engine with Silero VAD endpointing; run `kesha install`",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Remedies validateArgv attaches when two present flags conflict; keyed `<command> --<flag> --<other>`. */
|
|
53
|
+
const CONFLICT_HINTS: Record<string, string> = {
|
|
54
|
+
"transcribe --speakers --no-vad":
|
|
55
|
+
"speaker labels attach to VAD-windowed speech segments, and disabling VAD leaves the whole file as one segment with nothing to label; drop --no-vad (VAD engages automatically for --speakers), or drop --speakers",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function isStringArray(value: unknown): value is string[] {
|
|
59
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseGate(value: unknown): Gate | undefined {
|
|
63
|
+
if (value === null || typeof value === "string") return value;
|
|
64
|
+
if (isStringArray(value)) return value;
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseFlag(raw: unknown): FlagSchema | null {
|
|
69
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
70
|
+
const o = raw as Record<string, unknown>;
|
|
71
|
+
if (!("gate" in o)) return null;
|
|
72
|
+
const gate = parseGate(o.gate);
|
|
73
|
+
if (gate === undefined) return null;
|
|
74
|
+
const flag: FlagSchema = { gate };
|
|
75
|
+
if (o.requires !== undefined) {
|
|
76
|
+
if (!isStringArray(o.requires)) return null;
|
|
77
|
+
flag.requires = o.requires;
|
|
78
|
+
}
|
|
79
|
+
if (o.conflicts !== undefined) {
|
|
80
|
+
if (!isStringArray(o.conflicts)) return null;
|
|
81
|
+
flag.conflicts = o.conflicts;
|
|
82
|
+
}
|
|
83
|
+
if (o.whenUngated !== undefined) {
|
|
84
|
+
if (o.whenUngated !== "reject" && o.whenUngated !== "drop") return null;
|
|
85
|
+
flag.whenUngated = o.whenUngated;
|
|
86
|
+
}
|
|
87
|
+
if (o.values !== undefined) {
|
|
88
|
+
if (typeof o.values !== "string") return null;
|
|
89
|
+
flag.values = o.values;
|
|
90
|
+
}
|
|
91
|
+
return flag;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseCommands(raw: unknown): Record<string, CommandSchema> | null {
|
|
95
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
96
|
+
const commands: Record<string, CommandSchema> = {};
|
|
97
|
+
for (const [name, entry] of Object.entries(raw as Record<string, unknown>)) {
|
|
98
|
+
const flagsRaw = (entry as { flags?: unknown } | null)?.flags;
|
|
99
|
+
if (!flagsRaw || typeof flagsRaw !== "object" || Array.isArray(flagsRaw)) return null;
|
|
100
|
+
const flags: Record<string, FlagSchema> = {};
|
|
101
|
+
for (const [flagName, flagRaw] of Object.entries(flagsRaw as Record<string, unknown>)) {
|
|
102
|
+
const flag = parseFlag(flagRaw);
|
|
103
|
+
if (!flag) return null;
|
|
104
|
+
flags[flagName] = flag;
|
|
105
|
+
}
|
|
106
|
+
commands[name] = { flags };
|
|
107
|
+
}
|
|
108
|
+
return commands;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseTtsLanguages(raw: unknown): TtsLanguageCapability[] | null {
|
|
112
|
+
const languages = (raw as { languages?: unknown } | null | undefined)?.languages;
|
|
113
|
+
if (!Array.isArray(languages)) return null;
|
|
114
|
+
const out: TtsLanguageCapability[] = [];
|
|
115
|
+
for (const entry of languages) {
|
|
116
|
+
const l = entry as Record<string, unknown> | null;
|
|
117
|
+
if (typeof l?.code !== "string" || !isStringArray(l.engines)) return null;
|
|
118
|
+
out.push({ code: l.code, engines: l.engines });
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A non-null return means the engine described itself; every field the CLI reads is rebuilt, never cast (#647, #928). */
|
|
124
|
+
export function parseDescribe(parsed: unknown): DescribeDocument | null {
|
|
125
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
126
|
+
const { protocolVersion, backend, profile, features, commands, tts } = parsed as Record<string, unknown>;
|
|
127
|
+
if (typeof protocolVersion !== "number") return null;
|
|
128
|
+
if (typeof backend !== "string" || typeof profile !== "string") return null;
|
|
129
|
+
if (!isStringArray(features)) return null;
|
|
130
|
+
const parsedCommands = parseCommands(commands);
|
|
131
|
+
if (!parsedCommands) return null;
|
|
132
|
+
const doc: DescribeDocument = { protocolVersion, backend, profile, features, commands: parsedCommands };
|
|
133
|
+
if (tts !== undefined) {
|
|
134
|
+
const languages = parseTtsLanguages(tts);
|
|
135
|
+
if (!languages) return null;
|
|
136
|
+
doc.tts = { languages };
|
|
137
|
+
}
|
|
138
|
+
return doc;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function protocolMismatch(doc: DescribeDocument, binPath: string): KeshaError | null {
|
|
142
|
+
if (doc.protocolVersion === PROTOCOL_VERSION) return null;
|
|
143
|
+
const stale = doc.protocolVersion < PROTOCOL_VERSION;
|
|
144
|
+
return new KeshaError(
|
|
145
|
+
"E_ENGINE_PROTOCOL",
|
|
146
|
+
`kesha-engine at ${binPath} speaks protocol ${doc.protocolVersion}; this CLI speaks protocol ${PROTOCOL_VERSION}`,
|
|
147
|
+
{
|
|
148
|
+
hint: stale
|
|
149
|
+
? "run `kesha install` to fetch the engine this CLI expects"
|
|
150
|
+
: `the engine is newer than this CLI; run \`${UPGRADE_CLI}\``,
|
|
151
|
+
versionMismatch: true,
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function describeToCapabilities(doc: DescribeDocument): EngineCapabilities {
|
|
157
|
+
const caps: EngineCapabilities = {
|
|
158
|
+
protocolVersion: doc.protocolVersion,
|
|
159
|
+
backend: doc.backend,
|
|
160
|
+
features: doc.features,
|
|
161
|
+
};
|
|
162
|
+
if (doc.tts) caps.tts = doc.tts;
|
|
163
|
+
return caps;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function gateSatisfied(gate: Gate, features: string[]): boolean {
|
|
167
|
+
if (gate === null) return true;
|
|
168
|
+
if (typeof gate === "string") return features.includes(gate);
|
|
169
|
+
return gate.some((f) => features.includes(f));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function gateText(gate: Gate): string {
|
|
173
|
+
return typeof gate === "string" ? gate : `one of ${(gate as string[]).join(", ")}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function flagNameAt(argv: string[], index: number): string | null {
|
|
177
|
+
const token = argv[index]!;
|
|
178
|
+
if (!token.startsWith("--") || token === "--") return null;
|
|
179
|
+
const eq = token.indexOf("=");
|
|
180
|
+
return (eq === -1 ? token : token.slice(0, eq)).slice(2);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Checks an engine argv (subcommand first) against the describe document; unknown flags, absent gates, missing requires and present conflicts are E_INVALID_ARG, a whenUngated: drop flag is removed with one warning. */
|
|
184
|
+
export function validateArgv(argv: string[], doc: DescribeDocument): { argv: string[]; warnings: string[] } {
|
|
185
|
+
const command = argv[0] ?? "";
|
|
186
|
+
const schema = doc.commands[command];
|
|
187
|
+
if (!schema) {
|
|
188
|
+
throw new KeshaError("E_INVALID_ARG", `kesha-engine has no \`${command}\` subcommand`);
|
|
189
|
+
}
|
|
190
|
+
const build = `${doc.backend}/${doc.profile}`;
|
|
191
|
+
const present = new Set<string>();
|
|
192
|
+
const drop = new Set<number>();
|
|
193
|
+
const warnings: string[] = [];
|
|
194
|
+
for (let i = 1; i < argv.length; i++) {
|
|
195
|
+
if (argv[i] === "--") break;
|
|
196
|
+
const name = flagNameAt(argv, i);
|
|
197
|
+
if (name === null) continue;
|
|
198
|
+
const flag = schema.flags[name];
|
|
199
|
+
if (!flag) {
|
|
200
|
+
throw new KeshaError("E_INVALID_ARG", `kesha-engine ${command} does not accept --${name}`);
|
|
201
|
+
}
|
|
202
|
+
if (!gateSatisfied(flag.gate, doc.features)) {
|
|
203
|
+
const needs = `--${name} needs ${gateText(flag.gate)}, which this kesha-engine build (${build}) does not provide`;
|
|
204
|
+
if (flag.whenUngated === "drop") {
|
|
205
|
+
drop.add(i);
|
|
206
|
+
warnings.push(`${needs}; the flag was ignored`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
throw new KeshaError("E_INVALID_ARG", needs, { hint: GATE_HINTS[`${command} --${name}`] });
|
|
210
|
+
}
|
|
211
|
+
present.add(name);
|
|
212
|
+
}
|
|
213
|
+
for (const name of present) {
|
|
214
|
+
const flag = schema.flags[name]!;
|
|
215
|
+
for (const req of flag.requires ?? []) {
|
|
216
|
+
if (!present.has(req)) throw new KeshaError("E_INVALID_ARG", `--${name} requires --${req}`);
|
|
217
|
+
}
|
|
218
|
+
for (const other of flag.conflicts ?? []) {
|
|
219
|
+
if (present.has(other)) {
|
|
220
|
+
throw new KeshaError("E_INVALID_ARG", `--${name} cannot be combined with --${other}`, {
|
|
221
|
+
hint: CONFLICT_HINTS[`${command} --${name} --${other}`],
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { argv: argv.filter((_, i) => !drop.has(i)), warnings };
|
|
227
|
+
}
|