@drakulavich/kesha-voice-kit 1.26.0 → 1.27.0-alpha.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 +2 -1
- package/completions/kesha.bash +1 -1
- package/completions/kesha.fish +1 -0
- package/completions/kesha.zsh +2 -1
- package/man/kesha.1 +3 -0
- package/package.json +3 -2
- package/src/cli/init.ts +20 -18
- package/src/cli/install.ts +80 -23
- package/src/doctor.ts +62 -5
- package/src/engine-install.ts +67 -24
- package/src/fluid-asr-cache.ts +91 -0
- package/src/install-plan.ts +121 -19
- package/src/semver.d.mts +16 -0
- package/src/semver.mjs +87 -0
- package/src/star.ts +3 -6
- package/src/status.ts +36 -9
- package/src/__tests__/error-codes-drift.test.ts +0 -29
- package/src/__tests__/error-codes.test.ts +0 -25
- package/src/__tests__/stats-error-codes.test.ts +0 -14
package/README.md
CHANGED
|
@@ -36,7 +36,8 @@ curl -fsSL https://bun.sh/install | bash # or: brew install oven-sh/bun/b
|
|
|
36
36
|
bun add -g @drakulavich/kesha-voice-kit
|
|
37
37
|
kesha --version # confirms `kesha` resolved on PATH
|
|
38
38
|
kesha install --plan # preview exact download/disk sizes first — downloads nothing
|
|
39
|
-
kesha install # ~2.5 GB
|
|
39
|
+
kesha install # ~2.5 GB on Linux/Windows; ~0.6 GB on Apple Silicon, whose CoreML
|
|
40
|
+
# engine uses a different, smaller model set. Explicit — never automatic.
|
|
40
41
|
# No progress bar during the model step; can take several minutes.
|
|
41
42
|
# Prefer a guided wizard? `kesha init` walks through the same choices interactively.
|
|
42
43
|
|
package/completions/kesha.bash
CHANGED
|
@@ -21,7 +21,7 @@ _kesha_completion() {
|
|
|
21
21
|
completions) opts="--help -h" ;;
|
|
22
22
|
doctor) opts="--help -h --json --redact" ;;
|
|
23
23
|
init) opts="--help -h --coreml --onnx --no-cache --plan --yes --tts --vad --diarize" ;;
|
|
24
|
-
install) opts="--help -h --coreml --onnx --no-cache --plan --tts --vad --diarize" ;;
|
|
24
|
+
install) opts="--help -h --coreml --onnx --no-cache --plan --tts --vad --diarize --engine-version" ;;
|
|
25
25
|
logs) opts="--help -h --json" ;;
|
|
26
26
|
manpage) opts="--help -h" ;;
|
|
27
27
|
mcp) opts="--help -h" ;;
|
package/completions/kesha.fish
CHANGED
|
@@ -54,6 +54,7 @@ complete -c kesha -n '__fish_seen_subcommand_from install' -l plan -d 'Show down
|
|
|
54
54
|
complete -c kesha -n '__fish_seen_subcommand_from install' -l tts -d 'Also install TTS models (English only; add codes for more, e.g. --tts en ru (preview sizes: kesha install --plan))'
|
|
55
55
|
complete -c kesha -n '__fish_seen_subcommand_from install' -l vad -d 'Also install Silero VAD (~2.3MB) for long-audio preprocessing'
|
|
56
56
|
complete -c kesha -n '__fish_seen_subcommand_from install' -l diarize -d 'Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)'
|
|
57
|
+
complete -c kesha -n '__fish_seen_subcommand_from install' -l engine-version -r -d 'Install this exact engine release instead of the pinned one (expert; the next install returns to the pin)'
|
|
57
58
|
complete -c kesha -n '__fish_seen_subcommand_from logs' -l help -d 'Show help'
|
|
58
59
|
complete -c kesha -n '__fish_seen_subcommand_from logs' -s h -d 'Show help'
|
|
59
60
|
complete -c kesha -n '__fish_seen_subcommand_from logs' -l json -d 'Output status as JSON'
|
package/completions/kesha.zsh
CHANGED
|
@@ -75,7 +75,8 @@ _kesha() {
|
|
|
75
75
|
'--plan[Show download, disk, and warm-up plan without changing local state]' \
|
|
76
76
|
'--tts[Also install TTS models (English only; add codes for more, e.g. --tts en ru (preview sizes: kesha install --plan))]' \
|
|
77
77
|
'--vad[Also install Silero VAD (~2.3MB) for long-audio preprocessing]' \
|
|
78
|
-
'--diarize[Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)]'
|
|
78
|
+
'--diarize[Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)]' \
|
|
79
|
+
'--engine-version=[Install this exact engine release instead of the pinned one (expert; the next install returns to the pin)]:engine version:'
|
|
79
80
|
;;
|
|
80
81
|
logs)
|
|
81
82
|
_arguments '--help[Show help]' \
|
package/man/kesha.1
CHANGED
|
@@ -141,6 +141,9 @@ Also install Silero VAD (~2.3MB) for long\-audio preprocessing
|
|
|
141
141
|
.TP
|
|
142
142
|
.B \-\-diarize
|
|
143
143
|
Also install the Sortformer streaming\-diarization model (~245MB, darwin\-arm64 only — #199)
|
|
144
|
+
.TP
|
|
145
|
+
.B \-\-engine\-version
|
|
146
|
+
Install this exact engine release instead of the pinned one (expert; the next install returns to the pin)
|
|
144
147
|
.SS logs
|
|
145
148
|
.TP
|
|
146
149
|
.B \-\-json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0-alpha.1",
|
|
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": {
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"SKILL.md",
|
|
26
26
|
"LICENSE",
|
|
27
27
|
"NOTICES.md",
|
|
28
|
-
"README.md"
|
|
28
|
+
"README.md",
|
|
29
|
+
"!src/__tests__"
|
|
29
30
|
],
|
|
30
31
|
"openclaw": {
|
|
31
32
|
"extensions": [
|
package/src/cli/init.ts
CHANGED
|
@@ -3,7 +3,13 @@ import { confirm, multiselect, isCancel, cancel } from "@clack/prompts";
|
|
|
3
3
|
import { renderInstallPlan } from "../install-plan";
|
|
4
4
|
import { log } from "../log";
|
|
5
5
|
import { getEngineCapabilities } from "../engine";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
performInstall,
|
|
8
|
+
resolveBackendFlag,
|
|
9
|
+
resolveNoCacheFlag,
|
|
10
|
+
TTS_LANG_FALLBACK,
|
|
11
|
+
unavailableBackendError,
|
|
12
|
+
} from "./install";
|
|
7
13
|
import type { SharedInstallArgs } from "./types";
|
|
8
14
|
|
|
9
15
|
const TTS_LANG_LABELS: Record<string, string> = {
|
|
@@ -171,7 +177,14 @@ export async function promptInitSelection(
|
|
|
171
177
|
};
|
|
172
178
|
}
|
|
173
179
|
|
|
174
|
-
|
|
180
|
+
/** Mirrors the guard in `performInstall`: a preview must not describe an install this platform rejects (#684). */
|
|
181
|
+
async function printPlan(selection: InitSelection): Promise<boolean> {
|
|
182
|
+
const backendError = unavailableBackendError(selection.backend);
|
|
183
|
+
if (backendError) {
|
|
184
|
+
log.error(backendError);
|
|
185
|
+
process.exitCode = 2;
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
175
188
|
log.info(
|
|
176
189
|
await renderInstallPlan({
|
|
177
190
|
noCache: selection.noCache,
|
|
@@ -181,6 +194,7 @@ async function printPlan(selection: InitSelection): Promise<void> {
|
|
|
181
194
|
diarize: selection.diarize,
|
|
182
195
|
}),
|
|
183
196
|
);
|
|
197
|
+
return true;
|
|
184
198
|
}
|
|
185
199
|
|
|
186
200
|
async function runNonInteractive(selection: InitSelection): Promise<void> {
|
|
@@ -190,7 +204,7 @@ async function runNonInteractive(selection: InitSelection): Promise<void> {
|
|
|
190
204
|
log.warn("--diarize is currently darwin-arm64 only; omitting it from non-interactive examples.");
|
|
191
205
|
}
|
|
192
206
|
log.info(renderInitOverview(canDiarize));
|
|
193
|
-
await printPlan(printableSelection);
|
|
207
|
+
if (!(await printPlan(printableSelection))) return;
|
|
194
208
|
log.info("Run one of these commands from an interactive terminal:");
|
|
195
209
|
for (const command of initSuggestionCommands(printableSelection, canDiarize)) {
|
|
196
210
|
log.info(` ${command.join(" ")}`);
|
|
@@ -260,13 +274,7 @@ export const initCommand = defineCommand({
|
|
|
260
274
|
if (selection.diarize && !installSelection.diarize) {
|
|
261
275
|
log.warn("--diarize is currently darwin-arm64 only; omitting it from the --yes install.");
|
|
262
276
|
}
|
|
263
|
-
await performInstall(
|
|
264
|
-
installSelection.noCache,
|
|
265
|
-
installSelection.backend,
|
|
266
|
-
installSelection.ttsLangs,
|
|
267
|
-
installSelection.vad,
|
|
268
|
-
installSelection.diarize,
|
|
269
|
-
);
|
|
277
|
+
await performInstall(installSelection);
|
|
270
278
|
return;
|
|
271
279
|
}
|
|
272
280
|
|
|
@@ -286,7 +294,7 @@ export const initCommand = defineCommand({
|
|
|
286
294
|
noCache,
|
|
287
295
|
);
|
|
288
296
|
log.info("");
|
|
289
|
-
await printPlan(prompted);
|
|
297
|
+
if (!(await printPlan(prompted))) return;
|
|
290
298
|
const confirmed = await promptConfirm(
|
|
291
299
|
`Run \`${initInstallArgs(prompted).join(" ")}\` now?`,
|
|
292
300
|
true,
|
|
@@ -295,13 +303,7 @@ export const initCommand = defineCommand({
|
|
|
295
303
|
log.info(`Skipped install. Run later: ${initInstallArgs(prompted).join(" ")}`);
|
|
296
304
|
return;
|
|
297
305
|
}
|
|
298
|
-
await performInstall(
|
|
299
|
-
prompted.noCache,
|
|
300
|
-
prompted.backend,
|
|
301
|
-
prompted.ttsLangs,
|
|
302
|
-
prompted.vad,
|
|
303
|
-
prompted.diarize,
|
|
304
|
-
);
|
|
306
|
+
await performInstall(prompted);
|
|
305
307
|
// #523: end on something runnable, not on the install log.
|
|
306
308
|
log.info("");
|
|
307
309
|
log.success("Kesha is ready. Try:");
|
package/src/cli/install.ts
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
|
-
import {
|
|
3
|
+
import { installEngine } from "../engine-install";
|
|
4
4
|
import { engineTarget } from "../engine-targets";
|
|
5
5
|
import { getEngineBinPath, getEngineCapabilities } from "../engine";
|
|
6
6
|
import { renderInstallPlan } from "../install-plan";
|
|
7
7
|
import { maybeAskForStar } from "../star";
|
|
8
8
|
import { log } from "../log";
|
|
9
9
|
import { packageVersion } from "../package-info";
|
|
10
|
+
import { isSemver } from "../semver.mjs";
|
|
10
11
|
import { createDiagnosticLogSession, type DiagnosticLogSession } from "../diagnostic-log";
|
|
11
12
|
import type { SharedInstallArgs } from "./types";
|
|
12
13
|
|
|
13
14
|
export interface InstallCommandArgs extends SharedInstallArgs {
|
|
14
15
|
/** Positional args after `install` — candidate TTS language codes. */
|
|
15
16
|
_?: string[];
|
|
17
|
+
/** `init` deliberately has no such flag: an override is an expert action, not a guided one. */
|
|
18
|
+
"engine-version"?: string;
|
|
19
|
+
engineVersion?: string;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
/**
|
|
@@ -57,6 +61,23 @@ export function resolveTtsLangs(input: TtsArgInput, supported: string[] | undefi
|
|
|
57
61
|
return langs;
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Validates `--engine-version` before any network call. Returns undefined when the flag is
|
|
66
|
+
* absent, so the Pinned Engine version stays the default; throws on anything the version
|
|
67
|
+
* drift gate would not accept either — one SemVer 2.0 definition across the repo.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveEngineVersionFlag(raw: unknown): string | undefined {
|
|
70
|
+
if (raw === undefined || raw === null || raw === false) return undefined;
|
|
71
|
+
const value = String(raw).trim();
|
|
72
|
+
if (isSemver(value)) return value;
|
|
73
|
+
const hint = /^v\d/.test(value)
|
|
74
|
+
? ' Drop the leading "v" — that belongs to the release tag, not the version.'
|
|
75
|
+
: "";
|
|
76
|
+
throw new Error(
|
|
77
|
+
`--engine-version needs an exact SemVer 2.0 version like 1.24.8 or 1.24.8-alpha.1, got "${value}".${hint}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
60
81
|
export function resolveNoCacheFlag(
|
|
61
82
|
args: Pick<InstallCommandArgs, "no-cache" | "noCache" | "no_cache">,
|
|
62
83
|
rawArgs: string[] = [],
|
|
@@ -120,16 +141,42 @@ function finishInstallDiagnostic(
|
|
|
120
141
|
}
|
|
121
142
|
}
|
|
122
143
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
144
|
+
/** Null when the requested backend is installable here. `KESHA_ENGINE_BIN` opts out — the user supplied their own engine. */
|
|
145
|
+
export function unavailableBackendError(backend: string | undefined): string | null {
|
|
146
|
+
const platformBackend = defaultBackendForPlatform();
|
|
147
|
+
if (!backend || process.env.KESHA_ENGINE_BIN || !platformBackend) return null;
|
|
148
|
+
if (backend === platformBackend) return null;
|
|
149
|
+
return (
|
|
150
|
+
`Requested backend "${backend}" is not available on this platform; ` +
|
|
151
|
+
`the release engine uses "${platformBackend}".`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface PerformInstallOptions {
|
|
156
|
+
noCache: boolean;
|
|
157
|
+
backend?: string;
|
|
158
|
+
ttsLangs: string[];
|
|
159
|
+
vad?: boolean;
|
|
160
|
+
diarize?: boolean;
|
|
161
|
+
plan?: boolean;
|
|
162
|
+
/** Engine release to install instead of the pin, for this invocation only. */
|
|
163
|
+
engineVersion?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function performInstall(options: PerformInstallOptions) {
|
|
167
|
+
const { noCache, backend, ttsLangs, vad = false, diarize = false, plan = false, engineVersion } =
|
|
168
|
+
options;
|
|
169
|
+
// A plan for an unavailable backend previews what its own printed command rejects (#684).
|
|
170
|
+
const backendError = unavailableBackendError(backend);
|
|
131
171
|
if (plan) {
|
|
132
|
-
|
|
172
|
+
if (backendError) {
|
|
173
|
+
log.error(backendError);
|
|
174
|
+
process.exitCode = 2;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
log.info(
|
|
178
|
+
await renderInstallPlan({ noCache, backend, ttsLangs, vad, diarize, engineVersion }),
|
|
179
|
+
);
|
|
133
180
|
return;
|
|
134
181
|
}
|
|
135
182
|
|
|
@@ -145,6 +192,7 @@ export async function performInstall(
|
|
|
145
192
|
tts: ttsLangs.length > 0,
|
|
146
193
|
vad,
|
|
147
194
|
diarize,
|
|
195
|
+
engineVersionOverride: engineVersion !== undefined,
|
|
148
196
|
});
|
|
149
197
|
|
|
150
198
|
if (diarize && !(process.platform === "darwin" && process.arch === "arm64")) {
|
|
@@ -154,15 +202,11 @@ export async function performInstall(
|
|
|
154
202
|
"(see https://github.com/drakulavich/kesha-voice-kit/issues/199).",
|
|
155
203
|
);
|
|
156
204
|
}
|
|
157
|
-
|
|
158
|
-
if (backend && !process.env.KESHA_ENGINE_BIN && platformBackend && backend !== platformBackend) {
|
|
205
|
+
if (backendError) {
|
|
159
206
|
errorKind = "validation_failed";
|
|
160
|
-
throw new Error(
|
|
161
|
-
`Requested backend "${backend}" is not available on this platform; ` +
|
|
162
|
-
`the release engine uses "${platformBackend}".`,
|
|
163
|
-
);
|
|
207
|
+
throw new Error(backendError);
|
|
164
208
|
}
|
|
165
|
-
await
|
|
209
|
+
await installEngine({ noCache, backend, ttsLangs, vad, diarize, version: engineVersion });
|
|
166
210
|
await maybeAskForStar(getEngineBinPath(), packageVersion, log);
|
|
167
211
|
finishInstallDiagnostic(diagnosticLog, startedAt, "success");
|
|
168
212
|
} catch (err: unknown) {
|
|
@@ -214,8 +258,20 @@ export const installCommand = defineCommand({
|
|
|
214
258
|
description: "Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)",
|
|
215
259
|
default: false,
|
|
216
260
|
},
|
|
261
|
+
"engine-version": {
|
|
262
|
+
type: "string",
|
|
263
|
+
description:
|
|
264
|
+
"Install this exact engine release instead of the pinned one (expert; the next install returns to the pin)",
|
|
265
|
+
},
|
|
217
266
|
},
|
|
218
267
|
async run({ args, rawArgs }: { args: InstallCommandArgs; rawArgs: string[] }) {
|
|
268
|
+
let engineVersion: string | undefined;
|
|
269
|
+
try {
|
|
270
|
+
engineVersion = resolveEngineVersionFlag(args["engine-version"] ?? args.engineVersion);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
log.error(errorMessage(err));
|
|
273
|
+
process.exit(2);
|
|
274
|
+
}
|
|
219
275
|
const backend = resolveBackendFlag(args.coreml, args.onnx);
|
|
220
276
|
const positionals = (args._ ?? []).map(String);
|
|
221
277
|
const caps = await getEngineCapabilities();
|
|
@@ -227,13 +283,14 @@ export const installCommand = defineCommand({
|
|
|
227
283
|
log.error(errorMessage(err));
|
|
228
284
|
process.exit(1);
|
|
229
285
|
}
|
|
230
|
-
await performInstall(
|
|
231
|
-
resolveNoCacheFlag(args, rawArgs),
|
|
286
|
+
await performInstall({
|
|
287
|
+
noCache: resolveNoCacheFlag(args, rawArgs),
|
|
232
288
|
backend,
|
|
233
289
|
ttsLangs,
|
|
234
|
-
args.vad,
|
|
235
|
-
args.diarize,
|
|
236
|
-
args.plan,
|
|
237
|
-
|
|
290
|
+
vad: args.vad,
|
|
291
|
+
diarize: args.diarize,
|
|
292
|
+
plan: args.plan,
|
|
293
|
+
engineVersion,
|
|
294
|
+
});
|
|
238
295
|
},
|
|
239
296
|
});
|
package/src/doctor.ts
CHANGED
|
@@ -10,12 +10,13 @@ import {
|
|
|
10
10
|
} from "./engine";
|
|
11
11
|
import { readInstalledEngineVersion } from "./engine-version-marker";
|
|
12
12
|
import { keshaCacheDir } from "./paths";
|
|
13
|
-
import { packageName, packageVersion } from "./package-info";
|
|
13
|
+
import { engineVersion, packageName, packageVersion } from "./package-info";
|
|
14
14
|
import { getStatsStatus, type StatsStatus } from "./stats";
|
|
15
15
|
import {
|
|
16
16
|
fluidKokoroCacheInfo,
|
|
17
17
|
type FluidKokoroCacheInfo,
|
|
18
18
|
} from "./fluid-kokoro-cache";
|
|
19
|
+
import { fluidAsrCacheInfo, isCoremlBackend } from "./fluid-asr-cache";
|
|
19
20
|
import { diagnosticHomeDir, dirSizeBytes } from "./diagnostic-paths";
|
|
20
21
|
import {
|
|
21
22
|
getDiagnosticLogStatus,
|
|
@@ -30,6 +31,7 @@ const KNOWN_ENV_KEYS = [
|
|
|
30
31
|
"KESHA_STATS_DB",
|
|
31
32
|
"KESHA_DEBUG",
|
|
32
33
|
"KESHA_DEBUG_FD",
|
|
34
|
+
"KESHA_KOKORO_COMPUTE_UNITS",
|
|
33
35
|
] as const;
|
|
34
36
|
|
|
35
37
|
interface DoctorOptions {
|
|
@@ -53,6 +55,21 @@ interface OptionalComponent extends PathSummary {
|
|
|
53
55
|
|
|
54
56
|
type DoctorDiagnosticLogStatus = DiagnosticLogStatus & { error?: string };
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* How the Recorded Engine version relates to the Pinned Engine version. `differs-from-pin`
|
|
60
|
+
* is a supported state (`kesha install --engine-version`), not a fault — `unrecorded` is
|
|
61
|
+
* separate so a missing marker is never reported as a difference.
|
|
62
|
+
*/
|
|
63
|
+
export type EngineVersionState = "matches-pin" | "differs-from-pin" | "unrecorded";
|
|
64
|
+
|
|
65
|
+
export function engineVersionState(
|
|
66
|
+
versionMarker: string | null,
|
|
67
|
+
pinnedVersion: string,
|
|
68
|
+
): EngineVersionState {
|
|
69
|
+
if (versionMarker === null) return "unrecorded";
|
|
70
|
+
return versionMarker === pinnedVersion ? "matches-pin" : "differs-from-pin";
|
|
71
|
+
}
|
|
72
|
+
|
|
56
73
|
export interface DoctorReport {
|
|
57
74
|
generatedAt: string;
|
|
58
75
|
redacted: boolean;
|
|
@@ -69,6 +86,8 @@ export interface DoctorReport {
|
|
|
69
86
|
path: string;
|
|
70
87
|
installed: boolean;
|
|
71
88
|
versionMarker: string | null;
|
|
89
|
+
pinnedVersion: string;
|
|
90
|
+
versionState: EngineVersionState;
|
|
72
91
|
capabilities: EngineCapabilities | null;
|
|
73
92
|
probeError: string | null;
|
|
74
93
|
};
|
|
@@ -188,10 +207,14 @@ async function collectEngine(redact: boolean): Promise<DoctorReport["engine"]> {
|
|
|
188
207
|
}
|
|
189
208
|
}
|
|
190
209
|
|
|
210
|
+
const versionMarker = readInstalledEngineVersion(binPath);
|
|
211
|
+
|
|
191
212
|
return {
|
|
192
213
|
path: redactPath(binPath, redact),
|
|
193
214
|
installed,
|
|
194
|
-
versionMarker
|
|
215
|
+
versionMarker,
|
|
216
|
+
pinnedVersion: engineVersion,
|
|
217
|
+
versionState: engineVersionState(versionMarker, engineVersion),
|
|
195
218
|
capabilities,
|
|
196
219
|
probeError: redactString("probeError", probeError, redact),
|
|
197
220
|
};
|
|
@@ -200,18 +223,33 @@ async function collectEngine(redact: boolean): Promise<DoctorReport["engine"]> {
|
|
|
200
223
|
function collectCache(
|
|
201
224
|
redact: boolean,
|
|
202
225
|
fluidKokoro: FluidKokoroCacheInfo,
|
|
226
|
+
backend?: string,
|
|
203
227
|
): DoctorReport["cache"] {
|
|
204
228
|
const cache = keshaCacheDir();
|
|
205
229
|
const binPath = getEngineBinPath();
|
|
206
230
|
const engineDir = dirname(dirname(binPath));
|
|
231
|
+
const coreml = isCoremlBackend(backend);
|
|
207
232
|
const components: CacheComponent[] = [
|
|
208
233
|
{ label: "Engine", ...pathSummary(engineDir) },
|
|
209
|
-
|
|
234
|
+
// A CoreML engine never populates the ONNX dir; pointing at it would render a
|
|
235
|
+
// healthy darwin install as "ASR missing" (#684).
|
|
236
|
+
...(coreml
|
|
237
|
+
? []
|
|
238
|
+
: [{ label: "ASR (Parakeet)", ...pathSummary(join(cache, "models/parakeet-tdt-v3")) }]),
|
|
210
239
|
{ label: "Language ID", ...pathSummary(join(cache, "models/lang-id-ecapa")) },
|
|
211
240
|
{ label: "VAD (Silero)", ...pathSummary(join(cache, "models/silero-vad")) },
|
|
212
241
|
{ label: "TTS (Kokoro)", ...pathSummary(join(cache, "models/kokoro-82m")) },
|
|
213
242
|
{ label: "TTS (Vosk)", ...pathSummary(join(cache, "models/vosk-ru")) },
|
|
214
243
|
];
|
|
244
|
+
if (coreml) {
|
|
245
|
+
const fluidAsr = fluidAsrCacheInfo();
|
|
246
|
+
components.push({
|
|
247
|
+
label: "ASR (Parakeet, FluidAudio) (external)",
|
|
248
|
+
path: fluidAsr.path,
|
|
249
|
+
exists: fluidAsr.exists,
|
|
250
|
+
sizeBytes: fluidAsr.sizeBytes,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
215
253
|
if (fluidKokoro.supported) {
|
|
216
254
|
components.push({
|
|
217
255
|
label: "FluidAudio Kokoro cache (external)",
|
|
@@ -346,6 +384,7 @@ export async function collectDoctorReport(
|
|
|
346
384
|
): Promise<DoctorReport> {
|
|
347
385
|
const redact = options.redact === true;
|
|
348
386
|
const fluidKokoro = fluidKokoroCacheInfo();
|
|
387
|
+
const engine = await collectEngine(redact);
|
|
349
388
|
return {
|
|
350
389
|
generatedAt: new Date().toISOString(),
|
|
351
390
|
redacted: redact,
|
|
@@ -355,8 +394,8 @@ export async function collectDoctorReport(
|
|
|
355
394
|
platform: process.platform,
|
|
356
395
|
arch: process.arch,
|
|
357
396
|
},
|
|
358
|
-
engine
|
|
359
|
-
cache: collectCache(redact, fluidKokoro),
|
|
397
|
+
engine,
|
|
398
|
+
cache: collectCache(redact, fluidKokoro, engine.capabilities?.backend),
|
|
360
399
|
optionalComponents: collectOptionalComponents(redact, fluidKokoro),
|
|
361
400
|
stats: collectStats(redact),
|
|
362
401
|
diagnosticLogs: collectDiagnosticLogs(redact),
|
|
@@ -368,6 +407,22 @@ function formatInstalled(installed: boolean): string {
|
|
|
368
407
|
return installed ? "installed" : "missing";
|
|
369
408
|
}
|
|
370
409
|
|
|
410
|
+
function formatVersionState(engine: DoctorReport["engine"]): string {
|
|
411
|
+
switch (engine.versionState) {
|
|
412
|
+
case "differs-from-pin":
|
|
413
|
+
return (
|
|
414
|
+
`installed v${engine.versionMarker} differs from the pinned v${engine.pinnedVersion} — ` +
|
|
415
|
+
"supported; `kesha install` without --engine-version restores the pin"
|
|
416
|
+
);
|
|
417
|
+
case "unrecorded":
|
|
418
|
+
return engine.installed
|
|
419
|
+
? "no recorded version beside the binary; `kesha install` records one"
|
|
420
|
+
: "no engine installed";
|
|
421
|
+
default:
|
|
422
|
+
return "matches the pin";
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
371
426
|
function formatCapabilities(engine: DoctorReport["engine"]): string {
|
|
372
427
|
if (!engine.capabilities) return engine.probeError ?? "not available";
|
|
373
428
|
const { backend, protocolVersion, features } = engine.capabilities;
|
|
@@ -437,6 +492,8 @@ export function formatDoctorReport(report: DoctorReport): string {
|
|
|
437
492
|
"Engine:",
|
|
438
493
|
` Binary: ${report.engine.path} (${formatInstalled(report.engine.installed)})`,
|
|
439
494
|
` Version marker: ${report.engine.versionMarker ?? "missing"}`,
|
|
495
|
+
` Pinned version: ${report.engine.pinnedVersion}`,
|
|
496
|
+
` Version state: ${formatVersionState(report.engine)}`,
|
|
440
497
|
` Capabilities: ${formatCapabilities(report.engine)}`,
|
|
441
498
|
"",
|
|
442
499
|
...formatCacheSection(report.cache),
|
package/src/engine-install.ts
CHANGED
|
@@ -192,12 +192,12 @@ function darwinTrustBinary(path: string, displayName: string): void {
|
|
|
192
192
|
async function downloadSidecar(
|
|
193
193
|
spec: SidecarSpec,
|
|
194
194
|
binPath: string,
|
|
195
|
-
|
|
195
|
+
version: string,
|
|
196
196
|
): Promise<void> {
|
|
197
197
|
if (!isDarwinArm64()) return;
|
|
198
198
|
|
|
199
199
|
const sidecarPath = join(dirname(binPath), spec.fileBasename);
|
|
200
|
-
const url = `https://github.com/${GITHUB_REPO}/releases/download/v${
|
|
200
|
+
const url = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${spec.assetName}`;
|
|
201
201
|
|
|
202
202
|
let res: Response;
|
|
203
203
|
try {
|
|
@@ -211,7 +211,7 @@ async function downloadSidecar(
|
|
|
211
211
|
|
|
212
212
|
if (!res.ok) {
|
|
213
213
|
log.warn(
|
|
214
|
-
`${spec.displayName} not in release v${
|
|
214
|
+
`${spec.displayName} not in release v${version} (HTTP ${res.status}); ${spec.unavailableHint}.`,
|
|
215
215
|
);
|
|
216
216
|
return;
|
|
217
217
|
}
|
|
@@ -328,21 +328,22 @@ export interface InstallOptions {
|
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
/**
|
|
331
|
-
* Cache-valid path: the binary at binPath already matches
|
|
331
|
+
* Cache-valid path: the binary at binPath already matches the requested version.
|
|
332
332
|
* Re-trusts it and tops up any sidecars the cached install is missing.
|
|
333
333
|
*/
|
|
334
334
|
async function refreshCachedEngine(
|
|
335
335
|
binPath: string,
|
|
336
336
|
canWriteEngineDir: boolean,
|
|
337
337
|
noCache: boolean,
|
|
338
|
+
version: string,
|
|
338
339
|
): Promise<void> {
|
|
339
340
|
const engineDir = dirname(binPath);
|
|
340
341
|
if (noCache && !canWriteEngineDir) {
|
|
341
342
|
log.info(
|
|
342
|
-
`Engine binary at v${
|
|
343
|
+
`Engine binary at v${version} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
|
|
343
344
|
);
|
|
344
345
|
} else {
|
|
345
|
-
log.success(`Engine binary already installed (v${
|
|
346
|
+
log.success(`Engine binary already installed (v${version}).`);
|
|
346
347
|
}
|
|
347
348
|
// Re-trust on cache hit: a user who upgraded to Sequoia after install would still have
|
|
348
349
|
// com.apple.provenance attached; idempotent (~10ms no-op if already correct).
|
|
@@ -355,7 +356,7 @@ async function refreshCachedEngine(
|
|
|
355
356
|
const missing = SIDECARS.filter(
|
|
356
357
|
(s) => !existsSync(join(engineDir, s.fileBasename)),
|
|
357
358
|
);
|
|
358
|
-
await Promise.all(missing.map((s) => downloadSidecar(s, binPath,
|
|
359
|
+
await Promise.all(missing.map((s) => downloadSidecar(s, binPath, version)));
|
|
359
360
|
// Also re-trust already-present sidecars for the Sequoia upgrade scenario.
|
|
360
361
|
for (const s of SIDECARS) {
|
|
361
362
|
const p = join(engineDir, s.fileBasename);
|
|
@@ -428,21 +429,22 @@ export async function waitUntilSpawnable(
|
|
|
428
429
|
async function fetchEngineBinary(
|
|
429
430
|
binPath: string,
|
|
430
431
|
installedVersion: string | null,
|
|
432
|
+
version: string,
|
|
431
433
|
): Promise<void> {
|
|
432
434
|
// Log why we're downloading — helps diagnose surprising re-downloads.
|
|
433
|
-
if (existsSync(binPath) && installedVersion && installedVersion !==
|
|
435
|
+
if (existsSync(binPath) && installedVersion && installedVersion !== version) {
|
|
434
436
|
log.progress(
|
|
435
|
-
`
|
|
437
|
+
`Replacing engine v${installedVersion} → v${version}...`,
|
|
436
438
|
);
|
|
437
439
|
}
|
|
438
440
|
const binaryName = getEngineBinaryName();
|
|
439
|
-
const url = `https://github.com/${GITHUB_REPO}/releases/download/v${
|
|
441
|
+
const url = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${binaryName}`;
|
|
440
442
|
|
|
441
443
|
mkdirSync(dirname(binPath), { recursive: true });
|
|
442
444
|
|
|
443
445
|
// Overlap sidecar fetches with the engine fetch (~15-30s saved on cold install).
|
|
444
446
|
const sidecarPromises = SIDECARS.map((s) =>
|
|
445
|
-
downloadSidecar(s, binPath,
|
|
447
|
+
downloadSidecar(s, binPath, version),
|
|
446
448
|
);
|
|
447
449
|
// If the engine fetch throws, silence in-flight sidecar rejections so unhandledRejection doesn't obscure the engine error.
|
|
448
450
|
const muteSidecarRejections = () =>
|
|
@@ -460,8 +462,9 @@ async function fetchEngineBinary(
|
|
|
460
462
|
|
|
461
463
|
if (!res.ok) {
|
|
462
464
|
muteSidecarRejections();
|
|
465
|
+
// Names the tag: nothing falls back to the pin, so the caller must see which release 404ed.
|
|
463
466
|
throw new Error(
|
|
464
|
-
`Failed to download engine binary (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
|
|
467
|
+
`Failed to download engine binary from release v${version} (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
|
|
465
468
|
);
|
|
466
469
|
}
|
|
467
470
|
|
|
@@ -470,8 +473,8 @@ async function fetchEngineBinary(
|
|
|
470
473
|
darwinTrustBinary(binPath, "kesha-engine binary");
|
|
471
474
|
// Marker last: writing it first sends the retry down the cacheValid branch, which never waits.
|
|
472
475
|
await waitUntilSpawnable(binPath);
|
|
473
|
-
writeInstalledEngineVersion(binPath,
|
|
474
|
-
log.success(`Engine binary downloaded (v${
|
|
476
|
+
writeInstalledEngineVersion(binPath, version);
|
|
477
|
+
log.success(`Engine binary downloaded (v${version}).`);
|
|
475
478
|
await Promise.all(sidecarPromises);
|
|
476
479
|
}
|
|
477
480
|
|
|
@@ -556,27 +559,51 @@ function runEngineModelInstall(
|
|
|
556
559
|
}
|
|
557
560
|
}
|
|
558
561
|
|
|
559
|
-
export
|
|
560
|
-
noCache
|
|
561
|
-
backend?: string
|
|
562
|
-
|
|
563
|
-
|
|
562
|
+
export interface EngineInstallRequest extends InstallOptions {
|
|
563
|
+
noCache?: boolean;
|
|
564
|
+
backend?: string;
|
|
565
|
+
/** Engine release to install; defaults to the Pinned Engine version and applies to this call only. */
|
|
566
|
+
version?: string;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Installs one Engine release plus its models.
|
|
571
|
+
*
|
|
572
|
+
* `version` is the single input for the release URL, the cache-validity comparison, the
|
|
573
|
+
* sidecar downloads and the recorded `.version` marker — reading the pin at any one of
|
|
574
|
+
* them would install the requested Engine and then replace it on the next cache check.
|
|
575
|
+
*/
|
|
576
|
+
export async function installEngine(request: EngineInstallRequest = {}): Promise<string> {
|
|
577
|
+
const { noCache = false, backend, version = engineVersion, ...options } = request;
|
|
564
578
|
const binPath = getEngineBinPath();
|
|
565
579
|
const installedVersion = readInstalledEngineVersion(binPath);
|
|
566
580
|
const engineDir = dirname(binPath);
|
|
567
581
|
|
|
582
|
+
if (version !== engineVersion) {
|
|
583
|
+
log.info(
|
|
584
|
+
`Installing engine v${version} instead of the pinned v${engineVersion}; ` +
|
|
585
|
+
"a later `kesha install` without --engine-version restores the pin.",
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
568
589
|
// Read-only engine dir = Nix-store install; skip download/sidecar writes to avoid EROFS errors.
|
|
569
590
|
const canWriteEngineDir = checkEngineWritable(engineDir);
|
|
570
591
|
|
|
571
|
-
const versionMatches =
|
|
572
|
-
existsSync(binPath) && installedVersion === engineVersion;
|
|
592
|
+
const versionMatches = existsSync(binPath) && installedVersion === version;
|
|
573
593
|
// On read-only fs, --no-cache can't re-download; treat as cache-valid and forward flag to model install.
|
|
574
594
|
const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
|
|
575
595
|
|
|
576
596
|
if (cacheValid) {
|
|
577
|
-
await refreshCachedEngine(binPath, canWriteEngineDir, noCache);
|
|
597
|
+
await refreshCachedEngine(binPath, canWriteEngineDir, noCache, version);
|
|
578
598
|
} else {
|
|
579
|
-
|
|
599
|
+
if (!canWriteEngineDir) {
|
|
600
|
+
throw new Error(
|
|
601
|
+
`Cannot install engine v${version}: ${engineDir} is not writable ` +
|
|
602
|
+
`(installed: ${installedVersion ? `v${installedVersion}` : "no recorded version"}).\n` +
|
|
603
|
+
" Fix: point KESHA_ENGINE_BIN at a writable path, or install into a writable prefix.",
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
await fetchEngineBinary(binPath, installedVersion, version);
|
|
580
607
|
}
|
|
581
608
|
|
|
582
609
|
if (backend || options.diarize) {
|
|
@@ -599,6 +626,22 @@ export async function downloadEngine(
|
|
|
599
626
|
cleanupRetiredSidecars(engineDir);
|
|
600
627
|
}
|
|
601
628
|
|
|
602
|
-
log.success(
|
|
629
|
+
log.success(`Backend installed successfully (engine v${version}).`);
|
|
603
630
|
return binPath;
|
|
604
631
|
}
|
|
632
|
+
|
|
633
|
+
/** Installs the Pinned Engine version. Public API (`downloadModel`); the override is CLI-only. */
|
|
634
|
+
export async function downloadEngine(
|
|
635
|
+
noCache = false,
|
|
636
|
+
backend?: string,
|
|
637
|
+
options: InstallOptions = {},
|
|
638
|
+
): Promise<string> {
|
|
639
|
+
// Field-by-field, not a spread: a caller's stray `version` must not reach installEngine.
|
|
640
|
+
return installEngine({
|
|
641
|
+
noCache,
|
|
642
|
+
backend,
|
|
643
|
+
ttsLangs: options.ttsLangs,
|
|
644
|
+
vad: options.vad,
|
|
645
|
+
diarize: options.diarize,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { diagnosticHomeDir, dirSizeBytes } from "./diagnostic-paths";
|
|
4
|
+
import { isDarwinArm64 } from "./fluid-kokoro-cache";
|
|
5
|
+
import { engineTarget } from "./engine-targets";
|
|
6
|
+
|
|
7
|
+
export const FLUID_ASR_CACHE_NOTE =
|
|
8
|
+
"required for speech-to-text; fetched by the backend during warm-up, outside Kesha's model cache";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Which ASR layout applies. The reported backend is authoritative; when the capabilities
|
|
12
|
+
* probe yields nothing, fall back to the platform's shipped backend rather than to "not
|
|
13
|
+
* CoreML", which would point darwin at an ONNX directory it never populates (#684).
|
|
14
|
+
*/
|
|
15
|
+
export function isCoremlBackend(
|
|
16
|
+
backend?: string,
|
|
17
|
+
platform?: string,
|
|
18
|
+
arch?: string,
|
|
19
|
+
): boolean {
|
|
20
|
+
return (backend ?? engineTarget(platform, arch)?.backend) === "coreml";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FluidAsrCacheInfo {
|
|
24
|
+
supported: boolean;
|
|
25
|
+
path: string;
|
|
26
|
+
exists: boolean;
|
|
27
|
+
sizeBytes: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* FluidAudio loads ASR from `<ApplicationSupport>/FluidAudio/Models/<repo folder>`,
|
|
32
|
+
* and that folder name has the `-coreml` suffix stripped — the `…-v3-coreml` sibling
|
|
33
|
+
* that also appears on disk is not what it reads (#684).
|
|
34
|
+
*/
|
|
35
|
+
export function fluidAsrCachePath(homeDir = diagnosticHomeDir()): string {
|
|
36
|
+
return join(
|
|
37
|
+
homeDir,
|
|
38
|
+
"Library",
|
|
39
|
+
"Application Support",
|
|
40
|
+
"FluidAudio",
|
|
41
|
+
"Models",
|
|
42
|
+
"parakeet-tdt-0.6b-v3",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What FluidAudio's own `modelsExist` requires. The encoder is pinned to int8 because
|
|
48
|
+
* the bridge calls `downloadAndLoad(to:)` with its default `useInt8Encoder: true` —
|
|
49
|
+
* accepting `EncoderInt4.mlmodelc` would pass preflight and then let FluidAudio fetch
|
|
50
|
+
* the int8 encoder on first transcribe. Keep in step with `models.rs::FLUID_ASR_REQUIRED`.
|
|
51
|
+
*/
|
|
52
|
+
export const FLUID_ASR_REQUIRED = [
|
|
53
|
+
"Preprocessor.mlmodelc",
|
|
54
|
+
"Encoder.mlmodelc",
|
|
55
|
+
"Decoder.mlmodelc",
|
|
56
|
+
"JointDecisionv3.mlmodelc",
|
|
57
|
+
"parakeet_vocab.json",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Complete enough to transcribe. Mirrors `models.rs::fluidaudio_asr_ready` — a bare
|
|
62
|
+
* directory check would call an interrupted fetch healthy, and the engine would then
|
|
63
|
+
* download the remainder on first transcribe (#684).
|
|
64
|
+
*
|
|
65
|
+
* Existence-only on purpose: FluidAudio's own `modelsExist` is `fileExists` over the same
|
|
66
|
+
* names, and its download skips whatever that predicate accepts. Requiring more here (that
|
|
67
|
+
* each `.mlmodelc` is a directory, that `coremldata.bin` is inside) would report not-ready
|
|
68
|
+
* for a bundle FluidAudio considers present, so `kesha install` would fetch nothing and the
|
|
69
|
+
* user could never clear the error. Match the loader; do not out-strict it.
|
|
70
|
+
*/
|
|
71
|
+
export function fluidAsrCacheReady(path: string): boolean {
|
|
72
|
+
return FLUID_ASR_REQUIRED.every((f) => existsSync(join(path, f)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function fluidAsrCacheInfo(
|
|
76
|
+
options: {
|
|
77
|
+
platform?: typeof process.platform;
|
|
78
|
+
arch?: typeof process.arch;
|
|
79
|
+
homeDir?: string;
|
|
80
|
+
} = {},
|
|
81
|
+
): FluidAsrCacheInfo {
|
|
82
|
+
const supported = isDarwinArm64(options.platform, options.arch);
|
|
83
|
+
const path = fluidAsrCachePath(options.homeDir);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
supported,
|
|
87
|
+
path,
|
|
88
|
+
exists: supported && fluidAsrCacheReady(path),
|
|
89
|
+
sizeBytes: supported ? dirSizeBytes(path) : 0,
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/install-plan.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, statSync } from "fs";
|
|
2
2
|
import { humanBytes } from "./format";
|
|
3
3
|
import { dirname, join } from "path";
|
|
4
|
-
import { getEngineBinPath } from "./engine";
|
|
4
|
+
import { getEngineBinPath, getEngineCapabilities } from "./engine";
|
|
5
5
|
import { SIDECARS } from "./engine-install";
|
|
6
6
|
import { engineTarget } from "./engine-targets";
|
|
7
7
|
import { readInstalledEngineVersion } from "./engine-version-marker";
|
|
@@ -13,6 +13,12 @@ import {
|
|
|
13
13
|
isDarwinArm64,
|
|
14
14
|
} from "./fluid-kokoro-cache";
|
|
15
15
|
import modelPlan from "../model-plan.json" with { type: "json" };
|
|
16
|
+
import {
|
|
17
|
+
FLUID_ASR_CACHE_NOTE,
|
|
18
|
+
fluidAsrCachePath,
|
|
19
|
+
fluidAsrCacheReady,
|
|
20
|
+
isCoremlBackend,
|
|
21
|
+
} from "./fluid-asr-cache";
|
|
16
22
|
|
|
17
23
|
export interface InstallPlanOptions {
|
|
18
24
|
noCache?: boolean;
|
|
@@ -21,6 +27,8 @@ export interface InstallPlanOptions {
|
|
|
21
27
|
ttsLangs?: string[];
|
|
22
28
|
vad?: boolean;
|
|
23
29
|
diarize?: boolean;
|
|
30
|
+
/** `--engine-version`; the plan must preview the release the real install would fetch. */
|
|
31
|
+
engineVersion?: string;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
interface PlanFile {
|
|
@@ -46,6 +54,8 @@ interface PlanComponent {
|
|
|
46
54
|
cached: boolean;
|
|
47
55
|
refresh: boolean;
|
|
48
56
|
note?: string;
|
|
57
|
+
/** Fetched by a backend into its own cache — excluded from the Kesha-managed totals. */
|
|
58
|
+
external?: boolean;
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
interface PlanWarmup {
|
|
@@ -135,14 +145,59 @@ function bundleComponent(input: {
|
|
|
135
145
|
};
|
|
136
146
|
}
|
|
137
147
|
|
|
138
|
-
|
|
148
|
+
/**
|
|
149
|
+
* A CoreML engine cannot read the ONNX weights, so quoting their 2.43 GB would
|
|
150
|
+
* promise a download that never happens. FluidAudio fetches its own bundle during
|
|
151
|
+
* the install warm-up instead — still a cost, so it is named rather than dropped (#684).
|
|
152
|
+
*
|
|
153
|
+
* Measured from `parakeet-tdt-0.6b-v3`, the directory the current FluidAudio actually
|
|
154
|
+
* loads. A `…-v3-coreml` sibling may also exist from earlier versions; counting it here
|
|
155
|
+
* would overstate the plan against what `status --disk` then reports. An estimate, not a
|
|
156
|
+
* manifest sum — rendered as approximate.
|
|
157
|
+
*/
|
|
158
|
+
const FLUID_ASR_BUNDLE_BYTES = 473 * 1024 * 1024;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* An explicit `--coreml` / `--onnx` wins. Otherwise ask the installed engine, so a custom
|
|
162
|
+
* `KESHA_ENGINE_BIN` is planned for what it actually is rather than what the host usually
|
|
163
|
+
* ships (#684). The probe is optional: `--plan` must still work before any engine exists,
|
|
164
|
+
* and `isCoremlBackend` falls back to the platform when it yields nothing.
|
|
165
|
+
*/
|
|
166
|
+
async function planBackendIsCoreml(options: InstallPlanOptions): Promise<boolean> {
|
|
167
|
+
if (options.backend === "coreml") return true;
|
|
168
|
+
if (options.backend === "onnx") return false;
|
|
169
|
+
const probed = await getEngineCapabilities().catch(() => null);
|
|
170
|
+
return isCoremlBackend(probed?.backend);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function fluidAsrComponent(): PlanComponent {
|
|
174
|
+
return {
|
|
175
|
+
name: "ASR Parakeet TDT v3 (CoreML)",
|
|
176
|
+
source: "FluidAudio cache",
|
|
177
|
+
sizeBytes: FLUID_ASR_BUNDLE_BYTES,
|
|
178
|
+
cached: fluidAsrCacheReady(fluidAsrCachePath()),
|
|
179
|
+
// `--no-cache` is a Kesha-cache flag; FluidAudio's bundle is not re-fetched by it.
|
|
180
|
+
refresh: false,
|
|
181
|
+
external: true,
|
|
182
|
+
note: FLUID_ASR_CACHE_NOTE,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function engineIsCached(binPath: string, version: string): boolean {
|
|
187
|
+
return existsSync(binPath) && readInstalledEngineVersion(binPath) === version;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildEngineComponent(
|
|
191
|
+
binPath: string,
|
|
192
|
+
noCache: boolean,
|
|
193
|
+
version: string,
|
|
194
|
+
): PlanComponent {
|
|
139
195
|
const engineAsset = engineAssetForPlatform();
|
|
140
196
|
if (engineAsset) {
|
|
141
|
-
const engineCached =
|
|
142
|
-
existsSync(binPath) && readInstalledEngineVersion(binPath) === engineVersion;
|
|
197
|
+
const engineCached = engineIsCached(binPath, version);
|
|
143
198
|
return {
|
|
144
199
|
name: `Engine ${engineAsset.assetName}`,
|
|
145
|
-
source: `GitHub release v${
|
|
200
|
+
source: `GitHub release v${version}`,
|
|
146
201
|
sizeBytes: engineAsset.sizeBytes,
|
|
147
202
|
cached: engineCached,
|
|
148
203
|
refresh: noCache,
|
|
@@ -158,13 +213,19 @@ function buildEngineComponent(binPath: string, noCache: boolean): PlanComponent
|
|
|
158
213
|
};
|
|
159
214
|
}
|
|
160
215
|
|
|
161
|
-
function buildSidecarComponents(
|
|
216
|
+
function buildSidecarComponents(
|
|
217
|
+
engineDir: string,
|
|
218
|
+
noCache: boolean,
|
|
219
|
+
version: string,
|
|
220
|
+
engineCached: boolean,
|
|
221
|
+
): PlanComponent[] {
|
|
162
222
|
if (!isDarwinArm64()) return [];
|
|
163
223
|
return DARWIN_SIDECARS.map((sidecar) => ({
|
|
164
224
|
name: `Sidecar ${sidecar.assetName}`,
|
|
165
|
-
source: `GitHub release v${
|
|
225
|
+
source: `GitHub release v${version}`,
|
|
166
226
|
sizeBytes: sidecar.sizeBytes,
|
|
167
|
-
|
|
227
|
+
// A cold engine fetch re-downloads every sidecar; only a cache hit tops up the missing ones.
|
|
228
|
+
cached: engineCached && existsSync(join(engineDir, sidecar.fileBasename)),
|
|
168
229
|
refresh: noCache,
|
|
169
230
|
}));
|
|
170
231
|
}
|
|
@@ -245,6 +306,8 @@ function assembleComponents(input: {
|
|
|
245
306
|
engineDir: string;
|
|
246
307
|
noCache: boolean;
|
|
247
308
|
options: InstallPlanOptions;
|
|
309
|
+
coreml: boolean;
|
|
310
|
+
version: string;
|
|
248
311
|
tts: ReturnType<typeof buildTtsComponents>;
|
|
249
312
|
}): PlanComponent[] {
|
|
250
313
|
const { cacheRoot, noCache, options } = input;
|
|
@@ -252,9 +315,16 @@ function assembleComponents(input: {
|
|
|
252
315
|
bundleComponent({ cacheRoot, name, source: "model cache", files, refresh: noCache, note });
|
|
253
316
|
|
|
254
317
|
const components: PlanComponent[] = [
|
|
255
|
-
buildEngineComponent(input.binPath, noCache),
|
|
256
|
-
...buildSidecarComponents(
|
|
257
|
-
|
|
318
|
+
buildEngineComponent(input.binPath, noCache, input.version),
|
|
319
|
+
...buildSidecarComponents(
|
|
320
|
+
input.engineDir,
|
|
321
|
+
noCache,
|
|
322
|
+
input.version,
|
|
323
|
+
engineIsCached(input.binPath, input.version),
|
|
324
|
+
),
|
|
325
|
+
input.coreml
|
|
326
|
+
? fluidAsrComponent()
|
|
327
|
+
: modelBundle("ASR Parakeet TDT v3", ASR_FILES, "required for speech-to-text"),
|
|
258
328
|
modelBundle(
|
|
259
329
|
"Audio language ID ECAPA",
|
|
260
330
|
LANG_ID_FILES,
|
|
@@ -279,12 +349,18 @@ function assembleComponents(input: {
|
|
|
279
349
|
return components;
|
|
280
350
|
}
|
|
281
351
|
|
|
282
|
-
function renderHeader(
|
|
352
|
+
function renderHeader(
|
|
353
|
+
cacheRoot: string,
|
|
354
|
+
binPath: string,
|
|
355
|
+
backend: string | undefined,
|
|
356
|
+
version: string,
|
|
357
|
+
): string[] {
|
|
358
|
+
const pinNote = version === engineVersion ? "" : ` (overrides the pinned v${engineVersion})`;
|
|
283
359
|
return [
|
|
284
360
|
"Kesha install plan",
|
|
285
361
|
"",
|
|
286
362
|
`Package: @drakulavich/kesha-voice-kit ${packageVersion}`,
|
|
287
|
-
`Engine release: v${
|
|
363
|
+
`Engine release: v${version}${pinNote}`,
|
|
288
364
|
`Platform: ${process.platform} ${process.arch}`,
|
|
289
365
|
`Cache: ${cacheRoot}`,
|
|
290
366
|
`Engine binary: ${binPath}`,
|
|
@@ -298,7 +374,9 @@ function renderComponentLines(components: PlanComponent[]): string[] {
|
|
|
298
374
|
const status = (c: PlanComponent) => (c.refresh ? "refresh" : c.cached ? "cached" : "needed");
|
|
299
375
|
const lines: string[] = [];
|
|
300
376
|
for (const c of components) {
|
|
301
|
-
|
|
377
|
+
const size = c.external ? `~${humanBytes(c.sizeBytes)}` : humanBytes(c.sizeBytes);
|
|
378
|
+
const detail = c.external ? "approximate" : `${c.sizeBytes} bytes`;
|
|
379
|
+
lines.push(` - ${c.name}: ${size} (${detail}, ${status(c)}, ${c.source})`);
|
|
302
380
|
if (c.note) lines.push(` ${c.note}`);
|
|
303
381
|
}
|
|
304
382
|
return lines;
|
|
@@ -310,14 +388,25 @@ function renderWarmupLines(warmups: PlanWarmup[]): string[] {
|
|
|
310
388
|
}
|
|
311
389
|
|
|
312
390
|
function renderTotalLines(components: PlanComponent[]): string[] {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
391
|
+
// External components are fetched by a backend into its own cache, so they cannot
|
|
392
|
+
// sit under a total labelled "Kesha-managed" — they get their own line (#684).
|
|
393
|
+
const managed = components.filter((c) => !c.external);
|
|
394
|
+
const external = components.filter((c) => c.external);
|
|
395
|
+
const coldBytes = managed.reduce((sum, c) => sum + c.sizeBytes, 0);
|
|
396
|
+
const expectedNetworkBytes = managed.reduce((sum, c) => (c.cached && !c.refresh ? sum : sum + c.sizeBytes), 0);
|
|
397
|
+
const lines = [
|
|
316
398
|
"",
|
|
317
399
|
"Totals:",
|
|
318
400
|
` Cold-cache Kesha-managed download: ${humanBytes(coldBytes)}`,
|
|
319
401
|
` Expected Kesha-managed network for this run: ${humanBytes(expectedNetworkBytes)}`,
|
|
320
402
|
];
|
|
403
|
+
const externalPending = external.reduce((sum, c) => (c.cached ? sum : sum + c.sizeBytes), 0);
|
|
404
|
+
if (externalPending > 0) {
|
|
405
|
+
lines.push(
|
|
406
|
+
` Additionally fetched by the backend into its own cache: ~${humanBytes(externalPending)}`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return lines;
|
|
321
410
|
}
|
|
322
411
|
|
|
323
412
|
function renderBehaviorLines(ttsLangs: string[], wantsAnyKokoro: boolean): string[] {
|
|
@@ -344,6 +433,7 @@ export function buildInstallCommand(options: InstallPlanOptions, ttsLangs: strin
|
|
|
344
433
|
return [
|
|
345
434
|
"kesha",
|
|
346
435
|
"install",
|
|
436
|
+
...(options.engineVersion ? ["--engine-version", options.engineVersion] : []),
|
|
347
437
|
options.noCache ? "--no-cache" : "",
|
|
348
438
|
options.backend === "coreml" ? "--coreml" : "",
|
|
349
439
|
options.backend === "onnx" ? "--onnx" : "",
|
|
@@ -379,11 +469,23 @@ export async function renderInstallPlan(options: InstallPlanOptions = {}): Promi
|
|
|
379
469
|
const noCache = options.noCache === true;
|
|
380
470
|
const ttsLangs = options.ttsLangs ?? [];
|
|
381
471
|
|
|
472
|
+
const version = options.engineVersion ?? engineVersion;
|
|
473
|
+
|
|
382
474
|
const tts = buildTtsComponents(cacheRoot, ttsLangs, noCache);
|
|
383
|
-
const
|
|
475
|
+
const coreml = await planBackendIsCoreml(options);
|
|
476
|
+
const components = assembleComponents({
|
|
477
|
+
cacheRoot,
|
|
478
|
+
binPath,
|
|
479
|
+
engineDir,
|
|
480
|
+
noCache,
|
|
481
|
+
options,
|
|
482
|
+
coreml,
|
|
483
|
+
version,
|
|
484
|
+
tts,
|
|
485
|
+
});
|
|
384
486
|
|
|
385
487
|
const lines = [
|
|
386
|
-
...renderHeader(cacheRoot, binPath, options.backend),
|
|
488
|
+
...renderHeader(cacheRoot, binPath, options.backend, version),
|
|
387
489
|
...renderComponentLines(components),
|
|
388
490
|
...renderFooter({
|
|
389
491
|
components,
|
package/src/semver.d.mts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The module stays .mjs because the npm-publish and homebrew workflows run their scripts
|
|
2
|
+
// under node, which cannot import a .ts module.
|
|
3
|
+
export type SemVer = {
|
|
4
|
+
major: number;
|
|
5
|
+
minor: number;
|
|
6
|
+
patch: number;
|
|
7
|
+
prerelease: string[];
|
|
8
|
+
build: string[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function tryParseSemver(raw: unknown): SemVer | null;
|
|
12
|
+
export function parseSemver(raw: unknown, label: string): SemVer;
|
|
13
|
+
export function isSemver(raw: unknown): boolean;
|
|
14
|
+
export function isStableVersion(raw: unknown): boolean;
|
|
15
|
+
export function cmp(a: SemVer, b: SemVer): number;
|
|
16
|
+
export function fmt(v: SemVer): string;
|
package/src/semver.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SemVer 2.0 — the one place in this repo that knows the grammar.
|
|
3
|
+
*
|
|
4
|
+
* `kesha install --engine-version`, the version drift gate, the alpha derivation, the npm
|
|
5
|
+
* dist-tag resolver, the package.json writer and the Linux package names all decide "is this
|
|
6
|
+
* a version" or "which is newer". Separate copies disagree at the edges: `derive-alpha-version`
|
|
7
|
+
* asserts its output sorts above the pin that `check-versions` later validates, so one parser
|
|
8
|
+
* accepting what another refuses is a release that passes derivation and fails the gate
|
|
9
|
+
* (#685, #738).
|
|
10
|
+
*
|
|
11
|
+
* Stays `.mjs`: npm-publish.yml, release-npm-publish.yml and homebrew-tap.yml run their
|
|
12
|
+
* scripts under node, which cannot import a `.ts` module. Types live in `semver.d.mts`.
|
|
13
|
+
*
|
|
14
|
+
* The release *tag* grammar is deliberately not here — `.github/scripts/release-tags.mjs`
|
|
15
|
+
* keeps it as a POSIX ERE string so bash `=~` and JavaScript consume identical text.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The pattern published with the SemVer 2.0 spec; a looser one accepts `1.0.0-01` and `01.2.3`. */
|
|
19
|
+
const SEMVER_2_0 =
|
|
20
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
21
|
+
|
|
22
|
+
/** The only place the pattern is applied; everything else here builds on this. */
|
|
23
|
+
export function tryParseSemver(raw) {
|
|
24
|
+
const m = typeof raw === "string" ? raw.match(SEMVER_2_0) : null;
|
|
25
|
+
if (!m) return null;
|
|
26
|
+
return {
|
|
27
|
+
major: Number(m[1]),
|
|
28
|
+
minor: Number(m[2]),
|
|
29
|
+
patch: Number(m[3]),
|
|
30
|
+
prerelease: m[4]?.split(".") ?? [],
|
|
31
|
+
build: m[5]?.split(".") ?? [],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseSemver(raw, label) {
|
|
36
|
+
const parsed = tryParseSemver(raw);
|
|
37
|
+
if (!parsed) {
|
|
38
|
+
throw new Error(`${label}: not a valid SemVer 2.0 version (got '${raw}')`);
|
|
39
|
+
}
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isSemver(raw) {
|
|
44
|
+
return tryParseSemver(raw) !== null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A version fit for a release tag or a package filename: no prerelease, no build metadata. */
|
|
48
|
+
export function isStableVersion(raw) {
|
|
49
|
+
const parsed = tryParseSemver(raw);
|
|
50
|
+
return parsed !== null && parsed.prerelease.length === 0 && parsed.build.length === 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** SemVer §10: build metadata is ignored when determining precedence. */
|
|
54
|
+
export function cmp(a, b) {
|
|
55
|
+
if (a.major !== b.major) return a.major - b.major;
|
|
56
|
+
if (a.minor !== b.minor) return a.minor - b.minor;
|
|
57
|
+
if (a.patch !== b.patch) return a.patch - b.patch;
|
|
58
|
+
|
|
59
|
+
// SemVer precedence: a stable version outranks its prereleases.
|
|
60
|
+
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0;
|
|
61
|
+
if (a.prerelease.length === 0) return 1;
|
|
62
|
+
if (b.prerelease.length === 0) return -1;
|
|
63
|
+
|
|
64
|
+
const len = Math.max(a.prerelease.length, b.prerelease.length);
|
|
65
|
+
for (let i = 0; i < len; i++) {
|
|
66
|
+
const ai = a.prerelease[i];
|
|
67
|
+
const bi = b.prerelease[i];
|
|
68
|
+
if (ai === undefined) return -1;
|
|
69
|
+
if (bi === undefined) return 1;
|
|
70
|
+
if (ai === bi) continue;
|
|
71
|
+
|
|
72
|
+
const an = ai.match(/^(0|[1-9]\d*)$/);
|
|
73
|
+
const bn = bi.match(/^(0|[1-9]\d*)$/);
|
|
74
|
+
if (an && bn) return Number(ai) - Number(bi);
|
|
75
|
+
if (an) return -1;
|
|
76
|
+
if (bn) return 1;
|
|
77
|
+
return ai < bi ? -1 : 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function fmt(v) {
|
|
84
|
+
const base = `${v.major}.${v.minor}.${v.patch}`;
|
|
85
|
+
const withPre = v.prerelease.length ? `${base}-${v.prerelease.join(".")}` : base;
|
|
86
|
+
return v.build.length ? `${withPre}+${v.build.join(".")}` : withPre;
|
|
87
|
+
}
|
package/src/star.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { tryParseSemver } from "./semver.mjs";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Version-bump gate for the "star the repo" prompt in `kesha install`.
|
|
@@ -26,12 +27,8 @@ export function writeStarSeen(binPath: string, version: string): void {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
function parseMajorMinor(v: string): [number, number] | null {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const major = Number(parts[0]);
|
|
32
|
-
const minor = Number(parts[1]);
|
|
33
|
-
if (!Number.isFinite(major) || !Number.isFinite(minor)) return null;
|
|
34
|
-
return [major, minor];
|
|
30
|
+
const parsed = tryParseSemver(v);
|
|
31
|
+
return parsed && [parsed.major, parsed.minor];
|
|
35
32
|
}
|
|
36
33
|
|
|
37
34
|
/**
|
package/src/status.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { log } from "./log";
|
|
|
11
11
|
import { packageVersion } from "./package-info";
|
|
12
12
|
import { keshaCacheDir } from "./paths";
|
|
13
13
|
import { fluidKokoroCacheInfo } from "./fluid-kokoro-cache";
|
|
14
|
+
import { fluidAsrCacheInfo, isCoremlBackend } from "./fluid-asr-cache";
|
|
14
15
|
import { dirSizeBytes } from "./diagnostic-paths";
|
|
15
16
|
import pc from "picocolors";
|
|
16
17
|
|
|
@@ -52,6 +53,7 @@ export interface StatusDiskUsage {
|
|
|
52
53
|
componentTotalBytes: number;
|
|
53
54
|
totalBytes: number;
|
|
54
55
|
fluidKokoro: { path: string; sizeBytes: number } | null;
|
|
56
|
+
fluidAsr: { path: string; sizeBytes: number } | null;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/**
|
|
@@ -90,7 +92,7 @@ export async function collectStatus(options: ShowStatusOptions = {}): Promise<St
|
|
|
90
92
|
? null
|
|
91
93
|
: `Run \`${installHint()}\` to download the engine and models.`,
|
|
92
94
|
// Absent engine means no disk walk, matching the human path (#647).
|
|
93
|
-
disk: installed && options.disk ? collectDiskUsage(path) : null,
|
|
95
|
+
disk: installed && options.disk ? collectDiskUsage(path, capabilities?.backend) : null,
|
|
94
96
|
};
|
|
95
97
|
}
|
|
96
98
|
|
|
@@ -144,10 +146,19 @@ export function renderStatus(report: StatusReport): void {
|
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
|
|
147
|
-
function buildDiskComponents(
|
|
149
|
+
function buildDiskComponents(
|
|
150
|
+
cache: string,
|
|
151
|
+
engineDir: string,
|
|
152
|
+
coreml: boolean,
|
|
153
|
+
): Array<{ label: string; path: string }> {
|
|
148
154
|
return [
|
|
149
155
|
{ label: "Engine", path: engineDir },
|
|
150
|
-
|
|
156
|
+
// A CoreML engine never populates the ONNX dir, and its own bundle lives outside
|
|
157
|
+
// the Kesha cache — so it is reported under external caches, not here, or the rows
|
|
158
|
+
// would sum past a Total that cannot include it (#684).
|
|
159
|
+
...(coreml
|
|
160
|
+
? []
|
|
161
|
+
: [{ label: "ASR (Parakeet)", path: join(cache, "models/parakeet-tdt-v3") }]),
|
|
151
162
|
{ label: "Language ID", path: join(cache, "models/lang-id-ecapa") },
|
|
152
163
|
{ label: "VAD (Silero)", path: join(cache, "models/silero-vad") },
|
|
153
164
|
{ label: "TTS (Kokoro)", path: join(cache, "models/kokoro-82m") },
|
|
@@ -169,20 +180,28 @@ function logDiskRows(rows: StatusDiskComponent[], total: number, componentTotal:
|
|
|
169
180
|
}
|
|
170
181
|
}
|
|
171
182
|
|
|
172
|
-
function
|
|
173
|
-
if (!fluidKokoro) return;
|
|
183
|
+
function logExternalCaches(disk: StatusDiskUsage): void {
|
|
184
|
+
if (!disk.fluidKokoro && !disk.fluidAsr) return;
|
|
174
185
|
log.info("");
|
|
175
186
|
log.info(`External caches (not included in Kesha total):`);
|
|
176
|
-
|
|
187
|
+
if (disk.fluidAsr) {
|
|
188
|
+
log.info(` FluidAudio ASR: ${humanBytes(disk.fluidAsr.sizeBytes)} (${disk.fluidAsr.path})`);
|
|
189
|
+
}
|
|
190
|
+
if (disk.fluidKokoro) {
|
|
191
|
+
log.info(
|
|
192
|
+
` FluidAudio Kokoro: ${humanBytes(disk.fluidKokoro.sizeBytes)} (${disk.fluidKokoro.path})`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
177
195
|
}
|
|
178
196
|
|
|
179
|
-
function collectDiskUsage(binPath: string): StatusDiskUsage {
|
|
197
|
+
function collectDiskUsage(binPath: string, backend?: string): StatusDiskUsage {
|
|
180
198
|
const cache = keshaCacheDir();
|
|
181
199
|
// Two levels up from the binary (`<cache>/engine/bin/`) so future engine-root siblings are counted.
|
|
182
200
|
const engineDir = join(binPath, "..", "..");
|
|
201
|
+
const coreml = isCoremlBackend(backend);
|
|
183
202
|
|
|
184
203
|
const components: StatusDiskComponent[] = [];
|
|
185
|
-
for (const c of buildDiskComponents(cache, engineDir)) {
|
|
204
|
+
for (const c of buildDiskComponents(cache, engineDir, coreml)) {
|
|
186
205
|
const sizeBytes = dirSizeBytes(c.path);
|
|
187
206
|
if (sizeBytes > 0) components.push({ label: c.label, sizeBytes });
|
|
188
207
|
}
|
|
@@ -191,6 +210,8 @@ function collectDiskUsage(binPath: string): StatusDiskUsage {
|
|
|
191
210
|
const cacheTotal = dirSizeBytes(cache);
|
|
192
211
|
const engineOutsideCache = engineDir.startsWith(cache) ? 0 : dirSizeBytes(engineDir);
|
|
193
212
|
const fluidKokoro = fluidKokoroCacheInfo();
|
|
213
|
+
// Only walked when it is the backend in play; otherwise the size is computed and dropped.
|
|
214
|
+
const fluidAsr = coreml ? fluidAsrCacheInfo() : null;
|
|
194
215
|
|
|
195
216
|
return {
|
|
196
217
|
cachePath: cache,
|
|
@@ -201,6 +222,12 @@ function collectDiskUsage(binPath: string): StatusDiskUsage {
|
|
|
201
222
|
fluidKokoro.exists && fluidKokoro.sizeBytes > 0
|
|
202
223
|
? { path: fluidKokoro.path, sizeBytes: fluidKokoro.sizeBytes }
|
|
203
224
|
: null,
|
|
225
|
+
// Gate on readiness, not size, so a partial bundle is not shown as a present
|
|
226
|
+
// cache while doctor reports it missing.
|
|
227
|
+
fluidAsr:
|
|
228
|
+
fluidAsr?.exists && fluidAsr.sizeBytes > 0
|
|
229
|
+
? { path: fluidAsr.path, sizeBytes: fluidAsr.sizeBytes }
|
|
230
|
+
: null,
|
|
204
231
|
};
|
|
205
232
|
}
|
|
206
233
|
|
|
@@ -209,7 +236,7 @@ function showDiskUsage(disk: StatusDiskUsage): void {
|
|
|
209
236
|
|
|
210
237
|
log.info(`Disk usage (${disk.cachePath}):`);
|
|
211
238
|
logDiskRows(disk.components, disk.totalBytes, disk.componentTotalBytes);
|
|
212
|
-
|
|
239
|
+
logExternalCaches(disk);
|
|
213
240
|
log.info("");
|
|
214
241
|
log.info(
|
|
215
242
|
pc.dim(` To reset cache: rm -rf ${disk.cachePath} — next \`kesha install\` re-downloads.`),
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { KNOWN_TS_CODES } from "../error-codes";
|
|
6
|
-
|
|
7
|
-
const ENGINE_BIN =
|
|
8
|
-
process.env.KESHA_ENGINE_BIN ?? join(import.meta.dir, "../../rust/target/release/kesha-engine");
|
|
9
|
-
|
|
10
|
-
const describeOrSkip = existsSync(ENGINE_BIN) ? describe : describe.skip;
|
|
11
|
-
|
|
12
|
-
describeOrSkip("error-code drift", () => {
|
|
13
|
-
test("engine codes ∪ TS-native codes == codes documented in docs/errors.md", () => {
|
|
14
|
-
const res = spawnSync(ENGINE_BIN, ["--error-codes-json"], { encoding: "utf8" });
|
|
15
|
-
expect(res.status).toBe(0);
|
|
16
|
-
const engineCodes: string[] = JSON.parse(res.stdout).map((e: { code: string }) => e.code);
|
|
17
|
-
|
|
18
|
-
const known = new Set<string>([...engineCodes, ...KNOWN_TS_CODES]);
|
|
19
|
-
|
|
20
|
-
const doc = readFileSync(join(import.meta.dir, "../../docs/errors.md"), "utf8");
|
|
21
|
-
const documented = new Set<string>();
|
|
22
|
-
for (const m of doc.matchAll(/`(E_[A-Z0-9_]+)`/g)) documented.add(m[1]);
|
|
23
|
-
|
|
24
|
-
// every known code is documented
|
|
25
|
-
for (const c of known) expect(documented.has(c)).toBe(true);
|
|
26
|
-
// every documented code is known (no stale doc rows)
|
|
27
|
-
for (const c of documented) expect(known.has(c)).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { extractEngineErrorCode, TS_NATIVE_CODES, KNOWN_TS_CODES } from "../error-codes";
|
|
3
|
-
|
|
4
|
-
describe("extractEngineErrorCode", () => {
|
|
5
|
-
test("extracts the code from a coded engine stderr line", () => {
|
|
6
|
-
const stderr = "error [E_MODEL_MISSING]: voice 'ru-vosk-m02' not installed. run: kesha install --tts";
|
|
7
|
-
expect(extractEngineErrorCode(stderr)).toBe("E_MODEL_MISSING");
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
test("extracts even when the message embeds a path or token", () => {
|
|
11
|
-
const stderr = "warning: foo\nerror [E_BAD_AUDIO]: decode error in: /Users/alice/secret-token-abc.wav";
|
|
12
|
-
expect(extractEngineErrorCode(stderr)).toBe("E_BAD_AUDIO");
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
test("returns undefined for an uncoded stderr so caller can fall back", () => {
|
|
16
|
-
expect(extractEngineErrorCode("Error: something went wrong")).toBeUndefined();
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test("TS-native codes are exposed and included in KNOWN_TS_CODES", () => {
|
|
20
|
-
expect(TS_NATIVE_CODES.INPUT_NOT_FOUND).toBe("E_INPUT_NOT_FOUND");
|
|
21
|
-
expect(TS_NATIVE_CODES.ENGINE_SPAWN).toBe("E_ENGINE_SPAWN");
|
|
22
|
-
expect(TS_NATIVE_CODES.INVALID_ARG).toBe("E_INVALID_ARG");
|
|
23
|
-
expect(KNOWN_TS_CODES.has("E_INTERNAL")).toBe(true);
|
|
24
|
-
});
|
|
25
|
-
});
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { SayError } from "../synth";
|
|
3
|
-
|
|
4
|
-
describe("SayError carries a taxonomy code", () => {
|
|
5
|
-
test("pre-check throws carry text codes", () => {
|
|
6
|
-
const e = new SayError("text is empty", 2, "", "E_TEXT_EMPTY");
|
|
7
|
-
expect(e.code).toBe("E_TEXT_EMPTY");
|
|
8
|
-
expect(e.exitCode).toBe(2);
|
|
9
|
-
});
|
|
10
|
-
test("defaults to E_INTERNAL when unspecified", () => {
|
|
11
|
-
const e = new SayError("boom", 4, "");
|
|
12
|
-
expect(e.code).toBe("E_INTERNAL");
|
|
13
|
-
});
|
|
14
|
-
});
|