@drakulavich/kesha-voice-kit 1.2.2 → 1.4.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/NOTICES.md +67 -0
- package/README.md +36 -9
- package/SKILL.md +70 -24
- package/package.json +4 -2
- package/src/cli/dispatch.ts +40 -0
- package/src/cli/install.ts +108 -0
- package/src/cli/main.ts +177 -0
- package/src/cli/say.ts +119 -0
- package/src/cli/status.ts +12 -0
- package/src/cli.ts +19 -463
- package/src/engine-install.ts +82 -2
- package/src/engine.ts +19 -2
- package/src/format.ts +57 -0
- package/src/lib.ts +14 -0
- package/src/say.ts +1 -1
- package/src/status.ts +16 -0
- package/src/toon.ts +15 -0
- package/src/transcribe.ts +7 -3
- package/src/types.ts +18 -0
- package/src/models.ts +0 -6
package/src/engine-install.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dirname } from "path";
|
|
1
|
+
import { dirname, join } from "path";
|
|
2
2
|
import { existsSync, mkdirSync, chmodSync, readFileSync, writeFileSync } from "fs";
|
|
3
3
|
import { getEngineBinPath, getEngineCapabilities } from "./engine";
|
|
4
4
|
import { log } from "./log";
|
|
@@ -41,9 +41,64 @@ function getEngineBinaryName(): string {
|
|
|
41
41
|
throw new Error(`Unsupported platform: ${platform} ${arch}`);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Fetch the AVSpeechSynthesizer sidecar (#141) and place it next to the
|
|
46
|
+
* engine binary on darwin-arm64. The Rust side (`avspeech::helper_path`)
|
|
47
|
+
* looks for a `say-avspeech` file adjacent to the running executable, so
|
|
48
|
+
* the filename on disk is always `say-avspeech` regardless of the release
|
|
49
|
+
* asset name.
|
|
50
|
+
*
|
|
51
|
+
* Best-effort: 404s (older engine versions predate the sidecar) and
|
|
52
|
+
* network errors log a warning and return — macos-* voices simply won't
|
|
53
|
+
* be available, which is a graceful degradation. The user keeps Kokoro +
|
|
54
|
+
* Piper.
|
|
55
|
+
*/
|
|
56
|
+
async function downloadAVSpeechSidecar(binPath: string, engineVersion: string): Promise<void> {
|
|
57
|
+
if (process.platform !== "darwin" || process.arch !== "arm64") return;
|
|
58
|
+
|
|
59
|
+
const sidecarPath = join(dirname(binPath), "say-avspeech");
|
|
60
|
+
const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/say-avspeech-darwin-arm64`;
|
|
61
|
+
|
|
62
|
+
let res: Response;
|
|
63
|
+
try {
|
|
64
|
+
res = await fetch(url, { redirect: "follow" });
|
|
65
|
+
} catch (e) {
|
|
66
|
+
log.warn(
|
|
67
|
+
`Could not fetch AVSpeech sidecar (${e instanceof Error ? e.message : e}); macos-* voices unavailable.`,
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
log.warn(
|
|
74
|
+
`AVSpeech sidecar not in release v${engineVersion} (HTTP ${res.status}); macos-* voices unavailable.`,
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Keep the best-effort contract: streamResponseToFile throws on an empty
|
|
80
|
+
// body and can fail mid-stream, and chmodSync can throw EPERM. Without
|
|
81
|
+
// this catch a stream/chmod failure would propagate through the tail
|
|
82
|
+
// `await sidecarPromise` in downloadEngine — converting a successful
|
|
83
|
+
// engine install into a thrown exception after log.success already
|
|
84
|
+
// announced it, which is exactly the regression the fetch/404 branches
|
|
85
|
+
// above protect against.
|
|
86
|
+
try {
|
|
87
|
+
await streamResponseToFile(res, sidecarPath, "say-avspeech sidecar");
|
|
88
|
+
chmodSync(sidecarPath, 0o755);
|
|
89
|
+
log.success("AVSpeech sidecar installed (macOS voices available).");
|
|
90
|
+
} catch (e) {
|
|
91
|
+
log.warn(
|
|
92
|
+
`AVSpeech sidecar install failed (${e instanceof Error ? e.message : e}); macos-* voices unavailable.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
44
97
|
export interface InstallOptions {
|
|
45
|
-
/** Also install Kokoro
|
|
98
|
+
/** Also install Kokoro + Piper + ONNX G2P TTS models. */
|
|
46
99
|
tts?: boolean;
|
|
100
|
+
/** Also install Silero VAD model for long-audio preprocessing. */
|
|
101
|
+
vad?: boolean;
|
|
47
102
|
}
|
|
48
103
|
|
|
49
104
|
export async function downloadEngine(
|
|
@@ -69,6 +124,13 @@ export async function downloadEngine(
|
|
|
69
124
|
|
|
70
125
|
if (cacheValid) {
|
|
71
126
|
log.success(`Engine binary already installed (v${engineVersion}).`);
|
|
127
|
+
// Cover the upgrade path from pre-#141 engines that never had a
|
|
128
|
+
// sidecar: if the cached engine is current but the sibling sidecar
|
|
129
|
+
// is missing, fetch it now so macos-* voices start working.
|
|
130
|
+
const sidecarPath = join(dirname(binPath), "say-avspeech");
|
|
131
|
+
if (!existsSync(sidecarPath)) {
|
|
132
|
+
await downloadAVSpeechSidecar(binPath, engineVersion);
|
|
133
|
+
}
|
|
72
134
|
} else {
|
|
73
135
|
// Log why we're downloading — helps diagnose surprising re-downloads.
|
|
74
136
|
if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
|
|
@@ -81,16 +143,32 @@ export async function downloadEngine(
|
|
|
81
143
|
|
|
82
144
|
mkdirSync(dirname(binPath), { recursive: true });
|
|
83
145
|
|
|
146
|
+
// Kick off the sidecar fetch concurrently with the engine fetch. Both
|
|
147
|
+
// target github.com release assets on independent paths with no data
|
|
148
|
+
// dependency, so overlapping the HTTP round-trips saves ~15-30s on a
|
|
149
|
+
// cold install. Sidecar is best-effort (404 on older engines, warn +
|
|
150
|
+
// continue) so a failure doesn't cascade into the engine path.
|
|
151
|
+
const sidecarPromise = downloadAVSpeechSidecar(binPath, engineVersion);
|
|
152
|
+
|
|
84
153
|
let res: Response;
|
|
85
154
|
try {
|
|
86
155
|
res = await fetch(url, { redirect: "follow" });
|
|
87
156
|
} catch (e) {
|
|
157
|
+
// Attach a no-op rejection handler to the sidecar promise as
|
|
158
|
+
// defense-in-depth. Today it can't reject (downloadAVSpeechSidecar
|
|
159
|
+
// catches all of its own errors), but if that contract ever drifts
|
|
160
|
+
// a bare dangling promise would surface as an unhandledRejection
|
|
161
|
+
// while we're throwing the engine error. Not waiting — the engine
|
|
162
|
+
// error is what the user needs to see now; sidecar's own logs
|
|
163
|
+
// will print whenever they finish.
|
|
164
|
+
sidecarPromise.catch(() => {});
|
|
88
165
|
throw new Error(
|
|
89
166
|
`Failed to fetch engine binary: ${e instanceof Error ? e.message : e}\n Fix: Check your network connection and try again`,
|
|
90
167
|
);
|
|
91
168
|
}
|
|
92
169
|
|
|
93
170
|
if (!res.ok) {
|
|
171
|
+
sidecarPromise.catch(() => {});
|
|
94
172
|
throw new Error(
|
|
95
173
|
`Failed to download engine binary (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
|
|
96
174
|
);
|
|
@@ -100,6 +178,7 @@ export async function downloadEngine(
|
|
|
100
178
|
chmodSync(binPath, 0o755);
|
|
101
179
|
writeInstalledEngineVersion(binPath, engineVersion);
|
|
102
180
|
log.success(`Engine binary downloaded (v${engineVersion}).`);
|
|
181
|
+
await sidecarPromise;
|
|
103
182
|
}
|
|
104
183
|
|
|
105
184
|
if (backend) {
|
|
@@ -116,6 +195,7 @@ export async function downloadEngine(
|
|
|
116
195
|
"install",
|
|
117
196
|
...(noCache ? ["--no-cache"] : []),
|
|
118
197
|
...(options.tts ? ["--tts"] : []),
|
|
198
|
+
...(options.vad ? ["--vad"] : []),
|
|
119
199
|
];
|
|
120
200
|
const proc = Bun.spawnSync([binPath, ...installArgs], {
|
|
121
201
|
stdout: "pipe",
|
package/src/engine.ts
CHANGED
|
@@ -42,8 +42,25 @@ async function runEngine(args: string[]): Promise<{ stdout: string; stderr: stri
|
|
|
42
42
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
/** VAD preprocessing selector.
|
|
46
|
+
* - `"auto"` (default): engine decides — VAD when audio ≥ 120 s and model installed
|
|
47
|
+
* - `"on"`: force VAD (requires `kesha install --vad`)
|
|
48
|
+
* - `"off"`: force full-file pass regardless of duration or install state
|
|
49
|
+
*/
|
|
50
|
+
export type VadMode = "auto" | "on" | "off";
|
|
51
|
+
|
|
52
|
+
export interface TranscribeEngineOptions {
|
|
53
|
+
vad?: VadMode;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function transcribeEngine(
|
|
57
|
+
audioPath: string,
|
|
58
|
+
opts: TranscribeEngineOptions = {},
|
|
59
|
+
): Promise<string> {
|
|
60
|
+
const args = ["transcribe", audioPath];
|
|
61
|
+
if (opts.vad === "on") args.push("--vad");
|
|
62
|
+
else if (opts.vad === "off") args.push("--no-vad");
|
|
63
|
+
const { stdout, stderr, exitCode } = await runEngine(args);
|
|
47
64
|
if (exitCode !== 0) {
|
|
48
65
|
throw new Error(stderr || `kesha-engine exited with code ${exitCode}`);
|
|
49
66
|
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { TranscribeResult } from "./types";
|
|
2
|
+
|
|
3
|
+
export function formatTextOutput(results: TranscribeResult[]): string {
|
|
4
|
+
if (results.length === 1) {
|
|
5
|
+
return results[0].text + "\n";
|
|
6
|
+
}
|
|
7
|
+
return results
|
|
8
|
+
.map((r, i) => (i > 0 ? "\n" : "") + `=== ${r.file} ===\n${r.text}\n`)
|
|
9
|
+
.join("");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatVerboseOutput(results: TranscribeResult[]): string {
|
|
13
|
+
return results
|
|
14
|
+
.map((r, i) => {
|
|
15
|
+
const lines: string[] = [];
|
|
16
|
+
if (results.length > 1) {
|
|
17
|
+
if (i > 0) lines.push("");
|
|
18
|
+
lines.push(`=== ${r.file} ===`);
|
|
19
|
+
}
|
|
20
|
+
if (r.audioLanguage) {
|
|
21
|
+
lines.push(`Audio language: ${r.audioLanguage.code} (confidence: ${r.audioLanguage.confidence.toFixed(2)})`);
|
|
22
|
+
}
|
|
23
|
+
const textLang = r.textLanguage ?? (r.lang ? { code: r.lang, confidence: 0 } : null);
|
|
24
|
+
if (textLang) {
|
|
25
|
+
const confStr = textLang.confidence > 0 ? ` (confidence: ${textLang.confidence.toFixed(2)})` : "";
|
|
26
|
+
lines.push(`Text language: ${textLang.code}${confStr}`);
|
|
27
|
+
}
|
|
28
|
+
if (r.sttTimeMs !== undefined) {
|
|
29
|
+
lines.push(`STT time: ${r.sttTimeMs}ms`);
|
|
30
|
+
}
|
|
31
|
+
lines.push("---");
|
|
32
|
+
lines.push(r.text);
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
})
|
|
35
|
+
.join("\n") + "\n";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatTranscriptOutput(results: TranscribeResult[]): string {
|
|
39
|
+
return results
|
|
40
|
+
.map((r, i) => {
|
|
41
|
+
const lines: string[] = [];
|
|
42
|
+
if (results.length > 1) {
|
|
43
|
+
if (i > 0) lines.push("");
|
|
44
|
+
lines.push(`=== ${r.file} ===`);
|
|
45
|
+
}
|
|
46
|
+
lines.push(r.text);
|
|
47
|
+
const lang = r.textLanguage?.code || r.audioLanguage?.code || r.lang;
|
|
48
|
+
const confidence = r.textLanguage?.confidence ?? r.audioLanguage?.confidence;
|
|
49
|
+
if (lang) lines.push(`[lang: ${lang}${confidence != null ? `, confidence: ${confidence.toFixed(2)}` : ""}]`);
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
})
|
|
52
|
+
.join("\n") + "\n";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatJsonOutput(results: TranscribeResult[]): string {
|
|
56
|
+
return JSON.stringify(results, null, 2) + "\n";
|
|
57
|
+
}
|
package/src/lib.ts
CHANGED
|
@@ -6,6 +6,20 @@ export type { TranscribeOptions };
|
|
|
6
6
|
export { downloadEngine as downloadModel };
|
|
7
7
|
export { say, type SayOptions, SayError } from "./say";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Encode a `TranscribeResult[]` as TOON (#138). Same data shape as the
|
|
11
|
+
* `--json` / `--toon` CLI output; the CLI reads from stdin of a transcribe
|
|
12
|
+
* run, this helper is for programmatic callers that already have the array.
|
|
13
|
+
*/
|
|
14
|
+
export { formatToonOutput as toToon } from "./toon";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Output shape returned by `kesha --json` and the input shape expected by
|
|
18
|
+
* `toToon`. Lives in `./types` (since #179) so the public API stops
|
|
19
|
+
* reaching into the CLI-layer file.
|
|
20
|
+
*/
|
|
21
|
+
export type { TranscribeResult } from "./types";
|
|
22
|
+
|
|
9
23
|
/** Install Kokoro TTS models. Shorthand for `downloadModel({ tts: true })`. */
|
|
10
24
|
export async function downloadTts(noCache = false): Promise<void> {
|
|
11
25
|
await downloadEngine(noCache, undefined, { tts: true });
|
package/src/say.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface SayOptions {
|
|
|
10
10
|
text?: string;
|
|
11
11
|
/** Voice id, e.g. `en-af_heart`. Defaults to engine default. */
|
|
12
12
|
voice?: string;
|
|
13
|
-
/** Override the voice's default
|
|
13
|
+
/** Override the voice's default BCP 47 language code (e.g. `en-us`, `ru`). */
|
|
14
14
|
lang?: string;
|
|
15
15
|
/** Write audio to this path instead of returning bytes. */
|
|
16
16
|
out?: string;
|
package/src/status.ts
CHANGED
|
@@ -38,6 +38,10 @@ export async function showStatus(): Promise<void> {
|
|
|
38
38
|
|
|
39
39
|
log.info(formatStatusLine("Runtime", `Bun ${Bun.version}`, true));
|
|
40
40
|
log.info(formatStatusLine("Platform", `${process.platform} ${process.arch}`, true));
|
|
41
|
+
const mirror = activeModelMirror();
|
|
42
|
+
if (mirror) {
|
|
43
|
+
log.info(formatStatusLine("Mirror", mirror, true));
|
|
44
|
+
}
|
|
41
45
|
log.info("");
|
|
42
46
|
|
|
43
47
|
if (installed) {
|
|
@@ -60,6 +64,18 @@ function kesheCacheDir(): string {
|
|
|
60
64
|
return process.env.KESHA_CACHE_DIR ?? join(homedir(), ".cache", "kesha");
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Read the effective `KESHA_MODEL_MIRROR` base URL (#121). Returns null when
|
|
69
|
+
* unset, empty, or whitespace. Matches the Rust side's `model_mirror()` in
|
|
70
|
+
* `rust/src/models.rs` — keeping them in lockstep lets `kesha status`
|
|
71
|
+
* surface the exact URL the engine will hit on the next `kesha install`.
|
|
72
|
+
*/
|
|
73
|
+
export function activeModelMirror(): string | null {
|
|
74
|
+
const raw = process.env.KESHA_MODEL_MIRROR ?? "";
|
|
75
|
+
const trimmed = raw.trim().replace(/\/+$/, "");
|
|
76
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
function listInstalledVoices(): string[] {
|
|
64
80
|
const cache = kesheCacheDir();
|
|
65
81
|
const voices: string[] = [];
|
package/src/toon.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { encode as encodeToon } from "@toon-format/toon";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TOON (#138) encoding of an array of transcription results. Same data shape
|
|
5
|
+
* as `formatJsonOutput`; lossless round-trip through `@toon-format/toon`'s
|
|
6
|
+
* `decode()`.
|
|
7
|
+
*
|
|
8
|
+
* Generic so this module stays independent of `TranscribeResult` (defined in
|
|
9
|
+
* `cli.ts`) and doesn't introduce a runtime cycle between `lib.ts` and
|
|
10
|
+
* `cli.ts`. Callers that have the concrete `TranscribeResult[]` type will
|
|
11
|
+
* still get a type-checked call via structural compatibility.
|
|
12
|
+
*/
|
|
13
|
+
export function formatToonOutput<T extends object>(results: T[]): string {
|
|
14
|
+
return encodeToon(results) + "\n";
|
|
15
|
+
}
|
package/src/transcribe.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { isEngineInstalled, transcribeEngine } from "./engine";
|
|
1
|
+
import { isEngineInstalled, transcribeEngine, type VadMode } from "./engine";
|
|
2
|
+
|
|
3
|
+
export type { VadMode };
|
|
2
4
|
|
|
3
5
|
export interface TranscribeOptions {
|
|
4
6
|
silent?: boolean;
|
|
7
|
+
/** Silero VAD preprocessing selector. Defaults to `"auto"`. */
|
|
8
|
+
vad?: VadMode;
|
|
5
9
|
}
|
|
6
10
|
|
|
7
|
-
export async function transcribe(audioPath: string,
|
|
11
|
+
export async function transcribe(audioPath: string, opts: TranscribeOptions = {}): Promise<string> {
|
|
8
12
|
if (!isEngineInstalled()) {
|
|
9
13
|
throw new Error(
|
|
10
14
|
"Error: No transcription backend is installed\n\n" +
|
|
@@ -16,5 +20,5 @@ export async function transcribe(audioPath: string, _opts: TranscribeOptions = {
|
|
|
16
20
|
);
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
return transcribeEngine(audioPath);
|
|
23
|
+
return transcribeEngine(audioPath, { vad: opts.vad });
|
|
20
24
|
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LangDetectResult } from "./engine";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One row of `kesha --json` / `kesha --toon` output. Canonical output shape
|
|
5
|
+
* for programmatic callers of `transcribe(...)` + `toToon(...)` in the
|
|
6
|
+
* public API (`@drakulavich/kesha-voice-kit/core`).
|
|
7
|
+
*/
|
|
8
|
+
export type TranscribeResult = {
|
|
9
|
+
file: string;
|
|
10
|
+
text: string;
|
|
11
|
+
lang: string;
|
|
12
|
+
audioLanguage?: LangDetectResult;
|
|
13
|
+
textLanguage?: LangDetectResult;
|
|
14
|
+
/** Wall-clock time around the engine subprocess calls for this file, ms. See #139. */
|
|
15
|
+
sttTimeMs?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type { LangDetectResult };
|