@drakulavich/kesha-voice-kit 2.0.0 → 2.2.0-beta.1
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 +13 -13
- package/docs/product-positioning.md +1 -1
- package/package.json +15 -14
- package/src/cli/dispatch.ts +28 -10
- package/src/cli/init.ts +15 -50
- package/src/cli/install.ts +1 -1
- package/src/cli/logs.ts +3 -0
- package/src/cli/main.ts +49 -4
- package/src/cli/options.ts +26 -5
- package/src/cli/say.ts +9 -6
- package/src/cli/stats.ts +8 -0
- package/src/cli/support-bundle.ts +12 -2
- package/src/cli.ts +0 -1
- package/src/doctor.ts +8 -2
- package/src/engine/events.ts +2 -1
- package/src/engine-install.ts +135 -63
- package/src/engine-version-marker.ts +8 -0
- package/src/engine.ts +36 -3
- package/src/log.ts +4 -0
- package/src/mcp/tools.ts +3 -1
- package/src/path-errnos.ts +11 -0
- package/src/progress.ts +30 -13
- package/src/support-bundle.ts +73 -8
- package/src/toon.ts +2 -1
- package/src/voice-routing.ts +16 -3
package/src/engine/events.ts
CHANGED
|
@@ -128,7 +128,8 @@ export class KeshaError extends Error {
|
|
|
128
128
|
const transcript = this.stderr?.trim();
|
|
129
129
|
if (!transcript) return renderError(this);
|
|
130
130
|
const line = renderError(this).split("\n")[0] ?? "";
|
|
131
|
-
|
|
131
|
+
if (!transcript.split("\n").some((l) => l === line)) return `${renderError(this)}\n${transcript}`;
|
|
132
|
+
return this.hint && !transcript.includes(` hint: ${this.hint}`) ? `${transcript}\n hint: ${this.hint}` : transcript;
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
package/src/engine-install.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { runEngineProcess } from "./engine/spawn";
|
|
|
19
19
|
import { acquireInstallLock } from "./install-lock";
|
|
20
20
|
import { log } from "./log";
|
|
21
21
|
import { engineVersion, injectedEnginePins } from "./package-info";
|
|
22
|
+
import { PATH_ERRNOS } from "./path-errnos";
|
|
22
23
|
import { keshaCacheDir } from "./paths";
|
|
23
24
|
import { createLiveStatus, streamResponseToFile } from "./progress";
|
|
24
25
|
import { interruptedRun } from "./process-tree";
|
|
@@ -37,6 +38,8 @@ export {
|
|
|
37
38
|
} from "./engine-version-marker";
|
|
38
39
|
|
|
39
40
|
const GITHUB_REPO = "drakulavich/kesha-voice-kit";
|
|
41
|
+
const NETWORK_HINT = "check your network connection and try again.";
|
|
42
|
+
const MISMATCH_HINT = `re-run \`kesha install\`; if the mismatch persists, report it at https://github.com/${GITHUB_REPO}/issues.`;
|
|
40
43
|
|
|
41
44
|
interface ExpectedSha256 {
|
|
42
45
|
sha256: string;
|
|
@@ -51,30 +54,46 @@ export interface AssetPins {
|
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
/** Without `--engine-version` nothing but the maintainer can make an unverifiable pinned release verifiable. */
|
|
54
|
-
function unverifiableFix(version: string): string {
|
|
57
|
+
function unverifiableFix(version: string, pins: AssetPins | undefined): string {
|
|
58
|
+
if (!pins) {
|
|
59
|
+
return version === engineVersion
|
|
60
|
+
? `this source checkout installs its own version v${engineVersion}, which may not be released yet: run ` +
|
|
61
|
+
`\`kesha install --engine-version <version>\` with a published release that ships SHA256SUMS (https://github.com/${GITHUB_REPO}/releases).`
|
|
62
|
+
: `pass --engine-version a published release that ships SHA256SUMS (https://github.com/${GITHUB_REPO}/releases).`;
|
|
63
|
+
}
|
|
55
64
|
return version === engineVersion
|
|
56
65
|
? `report it at https://github.com/${GITHUB_REPO}/issues: the pinned engine release v${version} must publish its checksums.`
|
|
57
66
|
: `pick a release that ships SHA256SUMS (https://github.com/${GITHUB_REPO}/releases), or run ` +
|
|
58
67
|
`\`kesha install\` without --engine-version to install the pinned v${engineVersion}.`;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
async function fetchSha256Sums(version: string): Promise<Map<string, string>> {
|
|
70
|
+
async function fetchSha256Sums(version: string, pins: AssetPins | undefined): Promise<Map<string, string>> {
|
|
62
71
|
const url = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/SHA256SUMS`;
|
|
63
72
|
let res: Response;
|
|
64
73
|
try {
|
|
65
74
|
res = await fetch(url, { redirect: "follow" });
|
|
66
75
|
} catch (e) {
|
|
67
|
-
throw new
|
|
68
|
-
|
|
69
|
-
);
|
|
76
|
+
throw new KeshaError("E_MODEL_DOWNLOAD", `Failed to fetch SHA256SUMS for release v${version}: ${errorMessage(e)}`, {
|
|
77
|
+
hint: NETWORK_HINT,
|
|
78
|
+
});
|
|
70
79
|
}
|
|
71
80
|
if (!res.ok) {
|
|
72
|
-
throw new
|
|
81
|
+
throw new KeshaError(
|
|
82
|
+
"E_MODEL_DOWNLOAD",
|
|
73
83
|
`Cannot verify engine v${version}: release v${version} publishes no SHA256SUMS (HTTP ${res.status}), ` +
|
|
74
|
-
|
|
84
|
+
"so its binaries cannot be checked before they are installed.",
|
|
85
|
+
{ hint: unverifiableFix(version, pins) },
|
|
75
86
|
);
|
|
76
87
|
}
|
|
77
|
-
|
|
88
|
+
let body: string;
|
|
89
|
+
try {
|
|
90
|
+
body = await res.text();
|
|
91
|
+
} catch (e) {
|
|
92
|
+
throw new KeshaError("E_MODEL_DOWNLOAD", `Failed to read SHA256SUMS for release v${version}: ${errorMessage(e)}`, {
|
|
93
|
+
hint: NETWORK_HINT,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return parseSha256Sums(body);
|
|
78
97
|
}
|
|
79
98
|
|
|
80
99
|
/**
|
|
@@ -92,12 +111,14 @@ export function releaseChecksums(
|
|
|
92
111
|
if (!pinned) throw new Error(`No pinned SHA-256 for ${assetName} of engine v${version}; this CLI build is incomplete.\n Fix: report it at https://github.com/${GITHUB_REPO}/issues.`);
|
|
93
112
|
return { sha256: pinned, source: "its pinned SHA-256" };
|
|
94
113
|
}
|
|
95
|
-
sums ??= fetchSha256Sums(version);
|
|
114
|
+
sums ??= fetchSha256Sums(version, pins);
|
|
96
115
|
const sha256 = (await sums).get(assetName);
|
|
97
116
|
if (!sha256) {
|
|
98
|
-
throw new
|
|
117
|
+
throw new KeshaError(
|
|
118
|
+
"E_MODEL_DOWNLOAD",
|
|
99
119
|
`Cannot verify ${assetName}: the SHA256SUMS of release v${version} does not list it, ` +
|
|
100
|
-
|
|
120
|
+
"so it cannot be checked before it is installed.",
|
|
121
|
+
{ hint: unverifiableFix(version, pins) },
|
|
101
122
|
);
|
|
102
123
|
}
|
|
103
124
|
return { sha256, source: `the SHA-256 in the SHA256SUMS of release v${version}` };
|
|
@@ -129,25 +150,44 @@ async function rejectMismatchedDownload(
|
|
|
129
150
|
return (
|
|
130
151
|
`${what} from release v${version} does not match ${expected.source}: ` +
|
|
131
152
|
`expected sha256 ${expected.sha256}, got ${actual}. The download was corrupted or altered, ` +
|
|
132
|
-
"so it was deleted instead of installed
|
|
133
|
-
` Fix: re-run \`kesha install\`; if the mismatch persists, report it at https://github.com/${GITHUB_REPO}/issues.`
|
|
153
|
+
"so it was deleted instead of installed."
|
|
134
154
|
);
|
|
135
155
|
}
|
|
136
156
|
|
|
137
157
|
/**
|
|
138
158
|
* Whether a cached asset of the pinned release still hashes to its pin; one that does not is
|
|
139
|
-
* deleted so the verified download replaces it
|
|
159
|
+
* deleted so the verified download replaces it, or refused when its directory is read-only.
|
|
160
|
+
* `--engine-version` caches are not re-hashed.
|
|
140
161
|
*/
|
|
141
|
-
async function cachedAssetMatchesPin(
|
|
162
|
+
async function cachedAssetMatchesPin(
|
|
163
|
+
path: string,
|
|
164
|
+
assetName: string,
|
|
165
|
+
what: string,
|
|
166
|
+
version: string,
|
|
167
|
+
canRepair: boolean,
|
|
168
|
+
): Promise<boolean> {
|
|
142
169
|
// KESHA_ENGINE_BIN names the user's own build, which no release pin describes.
|
|
143
170
|
if (version !== engineChecksums.pins?.version || process.env.KESHA_ENGINE_BIN || !existsSync(path)) return true;
|
|
144
171
|
const expected = await engineChecksums.forRelease(version)(assetName);
|
|
145
|
-
|
|
172
|
+
let actual: string;
|
|
173
|
+
try {
|
|
174
|
+
actual = await sha256OfFile(path);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
throw canRepair ? e : unreadableInReadOnlyDir(path, e);
|
|
177
|
+
}
|
|
146
178
|
if (actual === expected.sha256) return true;
|
|
147
|
-
|
|
148
|
-
`Installed ${what} ${assetName} does not match ${expected.source}
|
|
149
|
-
|
|
150
|
-
)
|
|
179
|
+
const mismatch =
|
|
180
|
+
`Installed ${what} ${assetName} at ${path} does not match ${expected.source} ` +
|
|
181
|
+
`(expected sha256 ${expected.sha256}, got ${actual})`;
|
|
182
|
+
if (!canRepair) {
|
|
183
|
+
const dir = dirname(path);
|
|
184
|
+
throw new KeshaError("E_CACHE_CORRUPT", `${mismatch}; ${dir} is read-only, so it cannot be replaced.`, {
|
|
185
|
+
hint:
|
|
186
|
+
`make ${dir} writable and re-run \`kesha install\` to replace it with a verified copy, ` +
|
|
187
|
+
"or point KESHA_CACHE_DIR at a writable directory and re-run `kesha install`.",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
log.warn(`${mismatch}; deleting it and downloading a verified copy.`);
|
|
151
191
|
rmSync(path, { force: true });
|
|
152
192
|
return false;
|
|
153
193
|
}
|
|
@@ -209,7 +249,7 @@ export function cleanupRetiredSidecars(engineDir: string): string[] {
|
|
|
209
249
|
}
|
|
210
250
|
|
|
211
251
|
if (removed.length > 0) {
|
|
212
|
-
log.
|
|
252
|
+
log.progress(`Removed retired sidecars: ${removed.join(", ")}.`);
|
|
213
253
|
}
|
|
214
254
|
|
|
215
255
|
return removed;
|
|
@@ -320,12 +360,12 @@ async function downloadSidecar(
|
|
|
320
360
|
await streamResponseToFile(res, sidecarPath, spec.displayName);
|
|
321
361
|
const refusal = await rejectMismatchedDownload(sidecarPath, `${spec.displayName} ${spec.assetName}`, version, expected);
|
|
322
362
|
if (refusal) {
|
|
323
|
-
log.warn(`${refusal}\n ${spec.displayName} not installed; ${spec.unavailableHint}.`);
|
|
363
|
+
log.warn(`${refusal}\n Fix: ${MISMATCH_HINT}\n ${spec.displayName} not installed; ${spec.unavailableHint}.`);
|
|
324
364
|
return;
|
|
325
365
|
}
|
|
326
366
|
chmodSync(sidecarPath, 0o755);
|
|
327
367
|
darwinTrustBinary(sidecarPath, spec.displayName);
|
|
328
|
-
log.
|
|
368
|
+
log.progress(`${spec.displayName} installed (${spec.availableHint}).`);
|
|
329
369
|
} catch (e) {
|
|
330
370
|
log.warn(
|
|
331
371
|
`${spec.displayName} install failed (${errorMessage(e)}); ${spec.unavailableHint}.`,
|
|
@@ -376,7 +416,7 @@ export async function warmDarwinKokoro(binPath: string, timeoutMs = 180_000): Pr
|
|
|
376
416
|
return;
|
|
377
417
|
}
|
|
378
418
|
|
|
379
|
-
log.
|
|
419
|
+
log.progress(
|
|
380
420
|
`FluidAudio Kokoro warmed (${Math.round(performance.now() - startedAt)}ms).`,
|
|
381
421
|
);
|
|
382
422
|
} catch (e) {
|
|
@@ -446,19 +486,18 @@ async function refreshCachedEngine(
|
|
|
446
486
|
): Promise<void> {
|
|
447
487
|
const engineDir = dirname(binPath);
|
|
448
488
|
if (noCache && !canWriteEngineDir) {
|
|
449
|
-
log.
|
|
489
|
+
log.warn(
|
|
450
490
|
`Engine binary at v${version} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
|
|
451
491
|
);
|
|
452
492
|
} else {
|
|
453
|
-
log.
|
|
493
|
+
log.progress(`Engine binary already installed (v${version}).`);
|
|
454
494
|
}
|
|
455
495
|
// Re-trust on cache hit: a user who upgraded to Sequoia after install would still have
|
|
456
496
|
// com.apple.provenance attached; idempotent (~10ms no-op if already correct).
|
|
457
497
|
if (canWriteEngineDir && existsSync(binPath)) {
|
|
458
498
|
darwinTrustBinary(binPath, "kesha-engine binary");
|
|
459
499
|
}
|
|
460
|
-
// Top up missing or broken sidecars (pre-#141/#199 cached binaries never had them)
|
|
461
|
-
// skip on read-only fs (Nix-store) to avoid confusing "install failed" warnings.
|
|
500
|
+
// Top up missing or broken sidecars (pre-#141/#199 cached binaries never had them).
|
|
462
501
|
if (canWriteEngineDir) {
|
|
463
502
|
const checksums = engineChecksums.forRelease(version);
|
|
464
503
|
await Promise.all(
|
|
@@ -468,7 +507,7 @@ async function refreshCachedEngine(
|
|
|
468
507
|
// sidecar is SIGKILLed on spawn, and re-downloading it would not lift the block.
|
|
469
508
|
let pinned = false;
|
|
470
509
|
try {
|
|
471
|
-
pinned = existsSync(path) && (await cachedAssetMatchesPin(path, spec.assetName, spec.displayName, version));
|
|
510
|
+
pinned = existsSync(path) && (await cachedAssetMatchesPin(path, spec.assetName, spec.displayName, version, true));
|
|
472
511
|
} catch (e) {
|
|
473
512
|
log.warn(`Could not read ${spec.displayName} at ${path} (${errorMessage(e)}); removing it and downloading it again.`);
|
|
474
513
|
rmSync(path, { force: true });
|
|
@@ -479,6 +518,10 @@ async function refreshCachedEngine(
|
|
|
479
518
|
}
|
|
480
519
|
}),
|
|
481
520
|
);
|
|
521
|
+
} else {
|
|
522
|
+
for (const spec of SIDECARS) {
|
|
523
|
+
await cachedAssetMatchesPin(join(engineDir, spec.fileBasename), spec.assetName, spec.displayName, version, false);
|
|
524
|
+
}
|
|
482
525
|
}
|
|
483
526
|
}
|
|
484
527
|
|
|
@@ -602,32 +645,35 @@ async function fetchEngineBinary(
|
|
|
602
645
|
res = await fetch(url, { redirect: "follow" });
|
|
603
646
|
} catch (e) {
|
|
604
647
|
muteSidecarRejections();
|
|
605
|
-
throw new
|
|
606
|
-
`Failed to fetch engine binary: ${errorMessage(e)}\n Fix: Check your network connection and try again`,
|
|
607
|
-
);
|
|
648
|
+
throw new KeshaError("E_MODEL_DOWNLOAD", `Failed to fetch engine binary: ${errorMessage(e)}`, { hint: NETWORK_HINT });
|
|
608
649
|
}
|
|
609
650
|
|
|
610
651
|
if (!res.ok) {
|
|
611
652
|
muteSidecarRejections();
|
|
612
653
|
// Names the tag: nothing falls back to the pin, so the caller must see which release 404ed.
|
|
613
|
-
throw new
|
|
614
|
-
|
|
615
|
-
);
|
|
654
|
+
throw new KeshaError("E_MODEL_DOWNLOAD", `Failed to download engine binary from release v${version} (HTTP ${res.status})`, {
|
|
655
|
+
hint: `check https://github.com/${GITHUB_REPO}/releases for available versions.`,
|
|
656
|
+
});
|
|
616
657
|
}
|
|
617
658
|
|
|
618
|
-
|
|
659
|
+
try {
|
|
660
|
+
await streamResponseToFile(res, binPath, "kesha-engine binary");
|
|
661
|
+
} catch (e) {
|
|
662
|
+
muteSidecarRejections();
|
|
663
|
+
throw e instanceof KeshaError ? e : (engineWriteFailure(binPath, e) ?? e);
|
|
664
|
+
}
|
|
619
665
|
const refusal = await rejectMismatchedDownload(binPath, `kesha-engine binary ${binaryName}`, version, expected);
|
|
620
666
|
if (refusal) {
|
|
621
667
|
muteSidecarRejections();
|
|
622
668
|
rmSync(getVersionMarkerPath(binPath), { force: true });
|
|
623
|
-
throw new
|
|
669
|
+
throw new KeshaError("E_CACHE_CORRUPT", refusal, { hint: MISMATCH_HINT });
|
|
624
670
|
}
|
|
625
671
|
chmodSync(binPath, 0o755);
|
|
626
672
|
darwinTrustBinary(binPath, "kesha-engine binary");
|
|
627
673
|
// Marker last: writing it first sends the retry down the cacheValid branch, which never waits.
|
|
628
674
|
await waitUntilSpawnable(binPath);
|
|
629
675
|
writeInstalledEngineVersion(binPath, version);
|
|
630
|
-
log.
|
|
676
|
+
log.progress(`Engine binary downloaded (v${version}).`);
|
|
631
677
|
await Promise.all(sidecarPromises);
|
|
632
678
|
}
|
|
633
679
|
|
|
@@ -807,17 +853,28 @@ async function assertRequestedVersionLanded(binPath: string, version: string): P
|
|
|
807
853
|
);
|
|
808
854
|
}
|
|
809
855
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
856
|
+
function engineDirSetting(): { name: string; value: string } | null {
|
|
857
|
+
if (process.env.KESHA_ENGINE_BIN) return { name: "KESHA_ENGINE_BIN", value: process.env.KESHA_ENGINE_BIN };
|
|
858
|
+
if (process.env.KESHA_CACHE_DIR) return { name: "KESHA_CACHE_DIR", value: process.env.KESHA_CACHE_DIR };
|
|
859
|
+
if (process.env.KESHA_HOME) return { name: "KESHA_HOME", value: process.env.KESHA_HOME };
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function unreadableInReadOnlyDir(path: string, e: unknown): unknown {
|
|
864
|
+
const errno = (e as NodeJS.ErrnoException).code ?? "";
|
|
865
|
+
const why = PATH_ERRNOS[errno];
|
|
866
|
+
if (!why) return e;
|
|
867
|
+
const dir = dirname(path);
|
|
868
|
+
return new KeshaError(
|
|
869
|
+
"E_INVALID_ARG",
|
|
870
|
+
`Cannot verify ${path} against its pinned SHA-256: ${why} (${errno}), and ${dir} is read-only, so it cannot be replaced.`,
|
|
871
|
+
{
|
|
872
|
+
hint:
|
|
873
|
+
`make ${path} readable and ${dir} writable, then re-run \`kesha install\`. Or ` +
|
|
874
|
+
engineDirFix(engineDirSetting()?.name, false, dir),
|
|
875
|
+
},
|
|
876
|
+
);
|
|
877
|
+
}
|
|
821
878
|
|
|
822
879
|
/** `KESHA_ENGINE_BIN` names the binary file, so "point it at a writable directory" breaks the next install. */
|
|
823
880
|
function engineDirFix(setting: string | undefined, notADir: boolean, engineDir: string): string {
|
|
@@ -847,15 +904,9 @@ function ensureEngineDirCreatable(binPath: string): void {
|
|
|
847
904
|
try {
|
|
848
905
|
mkdirSync(engineDir, { recursive: true });
|
|
849
906
|
} catch (e) {
|
|
850
|
-
const setting =
|
|
851
|
-
? { name: "KESHA_ENGINE_BIN", value: process.env.KESHA_ENGINE_BIN }
|
|
852
|
-
: process.env.KESHA_CACHE_DIR
|
|
853
|
-
? { name: "KESHA_CACHE_DIR", value: process.env.KESHA_CACHE_DIR }
|
|
854
|
-
: process.env.KESHA_HOME
|
|
855
|
-
? { name: "KESHA_HOME", value: process.env.KESHA_HOME }
|
|
856
|
-
: null;
|
|
907
|
+
const setting = engineDirSetting();
|
|
857
908
|
const errno = (e as NodeJS.ErrnoException).code ?? "";
|
|
858
|
-
const why =
|
|
909
|
+
const why = PATH_ERRNOS[errno];
|
|
859
910
|
const what = setting
|
|
860
911
|
? `${setting.name}="${setting.value}" cannot hold the engine directory ${engineDir}: ${why ?? errorMessage(e)}`
|
|
861
912
|
: `cannot create the engine directory ${engineDir}: ${why ?? errorMessage(e)}`;
|
|
@@ -871,6 +922,28 @@ function ensureEngineDirCreatable(binPath: string): void {
|
|
|
871
922
|
}
|
|
872
923
|
}
|
|
873
924
|
|
|
925
|
+
function engineWriteFailure(binPath: string, e: unknown): KeshaError | null {
|
|
926
|
+
const engineDir = dirname(binPath);
|
|
927
|
+
const errno = (e as NodeJS.ErrnoException).code ?? "";
|
|
928
|
+
const setting = engineDirSetting();
|
|
929
|
+
const into = setting ? `${engineDir} (from ${setting.name}="${setting.value}")` : engineDir;
|
|
930
|
+
const why = PATH_ERRNOS[errno];
|
|
931
|
+
if (why) {
|
|
932
|
+
return new KeshaError("E_INVALID_ARG", `Cannot write the engine binary into ${into}: ${why}`, {
|
|
933
|
+
hint: engineDirFix(setting?.name, errno === "ENOTDIR" || errno === "EEXIST", engineDir),
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
if (!errno) return null;
|
|
937
|
+
if (errno === "ENOSPC") {
|
|
938
|
+
return new KeshaError("E_INTERNAL", `Cannot write the engine binary into ${into}: no space left on its disk (ENOSPC)`, {
|
|
939
|
+
hint: `the disk is full: free space on the volume that holds ${engineDir}, then re-run \`kesha install\`.`,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
return new KeshaError("E_INTERNAL", `Cannot write the engine binary into ${into}: ${errorMessage(e)}`, {
|
|
943
|
+
hint: `resolve that filesystem error on ${engineDir} and re-run \`kesha install\`; if it persists, file a bug with \`kesha support-bundle\`.`,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
874
947
|
export interface EngineInstallRequest extends InstallOptions {
|
|
875
948
|
noCache?: boolean;
|
|
876
949
|
backend?: string;
|
|
@@ -913,7 +986,7 @@ async function installLockedEngine(
|
|
|
913
986
|
const engineDir = dirname(binPath);
|
|
914
987
|
|
|
915
988
|
if (version !== engineVersion) {
|
|
916
|
-
log.
|
|
989
|
+
log.warn(
|
|
917
990
|
`Installing engine v${version} instead of the pinned v${engineVersion}; ` +
|
|
918
991
|
"a later `kesha install` without --engine-version restores the pin.",
|
|
919
992
|
);
|
|
@@ -923,15 +996,14 @@ async function installLockedEngine(
|
|
|
923
996
|
const canWriteEngineDir = checkEngineWritable(engineDir);
|
|
924
997
|
|
|
925
998
|
const markerMatches = existsSync(binPath) && installedVersion === version;
|
|
926
|
-
// The marker vouches for a file, not for a working binary.
|
|
927
|
-
// dir: nothing there can be repaired, so a failed probe would only turn a usable Nix
|
|
999
|
+
// The marker vouches for a file, not for a working binary. The probe is skipped on a read-only
|
|
1000
|
+
// engine dir: nothing there can be repaired, so a failed probe would only turn a usable Nix
|
|
928
1001
|
// install into a hard error.
|
|
929
1002
|
// Hashed before the health probe, so an altered binary is never run.
|
|
930
1003
|
const versionMatches =
|
|
931
1004
|
markerMatches &&
|
|
932
|
-
(
|
|
933
|
-
|
|
934
|
-
(await engineWorks(binPath))));
|
|
1005
|
+
(await cachedAssetMatchesPin(binPath, getEngineBinaryName(), "kesha-engine binary", version, canWriteEngineDir)) &&
|
|
1006
|
+
(!canWriteEngineDir || (await engineWorks(binPath)));
|
|
935
1007
|
// On read-only fs, --no-cache can't re-download; treat as cache-valid and forward flag to model install.
|
|
936
1008
|
const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
|
|
937
1009
|
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { cmp, parseSemver, tryParseSemver } from "./semver.mjs";
|
|
3
|
+
|
|
4
|
+
export const MIN_ENGINE_VERSION = "1.26.0";
|
|
5
|
+
|
|
6
|
+
export function predatesMinimumEngine(version: string): boolean {
|
|
7
|
+
const parsed = tryParseSemver(version);
|
|
8
|
+
return parsed !== null && cmp(parsed, parseSemver(MIN_ENGINE_VERSION, "MIN_ENGINE_VERSION")) < 0;
|
|
9
|
+
}
|
|
2
10
|
|
|
3
11
|
/**
|
|
4
12
|
* Version-marker file written next to the engine binary on download (#151).
|
package/src/engine.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "child_process";
|
|
1
2
|
import { existsSync, statSync, type Stats } from "fs";
|
|
2
3
|
import { errorMessage } from "./error-utils";
|
|
3
4
|
import { join } from "path";
|
|
@@ -8,6 +9,7 @@ import { defaultEngineBinPath, keshaCacheDir } from "./paths";
|
|
|
8
9
|
import { engineAbortError, interruptedRun } from "./process-tree";
|
|
9
10
|
import { runEngineProcess, spawnHint } from "./engine/spawn";
|
|
10
11
|
import { engineFailure, KeshaError, renderError, type ErrorEvent } from "./engine/events";
|
|
12
|
+
import { MIN_ENGINE_VERSION, predatesMinimumEngine, readInstalledEngineVersion } from "./engine-version-marker";
|
|
11
13
|
import {
|
|
12
14
|
describeToCapabilities,
|
|
13
15
|
parseDescribe,
|
|
@@ -124,6 +126,16 @@ function failed(run: EngineRun): boolean {
|
|
|
124
126
|
return reportedFailure(run) || run.invalid.length > 0;
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
function assertEngineSupported(binPath: string): void {
|
|
130
|
+
const installed = readInstalledEngineVersion(binPath);
|
|
131
|
+
if (installed === null || !predatesMinimumEngine(installed)) return;
|
|
132
|
+
throw new KeshaError(
|
|
133
|
+
"E_ENGINE_PROTOCOL",
|
|
134
|
+
`kesha-engine at ${binPath} is v${installed}; this CLI needs v${MIN_ENGINE_VERSION} or newer`,
|
|
135
|
+
{ hint: "run `kesha install` to fetch the engine this CLI expects", versionMismatch: true },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
127
139
|
let cachedDescribe: { identity: string; doc: DescribeDocument } | null = null;
|
|
128
140
|
|
|
129
141
|
/** ctime is part of it because utimes cannot set it back after a replacement that restores mtime (#1256, #1258). */
|
|
@@ -142,6 +154,7 @@ export async function getDescribe(opts: RunEngineOptions = {}): Promise<Describe
|
|
|
142
154
|
hint: spawnHint(),
|
|
143
155
|
});
|
|
144
156
|
}
|
|
157
|
+
assertEngineSupported(binPath);
|
|
145
158
|
if (cachedDescribe?.identity === identity) return cachedDescribe.doc;
|
|
146
159
|
const run = await runEngine(["describe"], opts);
|
|
147
160
|
let doc: DescribeDocument | null = null;
|
|
@@ -295,10 +308,29 @@ export function buildTranscribeArgs(
|
|
|
295
308
|
return args;
|
|
296
309
|
}
|
|
297
310
|
|
|
311
|
+
function hostIsVirtualMac(): boolean {
|
|
312
|
+
return spawnSync("sysctl", ["-n", "kern.hv_vmm_present"], { encoding: "utf8" }).stdout?.trim() === "1";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** CoreML transcription is unreliable without an Apple Neural Engine, which no virtualised Mac has (#1419); synthesis says the same (#742). */
|
|
316
|
+
export function withVirtualMacHint(
|
|
317
|
+
err: KeshaError,
|
|
318
|
+
platform: string = process.platform,
|
|
319
|
+
isVirtualMac: () => boolean = hostIsVirtualMac,
|
|
320
|
+
): KeshaError {
|
|
321
|
+
if (platform !== "darwin" || err.hint || !err.message.includes("Swift bridge error") || !isVirtualMac()) return err;
|
|
322
|
+
return new KeshaError(err.code, err.message, {
|
|
323
|
+
exitCode: err.exitCode,
|
|
324
|
+
stderr: err.stderr,
|
|
325
|
+
origin: err.origin,
|
|
326
|
+
hint: "this is a virtualised Mac with no Apple Neural Engine, where CoreML transcription is unreliable; transcribe on a physical Mac, or on Linux or Windows",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
298
330
|
export async function transcribeEngine(audioPath: string, opts: TranscribeEngineOptions = {}): Promise<string> {
|
|
299
331
|
const args = await validatedArgs(buildTranscribeArgs(audioPath, opts), { signal: opts.signal });
|
|
300
332
|
const run = await runEngine(args, { signal: opts.signal, onProgressLine: opts.onProgressLine });
|
|
301
|
-
if (failed(run)) throw engineFailure(args[0] ?? "", run, run.exitCode);
|
|
333
|
+
if (failed(run)) throw withVirtualMacHint(engineFailure(args[0] ?? "", run, run.exitCode));
|
|
302
334
|
return run.stdout;
|
|
303
335
|
}
|
|
304
336
|
|
|
@@ -356,7 +388,7 @@ export async function transcribeEngineWithSegments(
|
|
|
356
388
|
const args = await validatedArgs(buildTranscribeArgs(audioPath, opts, true), { signal: opts.signal });
|
|
357
389
|
if (opts.speakers) assertSpeakerModelsInstalled();
|
|
358
390
|
const run = await runEngine(args, { signal: opts.signal, onProgressLine: opts.onProgressLine });
|
|
359
|
-
if (failed(run)) throw engineFailure(args[0] ?? "", run, run.exitCode);
|
|
391
|
+
if (failed(run)) throw withVirtualMacHint(engineFailure(args[0] ?? "", run, run.exitCode));
|
|
360
392
|
try {
|
|
361
393
|
return parseTranscriptionOutput(run.stdout);
|
|
362
394
|
} catch (err: unknown) {
|
|
@@ -477,7 +509,7 @@ export async function recordEngine(target: RecordTarget, maxSeconds: number): Pr
|
|
|
477
509
|
const signalled = target.live && SIGNALLED_LIVE_EXIT_CODES.has(exitCode);
|
|
478
510
|
// A clean interrupt delivers the transcript and exits 128+signal saying nothing (rust/src/cli/record.rs:82),
|
|
479
511
|
// so an error event beside that status is a real failure the signal must not excuse.
|
|
480
|
-
if (events.error || events.invalid.length > 0) throw engineFailure("record", events, exitCode);
|
|
512
|
+
if (events.error || events.invalid.length > 0) throw withVirtualMacHint(engineFailure("record", events, exitCode));
|
|
481
513
|
// A silent non-zero exit named nothing, so it stays the operational 1 it has been since #1167.
|
|
482
514
|
if (!signalled && exitCode !== 0) throw new Error(`kesha-engine record exited with code ${exitCode}`);
|
|
483
515
|
}
|
|
@@ -518,6 +550,7 @@ export async function detectTextLanguageEngine(
|
|
|
518
550
|
): Promise<LangDetectResult | null> {
|
|
519
551
|
if (text.trim().length === 0) return null;
|
|
520
552
|
if (!isEngineInstalled()) return null;
|
|
553
|
+
assertEngineSupported(getEngineBinPath());
|
|
521
554
|
const run = await runEngine(["detect-text-lang"], { ...opts, stdin: text });
|
|
522
555
|
if (reportedFailure(run)) {
|
|
523
556
|
const warning = textLangFailureWarning(run.stderr);
|
package/src/log.ts
CHANGED
|
@@ -16,6 +16,10 @@ export function setColorEnabled(enabled: boolean): void {
|
|
|
16
16
|
colors = enabled ? pc : pc.createColors(false);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function isColorEnabled(): boolean {
|
|
20
|
+
return colors.isColorSupported;
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
/**
|
|
20
24
|
* Debug mode (#148): when `KESHA_DEBUG` is truthy OR the caller has flipped
|
|
21
25
|
* `log.debugEnabled = true` (via `--debug`), `log.debug()` writes structured
|
package/src/mcp/tools.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
|
+
import { KeshaError } from "../engine/events";
|
|
3
4
|
import { installHint } from "../install-hint";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { chmodSync, existsSync, readFileSync, statSync } from "fs";
|
|
@@ -126,7 +127,8 @@ export function registerTools(server: McpServer): void {
|
|
|
126
127
|
const hint = isAbsolute(path)
|
|
127
128
|
? ""
|
|
128
129
|
: ` (relative paths resolve against the MCP server's working directory, ${process.cwd()} — pass an absolute path instead)`;
|
|
129
|
-
|
|
130
|
+
const err = new KeshaError("E_INPUT_NOT_FOUND", `File not found: ${path}${hint}`);
|
|
131
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
|
|
130
132
|
}
|
|
131
133
|
try {
|
|
132
134
|
assertAudioFileArgument(path);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Errnos that mean the path itself is wrong for writing, so the fix is a different path; anything else is not a bad argument. */
|
|
2
|
+
export const PATH_ERRNOS: Record<string, string> = {
|
|
3
|
+
EACCES: "permission denied",
|
|
4
|
+
EPERM: "permission denied",
|
|
5
|
+
ENOTDIR: "a component of it is a file, not a directory",
|
|
6
|
+
// mkdir(recursive) raises this, not ENOTDIR, when the directory itself is an existing file.
|
|
7
|
+
EEXIST: "it already exists as a file, not a directory",
|
|
8
|
+
EROFS: "the filesystem is read-only",
|
|
9
|
+
ELOOP: "the path loops through symlinks",
|
|
10
|
+
ENAMETOOLONG: "the path is too long",
|
|
11
|
+
};
|
package/src/progress.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { copyFileSync, readdirSync, renameSync, rmSync, statSync } from "fs";
|
|
2
2
|
import { basename, dirname, join } from "path";
|
|
3
3
|
import { errorMessage } from "./error-utils";
|
|
4
|
+
import { KeshaError } from "./engine/events";
|
|
4
5
|
import { log } from "./log";
|
|
5
6
|
|
|
6
7
|
const BAR_WIDTH = 20;
|
|
@@ -172,11 +173,15 @@ export async function applyBackpressure(
|
|
|
172
173
|
*/
|
|
173
174
|
const STALE_STAGING_MS = 24 * 60 * 60 * 1000;
|
|
174
175
|
|
|
176
|
+
export function stagingPrefix(destPath: string): string {
|
|
177
|
+
return `${destPath}.part.`;
|
|
178
|
+
}
|
|
179
|
+
|
|
175
180
|
/** A Ctrl-C kills the process before any cleanup runs, so last run's staging file is swept by the next one (#770). */
|
|
176
181
|
function sweepStagingFiles(destPath: string): void {
|
|
177
182
|
if (process.platform === "win32") return;
|
|
178
183
|
const dir = dirname(destPath);
|
|
179
|
-
const prefix =
|
|
184
|
+
const prefix = basename(stagingPrefix(destPath));
|
|
180
185
|
const cutoffMs = Date.now() - STALE_STAGING_MS;
|
|
181
186
|
|
|
182
187
|
let names: string[];
|
|
@@ -225,32 +230,44 @@ export async function streamResponseToFile(
|
|
|
225
230
|
destPath: string,
|
|
226
231
|
label: string,
|
|
227
232
|
): Promise<number> {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
);
|
|
232
|
-
|
|
233
|
+
const emptyResponse = () =>
|
|
234
|
+
new KeshaError("E_MODEL_DOWNLOAD", `Download failed: empty response for ${label}`, {
|
|
235
|
+
hint: "try again; the server may be temporarily unavailable.",
|
|
236
|
+
});
|
|
237
|
+
if (!res.body) throw emptyResponse();
|
|
233
238
|
|
|
234
239
|
const totalBytes = Number(res.headers.get("content-length") || 0);
|
|
235
240
|
const progress = createProgressBar(label, totalBytes);
|
|
236
241
|
|
|
237
242
|
sweepStagingFiles(destPath);
|
|
238
|
-
const stagingPath = `${destPath}
|
|
239
|
-
const
|
|
243
|
+
const stagingPath = `${stagingPrefix(destPath)}${process.pid}.${stagingSeq++}`;
|
|
244
|
+
const reader = res.body.getReader();
|
|
240
245
|
let bytes = 0;
|
|
241
246
|
try {
|
|
247
|
+
const writer = Bun.file(stagingPath).writer();
|
|
242
248
|
try {
|
|
243
|
-
for
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
249
|
+
for (;;) {
|
|
250
|
+
let next: Awaited<ReturnType<typeof reader.read>>;
|
|
251
|
+
try {
|
|
252
|
+
next = await reader.read();
|
|
253
|
+
} catch (err) {
|
|
254
|
+
throw new KeshaError("E_MODEL_DOWNLOAD", `Download of ${label} broke off after ${bytes} bytes: ${errorMessage(err)}`, {
|
|
255
|
+
hint: "check your network connection and try again.",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (next.done) break;
|
|
259
|
+
await applyBackpressure(writer, writer.write(next.value));
|
|
260
|
+
bytes += next.value.length;
|
|
261
|
+
progress.update(next.value.length);
|
|
247
262
|
}
|
|
248
263
|
} finally {
|
|
249
264
|
// Must await: an open write handle makes the freshly-downloaded engine unspawnable — EBUSY on Windows, ETXTBSY on Linux.
|
|
250
265
|
await writer.end();
|
|
251
266
|
}
|
|
267
|
+
if (bytes === 0) throw emptyResponse();
|
|
252
268
|
publishStaging(stagingPath, destPath);
|
|
253
269
|
} catch (err) {
|
|
270
|
+
reader.cancel().catch(() => {});
|
|
254
271
|
rmSync(stagingPath, { force: true });
|
|
255
272
|
throw err;
|
|
256
273
|
}
|
|
@@ -272,7 +289,7 @@ export function createProgressBar(label: string, totalBytes: number): {
|
|
|
272
289
|
return {
|
|
273
290
|
update() {},
|
|
274
291
|
finish() {
|
|
275
|
-
log.
|
|
292
|
+
log.progress(`Downloaded ${label} ✓`);
|
|
276
293
|
},
|
|
277
294
|
};
|
|
278
295
|
}
|