@drakulavich/kesha-voice-kit 1.31.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 +4 -4
- package/SKILL.md +1 -1
- package/docs/product-positioning.md +3 -3
- package/model-plan.json +14 -14
- package/package.json +21 -7
- package/src/cache-layout.ts +78 -8
- package/src/cli/install.ts +6 -4
- package/src/cli/main.ts +2 -14
- package/src/doctor.ts +3 -9
- package/src/engine/events.ts +6 -1
- package/src/engine/spawn.ts +114 -0
- package/src/engine-health.ts +25 -56
- package/src/engine-install.ts +224 -105
- package/src/engine-targets.ts +53 -5
- package/src/engine.ts +79 -121
- package/src/install-plan.ts +20 -41
- package/src/kokoro-ane.ts +1 -1
- package/src/language-routing.ts +14 -0
- package/src/lib.ts +95 -66
- package/src/mcp/tools.ts +5 -11
- package/src/package-info.ts +28 -2
- package/src/process-tree.ts +13 -11
- package/src/semver.mjs +7 -10
- package/src/status.ts +5 -16
- package/src/synth.ts +38 -86
- package/src/transcribe.ts +9 -4
package/src/engine-install.ts
CHANGED
|
@@ -2,22 +2,34 @@ import { dirname, join, resolve, sep } from "path";
|
|
|
2
2
|
import { errorMessage } from "./error-utils";
|
|
3
3
|
import { homedir, tmpdir } from "os";
|
|
4
4
|
import { existsSync, mkdirSync, chmodSync, accessSync, constants, rmSync } from "fs";
|
|
5
|
-
import { getDescribe, getEngineBinPath
|
|
5
|
+
import { getDescribe, getEngineBinPath } from "./engine";
|
|
6
6
|
import { engineFunctionalHealth, probeExecutable, readExecutableVersion } from "./engine-health";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
engineTarget,
|
|
9
|
+
engineTargetEntries,
|
|
10
|
+
isDarwinArm64,
|
|
11
|
+
parseSha256Sums,
|
|
12
|
+
SIDECARS,
|
|
13
|
+
type SidecarSpec,
|
|
14
|
+
targetKey,
|
|
15
|
+
} from "./engine-targets";
|
|
8
16
|
import { validateArgv } from "./engine/describe";
|
|
9
|
-
import { engineFailure, KeshaError,
|
|
17
|
+
import { engineFailure, KeshaError, offProtocolLine } from "./engine/events";
|
|
18
|
+
import { runEngineProcess } from "./engine/spawn";
|
|
10
19
|
import { acquireInstallLock } from "./install-lock";
|
|
11
20
|
import { log } from "./log";
|
|
12
|
-
import { engineVersion } from "./package-info";
|
|
21
|
+
import { engineVersion, injectedEnginePins } from "./package-info";
|
|
13
22
|
import { keshaCacheDir } from "./paths";
|
|
14
23
|
import { createLiveStatus, streamResponseToFile } from "./progress";
|
|
15
|
-
import {
|
|
24
|
+
import { interruptedRun } from "./process-tree";
|
|
25
|
+
import { DEFAULT_VOICE_ID } from "./voice-routing";
|
|
16
26
|
import {
|
|
27
|
+
getVersionMarkerPath,
|
|
17
28
|
readInstalledEngineVersion,
|
|
18
29
|
writeInstalledEngineVersion,
|
|
19
30
|
} from "./engine-version-marker";
|
|
20
31
|
|
|
32
|
+
export { SIDECARS } from "./engine-targets";
|
|
21
33
|
export {
|
|
22
34
|
getVersionMarkerPath,
|
|
23
35
|
readInstalledEngineVersion,
|
|
@@ -26,6 +38,120 @@ export {
|
|
|
26
38
|
|
|
27
39
|
const GITHUB_REPO = "drakulavich/kesha-voice-kit";
|
|
28
40
|
|
|
41
|
+
interface ExpectedSha256 {
|
|
42
|
+
sha256: string;
|
|
43
|
+
source: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `package.json#kesha.engine`, injected at publish by `engine-pin.ts`: what every downloaded asset of that Engine hashes to and weighs. */
|
|
47
|
+
export interface AssetPins {
|
|
48
|
+
version: string;
|
|
49
|
+
sha256: Readonly<Record<string, string>>;
|
|
50
|
+
size: Readonly<Record<string, number>>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Without `--engine-version` nothing but the maintainer can make an unverifiable pinned release verifiable. */
|
|
54
|
+
function unverifiableFix(version: string): string {
|
|
55
|
+
return version === engineVersion
|
|
56
|
+
? `report it at https://github.com/${GITHUB_REPO}/issues: the pinned engine release v${version} must publish its checksums.`
|
|
57
|
+
: `pick a release that ships SHA256SUMS (https://github.com/${GITHUB_REPO}/releases), or run ` +
|
|
58
|
+
`\`kesha install\` without --engine-version to install the pinned v${engineVersion}.`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function fetchSha256Sums(version: string): Promise<Map<string, string>> {
|
|
62
|
+
const url = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/SHA256SUMS`;
|
|
63
|
+
let res: Response;
|
|
64
|
+
try {
|
|
65
|
+
res = await fetch(url, { redirect: "follow" });
|
|
66
|
+
} catch (e) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Failed to fetch SHA256SUMS for release v${version}: ${errorMessage(e)}\n Fix: Check your network connection and try again`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Cannot verify engine v${version}: release v${version} publishes no SHA256SUMS (HTTP ${res.status}), ` +
|
|
74
|
+
`so its binaries cannot be checked before they are installed.\n Fix: ${unverifiableFix(version)}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return parseSha256Sums(await res.text());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The pinned Engine's assets against the pins, never the network; any other release, and every release
|
|
82
|
+
* in a source checkout (which carries no pin), against that release's own SHA256SUMS.
|
|
83
|
+
*/
|
|
84
|
+
export function releaseChecksums(
|
|
85
|
+
version: string,
|
|
86
|
+
pins: AssetPins | undefined,
|
|
87
|
+
): (assetName: string) => Promise<ExpectedSha256> {
|
|
88
|
+
let sums: Promise<Map<string, string>> | undefined;
|
|
89
|
+
return async (assetName) => {
|
|
90
|
+
if (version === pins?.version) {
|
|
91
|
+
const pinned = pins.sha256[assetName];
|
|
92
|
+
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
|
+
return { sha256: pinned, source: "its pinned SHA-256" };
|
|
94
|
+
}
|
|
95
|
+
sums ??= fetchSha256Sums(version);
|
|
96
|
+
const sha256 = (await sums).get(assetName);
|
|
97
|
+
if (!sha256) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Cannot verify ${assetName}: the SHA256SUMS of release v${version} does not list it, ` +
|
|
100
|
+
`so it cannot be checked before it is installed.\n Fix: ${unverifiableFix(version)}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return { sha256, source: `the SHA-256 in the SHA256SUMS of release v${version}` };
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Tests that serve a stand-in engine replace these, the way they replace `fetch`. */
|
|
108
|
+
export const engineChecksums = {
|
|
109
|
+
pins: injectedEnginePins,
|
|
110
|
+
forRelease: (version: string) => releaseChecksums(version, engineChecksums.pins),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
async function sha256OfFile(path: string): Promise<string> {
|
|
114
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
115
|
+
for await (const chunk of Bun.file(path).stream()) hasher.update(chunk);
|
|
116
|
+
return hasher.digest("hex");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Deletes a download that does not hash to `expected` and returns the refusal; null when it matches. */
|
|
120
|
+
async function rejectMismatchedDownload(
|
|
121
|
+
path: string,
|
|
122
|
+
what: string,
|
|
123
|
+
version: string,
|
|
124
|
+
expected: ExpectedSha256,
|
|
125
|
+
): Promise<string | null> {
|
|
126
|
+
const actual = await sha256OfFile(path);
|
|
127
|
+
if (actual === expected.sha256) return null;
|
|
128
|
+
rmSync(path, { force: true });
|
|
129
|
+
return (
|
|
130
|
+
`${what} from release v${version} does not match ${expected.source}: ` +
|
|
131
|
+
`expected sha256 ${expected.sha256}, got ${actual}. The download was corrupted or altered, ` +
|
|
132
|
+
"so it was deleted instead of installed.\n" +
|
|
133
|
+
` Fix: re-run \`kesha install\`; if the mismatch persists, report it at https://github.com/${GITHUB_REPO}/issues.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 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. `--engine-version` caches are not re-hashed.
|
|
140
|
+
*/
|
|
141
|
+
async function cachedAssetMatchesPin(path: string, assetName: string, what: string, version: string): Promise<boolean> {
|
|
142
|
+
// KESHA_ENGINE_BIN names the user's own build, which no release pin describes.
|
|
143
|
+
if (version !== engineChecksums.pins?.version || process.env.KESHA_ENGINE_BIN || !existsSync(path)) return true;
|
|
144
|
+
const expected = await engineChecksums.forRelease(version)(assetName);
|
|
145
|
+
const actual = await sha256OfFile(path);
|
|
146
|
+
if (actual === expected.sha256) return true;
|
|
147
|
+
log.warn(
|
|
148
|
+
`Installed ${what} ${assetName} does not match ${expected.source} (expected sha256 ${expected.sha256}, ` +
|
|
149
|
+
`got ${actual}); deleting it and downloading a verified copy.`,
|
|
150
|
+
);
|
|
151
|
+
rmSync(path, { force: true });
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
29
155
|
export function getEngineBinaryName(
|
|
30
156
|
platform: string = process.platform,
|
|
31
157
|
arch: string = process.arch,
|
|
@@ -55,44 +181,6 @@ export function assertPlatformCanInstall(
|
|
|
55
181
|
}
|
|
56
182
|
}
|
|
57
183
|
|
|
58
|
-
/** Sidecar spec — centralises AVSpeech (#141) and future sidecars so each is one entry. */
|
|
59
|
-
interface SidecarSpec {
|
|
60
|
-
/** Written next to the engine binary; Rust probes this exact name. */
|
|
61
|
-
fileBasename: string;
|
|
62
|
-
/** Release asset name — may differ from fileBasename (e.g. `say-avspeech-darwin-arm64` vs `say-avspeech`). */
|
|
63
|
-
assetName: string;
|
|
64
|
-
displayName: string;
|
|
65
|
-
availableHint: string;
|
|
66
|
-
unavailableHint: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export const SIDECARS: SidecarSpec[] = [
|
|
70
|
-
{
|
|
71
|
-
fileBasename: "say-avspeech",
|
|
72
|
-
assetName: "say-avspeech-darwin-arm64",
|
|
73
|
-
displayName: "AVSpeech sidecar",
|
|
74
|
-
availableHint: "macOS voices available",
|
|
75
|
-
unavailableHint: "macos-* voices unavailable",
|
|
76
|
-
},
|
|
77
|
-
// Kokoro TTS (#207) and speaker diarization (#199) no longer ship as Swift
|
|
78
|
-
// sidecars — both run in-engine via the native `fluidaudio-rs` binding. Only
|
|
79
|
-
// the AVSpeech and text-lang sidecars remain.
|
|
80
|
-
{
|
|
81
|
-
// Runtime resolver looks for plain `kesha-textlang` next to the engine
|
|
82
|
-
// (see `rust/src/text_lang.rs::helper_path`), not the platform-suffixed
|
|
83
|
-
// release-asset name. Mismatch is intentional: the asset name needs the
|
|
84
|
-
// suffix for GitHub-release uniqueness; the sidecar lookup wants the
|
|
85
|
-
// unsuffixed binary so the same Rust code path works on the build-time
|
|
86
|
-
// OUT_DIR baked fallback.
|
|
87
|
-
fileBasename: "kesha-textlang",
|
|
88
|
-
assetName: "kesha-textlang-darwin-arm64",
|
|
89
|
-
displayName: "Text-lang sidecar",
|
|
90
|
-
availableHint: "detect-text-lang fast path",
|
|
91
|
-
unavailableHint:
|
|
92
|
-
"detect-text-lang will fail until next `kesha install` (no swift -e fallback)",
|
|
93
|
-
},
|
|
94
|
-
];
|
|
95
|
-
|
|
96
184
|
const RETIRED_SIDECAR_FILENAMES = [
|
|
97
185
|
// Historical installed filenames.
|
|
98
186
|
"kesha-kokoro",
|
|
@@ -194,12 +282,22 @@ async function downloadSidecar(
|
|
|
194
282
|
spec: SidecarSpec,
|
|
195
283
|
binPath: string,
|
|
196
284
|
version: string,
|
|
285
|
+
checksums: (assetName: string) => Promise<ExpectedSha256>,
|
|
197
286
|
): Promise<void> {
|
|
198
287
|
if (!isDarwinArm64()) return;
|
|
199
288
|
|
|
200
289
|
const sidecarPath = join(dirname(binPath), spec.fileBasename);
|
|
201
290
|
const url = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${spec.assetName}`;
|
|
202
291
|
|
|
292
|
+
let expected: ExpectedSha256;
|
|
293
|
+
try {
|
|
294
|
+
expected = await checksums(spec.assetName);
|
|
295
|
+
} catch (e) {
|
|
296
|
+
rmSync(sidecarPath, { force: true });
|
|
297
|
+
log.warn(`Could not verify ${spec.displayName} for v${version} (${errorMessage(e)}); ${spec.unavailableHint}.`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
203
301
|
let res: Response;
|
|
204
302
|
try {
|
|
205
303
|
res = await fetch(url, { redirect: "follow" });
|
|
@@ -220,6 +318,11 @@ async function downloadSidecar(
|
|
|
220
318
|
// Catch stream/chmod failures so a sidecar error can't poison the engine install.
|
|
221
319
|
try {
|
|
222
320
|
await streamResponseToFile(res, sidecarPath, spec.displayName);
|
|
321
|
+
const refusal = await rejectMismatchedDownload(sidecarPath, `${spec.displayName} ${spec.assetName}`, version, expected);
|
|
322
|
+
if (refusal) {
|
|
323
|
+
log.warn(`${refusal}\n ${spec.displayName} not installed; ${spec.unavailableHint}.`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
223
326
|
chmodSync(sidecarPath, 0o755);
|
|
224
327
|
darwinTrustBinary(sidecarPath, spec.displayName);
|
|
225
328
|
log.success(`${spec.displayName} installed (${spec.availableHint}).`);
|
|
@@ -230,7 +333,8 @@ async function downloadSidecar(
|
|
|
230
333
|
}
|
|
231
334
|
}
|
|
232
335
|
|
|
233
|
-
|
|
336
|
+
/** `timeoutMs` exists for tests; install always uses the default. */
|
|
337
|
+
export async function warmDarwinKokoro(binPath: string, timeoutMs = 180_000): Promise<void> {
|
|
234
338
|
if (!isDarwinArm64()) return;
|
|
235
339
|
// Kokoro now runs in-engine (FluidAudio CoreML, system_kokoro) — warm it by
|
|
236
340
|
// exercising the engine's own `say`, not a sidecar. The first synthesis
|
|
@@ -238,30 +342,28 @@ async function warmDarwinKokoro(binPath: string): Promise<void> {
|
|
|
238
342
|
if (!existsSync(binPath)) return;
|
|
239
343
|
|
|
240
344
|
const outPath = join(tmpdir(), `kesha-kokoro-warmup-${process.pid}.wav`);
|
|
345
|
+
let args: string[];
|
|
346
|
+
try {
|
|
347
|
+
const validated = validateArgv(["say", "--voice", DEFAULT_VOICE_ID, "--out", outPath, "Kesha warmup."], await getDescribe());
|
|
348
|
+
for (const warning of validated.warnings) log.warn(warning);
|
|
349
|
+
args = validated.argv;
|
|
350
|
+
} catch (e) {
|
|
351
|
+
log.warn(`FluidAudio Kokoro warmup skipped (${errorMessage(e)}); first \`kesha say en-*\` may still be slow.`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
241
354
|
log.progress("Warming FluidAudio Kokoro CoreML cache...");
|
|
242
355
|
|
|
243
356
|
const startedAt = performance.now();
|
|
244
|
-
const proc = spawnEngineProcess(
|
|
245
|
-
binPath,
|
|
246
|
-
["say", "--voice", "en-am_michael", "--out", outPath, "Kesha warmup."],
|
|
247
|
-
["ignore", "pipe", "pipe"],
|
|
248
|
-
protocolEnv(),
|
|
249
|
-
);
|
|
250
|
-
const tree = registerProcessTree(proc);
|
|
251
|
-
|
|
252
|
-
let timedOut = false;
|
|
253
|
-
const timer = setTimeout(() => {
|
|
254
|
-
timedOut = true;
|
|
255
|
-
proc.kill();
|
|
256
|
-
}, 180_000);
|
|
257
|
-
|
|
258
357
|
try {
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
358
|
+
const { exitCode, aborted, events } = await runEngineProcess(binPath, args, {
|
|
359
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
360
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
361
|
+
});
|
|
263
362
|
|
|
264
|
-
|
|
363
|
+
// A Ctrl+C that lands while the deadline is stopping the engine is still the user's interrupt.
|
|
364
|
+
const interrupted = interruptedRun(exitCode);
|
|
365
|
+
if (interrupted) throw interrupted;
|
|
366
|
+
if (aborted) {
|
|
265
367
|
log.warn("FluidAudio Kokoro warmup timed out; first `kesha say en-*` may still be slow.");
|
|
266
368
|
return;
|
|
267
369
|
}
|
|
@@ -278,13 +380,13 @@ async function warmDarwinKokoro(binPath: string): Promise<void> {
|
|
|
278
380
|
`FluidAudio Kokoro warmed (${Math.round(performance.now() - startedAt)}ms).`,
|
|
279
381
|
);
|
|
280
382
|
} catch (e) {
|
|
383
|
+
// A launch failure was never the warmup's to swallow, and an interrupt is the user's.
|
|
384
|
+
if (e instanceof KeshaError && (e.code === "E_INTERRUPTED" || e.code === "E_ENGINE_SPAWN")) throw e;
|
|
281
385
|
log.warn(
|
|
282
386
|
`FluidAudio Kokoro warmup failed (${errorMessage(e)}); ` +
|
|
283
387
|
"first `kesha say en-*` may still be slow.",
|
|
284
388
|
);
|
|
285
389
|
} finally {
|
|
286
|
-
clearTimeout(timer);
|
|
287
|
-
tree.dispose();
|
|
288
390
|
try {
|
|
289
391
|
rmSync(outPath, { force: true });
|
|
290
392
|
} catch {
|
|
@@ -358,14 +460,22 @@ async function refreshCachedEngine(
|
|
|
358
460
|
// Top up missing or broken sidecars (pre-#141/#199 cached binaries never had them);
|
|
359
461
|
// skip on read-only fs (Nix-store) to avoid confusing "install failed" warnings.
|
|
360
462
|
if (canWriteEngineDir) {
|
|
463
|
+
const checksums = engineChecksums.forRelease(version);
|
|
361
464
|
await Promise.all(
|
|
362
465
|
SIDECARS.map(async (spec) => {
|
|
363
466
|
const path = join(engineDir, spec.fileBasename);
|
|
364
467
|
// Re-trust before probing, for the Sequoia upgrade scenario: a provenance-blocked
|
|
365
468
|
// sidecar is SIGKILLed on spawn, and re-downloading it would not lift the block.
|
|
366
|
-
|
|
469
|
+
let pinned = false;
|
|
470
|
+
try {
|
|
471
|
+
pinned = existsSync(path) && (await cachedAssetMatchesPin(path, spec.assetName, spec.displayName, version));
|
|
472
|
+
} catch (e) {
|
|
473
|
+
log.warn(`Could not read ${spec.displayName} at ${path} (${errorMessage(e)}); removing it and downloading it again.`);
|
|
474
|
+
rmSync(path, { force: true });
|
|
475
|
+
}
|
|
476
|
+
if (pinned) darwinTrustBinary(path, spec.displayName);
|
|
367
477
|
if (await sidecarNeedsDownload(spec, engineDir)) {
|
|
368
|
-
await downloadSidecar(spec, binPath, version);
|
|
478
|
+
await downloadSidecar(spec, binPath, version, checksums);
|
|
369
479
|
}
|
|
370
480
|
}),
|
|
371
481
|
);
|
|
@@ -399,9 +509,10 @@ export async function waitUntilSpawnable(binPath: string, deadlineMs = 60_000):
|
|
|
399
509
|
lastError = health.status === "unusable" ? health.detail : "the binary is gone";
|
|
400
510
|
// Only a lock is worth waiting out — a missing or corrupt binary never becomes spawnable.
|
|
401
511
|
if (!isTransientSpawnLock(lastError)) {
|
|
402
|
-
throw new
|
|
403
|
-
|
|
404
|
-
|
|
512
|
+
throw new KeshaError(
|
|
513
|
+
"E_ENGINE_SPAWN",
|
|
514
|
+
`Downloaded the engine to ${binPath} but it could not be started: ${lastError}`,
|
|
515
|
+
{ hint: `delete ${dirname(binPath)} and re-run \`kesha install\`.` },
|
|
405
516
|
);
|
|
406
517
|
}
|
|
407
518
|
if (Date.now() + delay > deadline) break;
|
|
@@ -413,11 +524,15 @@ export async function waitUntilSpawnable(binPath: string, deadlineMs = 60_000):
|
|
|
413
524
|
delay = Math.min(delay * 2, 2_000);
|
|
414
525
|
}
|
|
415
526
|
|
|
416
|
-
throw new
|
|
527
|
+
throw new KeshaError(
|
|
528
|
+
"E_ENGINE_SPAWN",
|
|
417
529
|
`Downloaded the engine to ${binPath} but it is still locked after ` +
|
|
418
|
-
`${Math.round(deadlineMs / 1000)}s: ${lastError}
|
|
419
|
-
|
|
420
|
-
|
|
530
|
+
`${Math.round(deadlineMs / 1000)}s: ${lastError}`,
|
|
531
|
+
{
|
|
532
|
+
hint:
|
|
533
|
+
"a security scanner is likely holding the file. Re-run `kesha install`, " +
|
|
534
|
+
`or exclude ${dirname(binPath)} from real-time scanning.`,
|
|
535
|
+
},
|
|
421
536
|
);
|
|
422
537
|
}
|
|
423
538
|
|
|
@@ -466,13 +581,22 @@ async function fetchEngineBinary(
|
|
|
466
581
|
mkdirSync(dirname(binPath), { recursive: true });
|
|
467
582
|
|
|
468
583
|
// Overlap sidecar fetches with the engine fetch (~15-30s saved on cold install).
|
|
584
|
+
const checksums = engineChecksums.forRelease(version);
|
|
469
585
|
const sidecarPromises = SIDECARS.map((s) =>
|
|
470
|
-
downloadSidecar(s, binPath, version),
|
|
586
|
+
downloadSidecar(s, binPath, version, checksums),
|
|
471
587
|
);
|
|
472
588
|
// If the engine fetch throws, silence in-flight sidecar rejections so unhandledRejection doesn't obscure the engine error.
|
|
473
589
|
const muteSidecarRejections = () =>
|
|
474
590
|
sidecarPromises.forEach((p) => p.catch(() => {}));
|
|
475
591
|
|
|
592
|
+
let expected: ExpectedSha256;
|
|
593
|
+
try {
|
|
594
|
+
expected = await checksums(binaryName);
|
|
595
|
+
} catch (e) {
|
|
596
|
+
muteSidecarRejections();
|
|
597
|
+
throw e;
|
|
598
|
+
}
|
|
599
|
+
|
|
476
600
|
let res: Response;
|
|
477
601
|
try {
|
|
478
602
|
res = await fetch(url, { redirect: "follow" });
|
|
@@ -492,6 +616,12 @@ async function fetchEngineBinary(
|
|
|
492
616
|
}
|
|
493
617
|
|
|
494
618
|
await streamResponseToFile(res, binPath, "kesha-engine binary");
|
|
619
|
+
const refusal = await rejectMismatchedDownload(binPath, `kesha-engine binary ${binaryName}`, version, expected);
|
|
620
|
+
if (refusal) {
|
|
621
|
+
muteSidecarRejections();
|
|
622
|
+
rmSync(getVersionMarkerPath(binPath), { force: true });
|
|
623
|
+
throw new Error(refusal);
|
|
624
|
+
}
|
|
495
625
|
chmodSync(binPath, 0o755);
|
|
496
626
|
darwinTrustBinary(binPath, "kesha-engine binary");
|
|
497
627
|
// Marker last: writing it first sends the retry down the cacheValid branch, which never waits.
|
|
@@ -520,7 +650,7 @@ function checkEngineWritable(engineDir: string): boolean {
|
|
|
520
650
|
/**
|
|
521
651
|
* The install path's pre-spawn gate: checks the requested backend against the describe
|
|
522
652
|
* document, then validates the built engine argv through it. `--diarize` on a build that
|
|
523
|
-
* lacks `system_diarize` (the Nix build, which omits it on purpose — docs/
|
|
653
|
+
* lacks `system_diarize` (the Nix build, which omits it on purpose — docs/distribution.md#nix)
|
|
524
654
|
* gets the npm-release remedy instead of `validateArgv`'s generic "needs" message.
|
|
525
655
|
*
|
|
526
656
|
* A bare `install` (no backend, no flags) skips `getDescribe()` entirely: a read-only
|
|
@@ -558,7 +688,7 @@ async function validateInstallRequest(
|
|
|
558
688
|
throw new KeshaError(
|
|
559
689
|
"E_INVALID_ARG",
|
|
560
690
|
"The installed engine was built without the system_diarize feature, so --diarize is unavailable " +
|
|
561
|
-
"(the Nix build in docs/
|
|
691
|
+
"(the Nix build in docs/distribution.md is one such case).",
|
|
562
692
|
{
|
|
563
693
|
hint: "install via the npm release with `bun add -g @drakulavich/kesha-voice-kit`, which ships the diarize-enabled engine on darwin-arm64",
|
|
564
694
|
},
|
|
@@ -571,15 +701,13 @@ async function validateInstallRequest(
|
|
|
571
701
|
/** Runs `kesha-engine install` to download/verify models. */
|
|
572
702
|
async function runEngineModelInstall(binPath: string, installArgs: string[]): Promise<void> {
|
|
573
703
|
log.progress("Installing models...");
|
|
574
|
-
const proc = spawnEngineProcess(binPath, installArgs, ["inherit", "inherit", "pipe"], protocolEnv());
|
|
575
|
-
const tree = registerProcessTree(proc);
|
|
576
704
|
// The byte percentage repaints one row; `GET`/`OK`/`retrying` are discrete steps that must stay in the log.
|
|
577
705
|
const status = createLiveStatus();
|
|
578
|
-
let
|
|
579
|
-
let exitCode: number;
|
|
706
|
+
let run: Awaited<ReturnType<typeof runEngineProcess>>;
|
|
580
707
|
try {
|
|
581
|
-
|
|
582
|
-
|
|
708
|
+
run = await runEngineProcess(binPath, installArgs, {
|
|
709
|
+
stdio: ["inherit", "inherit", "pipe"],
|
|
710
|
+
sinks: {
|
|
583
711
|
onProgress: (line, event) => {
|
|
584
712
|
if (event.pct !== undefined) {
|
|
585
713
|
status.update(line);
|
|
@@ -592,14 +720,17 @@ async function runEngineModelInstall(binPath: string, installArgs: string[]): Pr
|
|
|
592
720
|
status.clear();
|
|
593
721
|
log.warn(line);
|
|
594
722
|
},
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
]);
|
|
723
|
+
},
|
|
724
|
+
});
|
|
598
725
|
} finally {
|
|
599
726
|
status.clear();
|
|
600
|
-
tree.dispose();
|
|
601
727
|
}
|
|
602
|
-
|
|
728
|
+
const { exitCode, events } = run;
|
|
729
|
+
// #1301: engine 1.26.0 lets FluidAudio log a download retry it recovers from on raw fd 2; a clean exit outranks that line.
|
|
730
|
+
if (exitCode === 0 && events.error === null) {
|
|
731
|
+
for (const line of events.invalid) log.warn(offProtocolLine("install", line));
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
603
734
|
if (events.error || events.invalid.length > 0) throw engineFailure("install", events, exitCode);
|
|
604
735
|
if (exitCode !== 0) {
|
|
605
736
|
// Nothing coded and nothing off-protocol: the engine failed without saying why, so neither do we.
|
|
@@ -795,8 +926,12 @@ async function installLockedEngine(
|
|
|
795
926
|
// The marker vouches for a file, not for a working binary. Skipped on a read-only engine
|
|
796
927
|
// dir: nothing there can be repaired, so a failed probe would only turn a usable Nix
|
|
797
928
|
// install into a hard error.
|
|
929
|
+
// Hashed before the health probe, so an altered binary is never run.
|
|
798
930
|
const versionMatches =
|
|
799
|
-
markerMatches &&
|
|
931
|
+
markerMatches &&
|
|
932
|
+
(!canWriteEngineDir ||
|
|
933
|
+
((await cachedAssetMatchesPin(binPath, getEngineBinaryName(), "kesha-engine binary", version)) &&
|
|
934
|
+
(await engineWorks(binPath))));
|
|
800
935
|
// On read-only fs, --no-cache can't re-download; treat as cache-valid and forward flag to model install.
|
|
801
936
|
const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
|
|
802
937
|
|
|
@@ -835,19 +970,3 @@ async function installLockedEngine(
|
|
|
835
970
|
log.success(`Backend installed successfully (engine v${version}).`);
|
|
836
971
|
return binPath;
|
|
837
972
|
}
|
|
838
|
-
|
|
839
|
-
/** Installs the Pinned Engine version. Public API (`downloadModel`); the override is CLI-only. */
|
|
840
|
-
export async function downloadEngine(
|
|
841
|
-
noCache = false,
|
|
842
|
-
backend?: string,
|
|
843
|
-
options: InstallOptions = {},
|
|
844
|
-
): Promise<string> {
|
|
845
|
-
// Field-by-field, not a spread: a caller's stray `version` must not reach installEngine.
|
|
846
|
-
return installEngine({
|
|
847
|
-
noCache,
|
|
848
|
-
backend,
|
|
849
|
-
ttsLangs: options.ttsLangs,
|
|
850
|
-
vad: options.vad,
|
|
851
|
-
diarize: options.diarize,
|
|
852
|
-
});
|
|
853
|
-
}
|
package/src/engine-targets.ts
CHANGED
|
@@ -9,28 +9,76 @@ export interface EngineTarget {
|
|
|
9
9
|
/** GitHub release asset name — suffixed for uniqueness across platforms. */
|
|
10
10
|
assetName: string;
|
|
11
11
|
backend: "coreml" | "onnx";
|
|
12
|
-
/** Published asset size, for `kesha install --plan`. Verified by `check:engine-targets`. */
|
|
13
|
-
sizeBytes: number;
|
|
14
12
|
}
|
|
15
13
|
|
|
16
14
|
const ENGINE_TARGETS: Record<string, EngineTarget> = {
|
|
17
15
|
"darwin-arm64": {
|
|
18
16
|
assetName: "kesha-engine-darwin-arm64",
|
|
19
17
|
backend: "coreml",
|
|
20
|
-
sizeBytes: 64_713_088,
|
|
21
18
|
},
|
|
22
19
|
"linux-x64": {
|
|
23
20
|
assetName: "kesha-engine-linux-x64",
|
|
24
21
|
backend: "onnx",
|
|
25
|
-
sizeBytes: 66_588_184,
|
|
26
22
|
},
|
|
27
23
|
"win32-x64": {
|
|
28
24
|
assetName: "kesha-engine-windows-x64.exe",
|
|
29
25
|
backend: "onnx",
|
|
30
|
-
sizeBytes: 65_587_712,
|
|
31
26
|
},
|
|
32
27
|
};
|
|
33
28
|
|
|
29
|
+
/** Sidecar spec — centralises AVSpeech (#141) and future sidecars so each is one entry. */
|
|
30
|
+
export interface SidecarSpec {
|
|
31
|
+
/** Written next to the engine binary; Rust probes this exact name. */
|
|
32
|
+
fileBasename: string;
|
|
33
|
+
/** Release asset name — may differ from fileBasename (e.g. `say-avspeech-darwin-arm64` vs `say-avspeech`). */
|
|
34
|
+
assetName: string;
|
|
35
|
+
displayName: string;
|
|
36
|
+
availableHint: string;
|
|
37
|
+
unavailableHint: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const SIDECARS: SidecarSpec[] = [
|
|
41
|
+
{
|
|
42
|
+
fileBasename: "say-avspeech",
|
|
43
|
+
assetName: "say-avspeech-darwin-arm64",
|
|
44
|
+
displayName: "AVSpeech sidecar",
|
|
45
|
+
availableHint: "macOS voices available",
|
|
46
|
+
unavailableHint: "macos-* voices unavailable",
|
|
47
|
+
},
|
|
48
|
+
// Kokoro TTS (#207) and speaker diarization (#199) no longer ship as Swift
|
|
49
|
+
// sidecars — both run in-engine via the native `fluidaudio-rs` binding. Only
|
|
50
|
+
// the AVSpeech and text-lang sidecars remain.
|
|
51
|
+
{
|
|
52
|
+
// Runtime resolver looks for plain `kesha-textlang` next to the engine
|
|
53
|
+
// (see `rust/src/text_lang.rs::helper_path`), not the platform-suffixed
|
|
54
|
+
// release-asset name. Mismatch is intentional: the asset name needs the
|
|
55
|
+
// suffix for GitHub-release uniqueness; the sidecar lookup wants the
|
|
56
|
+
// unsuffixed binary so the same Rust code path works on the build-time
|
|
57
|
+
// OUT_DIR baked fallback.
|
|
58
|
+
fileBasename: "kesha-textlang",
|
|
59
|
+
assetName: "kesha-textlang-darwin-arm64",
|
|
60
|
+
displayName: "Text-lang sidecar",
|
|
61
|
+
availableHint: "detect-text-lang fast path",
|
|
62
|
+
unavailableHint:
|
|
63
|
+
"detect-text-lang will fail until next `kesha install` (no swift -e fallback)",
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/** Every asset `kesha install` downloads, and so every asset a published CLI's Engine pin covers. */
|
|
68
|
+
export function downloadedAssetNames(): string[] {
|
|
69
|
+
return [...Object.values(ENGINE_TARGETS).map((t) => t.assetName), ...SIDECARS.map((s) => s.assetName)];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Asset name to SHA-256 from a release's `sha256sum`-format SHA256SUMS, whose names carry a `./` prefix. */
|
|
73
|
+
export function parseSha256Sums(text: string): Map<string, string> {
|
|
74
|
+
const sums = new Map<string, string>();
|
|
75
|
+
for (const line of text.split(/\r?\n/)) {
|
|
76
|
+
const m = /^([0-9a-f]{64}) [ *]?(?:\.\/)?(\S.*)$/.exec(line.trim());
|
|
77
|
+
if (m) sums.set(m[2]!, m[1]!);
|
|
78
|
+
}
|
|
79
|
+
return sums;
|
|
80
|
+
}
|
|
81
|
+
|
|
34
82
|
/**
|
|
35
83
|
* The one target whose engine links FluidAudio, so the only one with ANE Kokoro assets,
|
|
36
84
|
* Swift sidecars and diarization. Kept beside the target table because that is what makes
|