@drakulavich/kesha-voice-kit 1.15.1 → 1.17.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 -0
- package/package.json +6 -3
- package/src/cli/dispatch.ts +12 -4
- package/src/cli/main.ts +7 -0
- package/src/engine-install.ts +14 -0
- package/src/engine.ts +56 -4
- package/src/star.ts +5 -6
- package/src/status.ts +6 -12
- package/src/synth.ts +21 -9
- package/src/transcribe.ts +3 -2
package/README.md
CHANGED
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
- **Rust engine** — single 20MB binary, no ffmpeg, no Python, no native Node addons
|
|
19
19
|
- **OpenClaw-ready** — plug into your LLM agent as a voice processing skill
|
|
20
20
|
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="./demo.gif" alt="kesha demo — English + Russian transcription with automatic language detection" width="800">
|
|
23
|
+
</p>
|
|
24
|
+
|
|
21
25
|
## Quick Start
|
|
22
26
|
|
|
23
27
|
Runtime: **[Bun](https://bun.sh)** >= 1.3.0.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "Open-source voice toolkit. Speech-to-text (25 langs, ~19x faster than Whisper on Apple Silicon, ONNX fallback on CPU), text-to-speech (Kokoro + Vosk-TTS + ~180 macOS system voices, SSML), voice-activity detection, language detection (107 langs). Rust engine, OpenClaw skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
]
|
|
31
31
|
},
|
|
32
32
|
"keshaEngine": {
|
|
33
|
-
"version": "1.
|
|
33
|
+
"version": "1.17.0"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"test": "bun test",
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
"test:integration": "bun test tests/integration/",
|
|
39
39
|
"lint": "bunx tsc --noEmit",
|
|
40
40
|
"lint:tsgo": "bunx tsgo --noEmit",
|
|
41
|
+
"check:workflows": "bun .github/scripts/check-workflows.ts",
|
|
42
|
+
"check:versions": "bun .github/scripts/check-versions.ts",
|
|
41
43
|
"postinstall": "node scripts/postinstall.cjs"
|
|
42
44
|
},
|
|
43
45
|
"keywords": [
|
|
@@ -69,7 +71,8 @@
|
|
|
69
71
|
"@types/bun": "latest",
|
|
70
72
|
"@typescript/native-preview": "^7.0.0-dev.20260423.1",
|
|
71
73
|
"fast-xml-parser": "^5.5.10",
|
|
72
|
-
"typescript": "^6.0.2"
|
|
74
|
+
"typescript": "^6.0.2",
|
|
75
|
+
"yaml": "^2.9.0"
|
|
73
76
|
},
|
|
74
77
|
"dependencies": {
|
|
75
78
|
"@toon-format/toon": "^2.1.0",
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runMain } from "citty";
|
|
2
|
+
import { existsSync } from "fs";
|
|
2
3
|
import { log } from "../log";
|
|
3
4
|
import { suggestCommand } from "../suggest-command";
|
|
4
5
|
import { installCommand } from "./install";
|
|
@@ -8,6 +9,10 @@ import { mainCommand } from "./main";
|
|
|
8
9
|
|
|
9
10
|
const SUBCOMMANDS = ["install", "status", "say"];
|
|
10
11
|
|
|
12
|
+
function isPathLike(arg: string): boolean {
|
|
13
|
+
return arg.includes(".") || arg.includes("/") || existsSync(arg);
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
|
|
12
17
|
const [firstArg, ...restArgs] = rawArgs;
|
|
13
18
|
|
|
@@ -26,14 +31,17 @@ export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
|
|
|
26
31
|
return;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
// Check for unknown subcommands (non-flag, non-file-path args)
|
|
30
|
-
|
|
34
|
+
// Check for unknown subcommands (non-flag, non-file-path args).
|
|
35
|
+
// Extensionless existing files remain valid transcription inputs; missing
|
|
36
|
+
// bare tokens are more likely command typos and should not start the engine.
|
|
37
|
+
if (firstArg && !firstArg.startsWith("-") && !isPathLike(firstArg)) {
|
|
31
38
|
const suggestion = suggestCommand(firstArg, SUBCOMMANDS);
|
|
39
|
+
log.error(`unknown command '${firstArg}'`);
|
|
32
40
|
if (suggestion && suggestion !== firstArg) {
|
|
33
|
-
log.error(`unknown command '${firstArg}'`);
|
|
34
41
|
log.warn(`(Did you mean ${suggestion}?)`);
|
|
35
|
-
process.exit(1);
|
|
36
42
|
}
|
|
43
|
+
log.warn(`If this is an audio file, pass a path like './${firstArg}'.`);
|
|
44
|
+
process.exit(1);
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
await runMain(mainCommand, { rawArgs });
|
package/src/cli/main.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
+
import { existsSync } from "fs";
|
|
2
3
|
import { detect } from "tinyld";
|
|
3
4
|
import { transcribeWithSegments } from "../transcribe";
|
|
4
5
|
import { detectAudioLanguageEngine, detectTextLanguageEngine } from "../engine";
|
|
@@ -206,6 +207,12 @@ export const mainCommand = defineCommand({
|
|
|
206
207
|
const wantsLangId = !!(args.lang || args.verbose || wantsJson || wantsToon || wantsTranscript);
|
|
207
208
|
|
|
208
209
|
for (const file of files) {
|
|
210
|
+
if (!existsSync(file)) {
|
|
211
|
+
hasError = true;
|
|
212
|
+
log.error(`${file}: File not found`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
209
216
|
const startedAt = performance.now();
|
|
210
217
|
try {
|
|
211
218
|
// Run audio lang-id and transcription concurrently.
|
package/src/engine-install.ts
CHANGED
|
@@ -74,6 +74,20 @@ const SIDECARS: SidecarSpec[] = [
|
|
|
74
74
|
availableHint: "--speakers available",
|
|
75
75
|
unavailableHint: "--speakers unavailable",
|
|
76
76
|
},
|
|
77
|
+
{
|
|
78
|
+
// Runtime resolver looks for plain `kesha-textlang` next to the engine
|
|
79
|
+
// (see `rust/src/text_lang.rs::helper_path`), not the platform-suffixed
|
|
80
|
+
// release-asset name. Mismatch is intentional: the asset name needs the
|
|
81
|
+
// suffix for GitHub-release uniqueness; the sidecar lookup wants the
|
|
82
|
+
// unsuffixed binary so the same Rust code path works on the build-time
|
|
83
|
+
// OUT_DIR baked fallback.
|
|
84
|
+
fileBasename: "kesha-textlang",
|
|
85
|
+
assetName: "kesha-textlang-darwin-arm64",
|
|
86
|
+
displayName: "Text-lang sidecar",
|
|
87
|
+
availableHint: "detect-text-lang fast path",
|
|
88
|
+
unavailableHint:
|
|
89
|
+
"detect-text-lang will fail until next `kesha install` (no swift -e fallback)",
|
|
90
|
+
},
|
|
77
91
|
];
|
|
78
92
|
|
|
79
93
|
/**
|
package/src/engine.ts
CHANGED
|
@@ -50,18 +50,70 @@ export function isEngineInstalled(): boolean {
|
|
|
50
50
|
return existsSync(getEngineBinPath());
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** A Bun.spawn `stdio` array entry: per-fd action or inherit-by-number. */
|
|
54
|
+
type SpawnStdioEntry = "inherit" | "pipe" | "ignore" | number;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Upper bound on the fd number we'll forward (#323 Greptile P2).
|
|
58
|
+
*
|
|
59
|
+
* The `stdio` array is index-addressed, so `KESHA_DEBUG_FD=1000000` would
|
|
60
|
+
* allocate a million-entry array of `"ignore"` strings before the spawn.
|
|
61
|
+
* 1024 is the conservative POSIX `RLIMIT_NOFILE` default — anything
|
|
62
|
+
* above it can't be open in the parent anyway, so capping is a no-op
|
|
63
|
+
* for legitimate users and a DoS guard for bogus input.
|
|
64
|
+
*/
|
|
65
|
+
const MAX_FORWARDED_FD = 1024;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build a `stdio` array for `Bun.spawn`, forwarding `KESHA_DEBUG_FD` (#321 F19).
|
|
69
|
+
*
|
|
70
|
+
* The engine's NDJSON debug sink looks for `KESHA_DEBUG_FD=N` and writes
|
|
71
|
+
* structured events to fd `N`. Bun.spawn closes all non-stdio fds in the
|
|
72
|
+
* child by default, so a bare `KESHA_DEBUG_FD=3 kesha ...` would propagate
|
|
73
|
+
* the env var but the kernel-level fd 3 wouldn't reach the engine — the
|
|
74
|
+
* sink would silently no-op.
|
|
75
|
+
*
|
|
76
|
+
* This helper forwards the parent's fd N to the same number in the child
|
|
77
|
+
* by extending the stdio array with `"ignore"` padding up to index N and
|
|
78
|
+
* setting `stdio[N] = N` (Bun's "inherit parent fd identity" form).
|
|
79
|
+
*
|
|
80
|
+
* Returns `base` unchanged when:
|
|
81
|
+
* - `KESHA_DEBUG_FD` is unset / empty.
|
|
82
|
+
* - The value isn't a non-negative integer.
|
|
83
|
+
* - The value is 0/1/2 (covered by base stdin/stdout/stderr entries).
|
|
84
|
+
*
|
|
85
|
+
* Exported so `synth.ts` (the `kesha say` spawn site) can share the
|
|
86
|
+
* forwarding logic without duplicating the env-parse code.
|
|
87
|
+
*/
|
|
88
|
+
export function spawnStdioWithDebugFd(
|
|
89
|
+
base: [SpawnStdioEntry, SpawnStdioEntry, SpawnStdioEntry],
|
|
90
|
+
): [SpawnStdioEntry, SpawnStdioEntry, SpawnStdioEntry, ...SpawnStdioEntry[]] {
|
|
91
|
+
const envFd = process.env.KESHA_DEBUG_FD;
|
|
92
|
+
if (!envFd) return base;
|
|
93
|
+
const fd = Number(envFd);
|
|
94
|
+
if (!Number.isInteger(fd) || fd < 3 || fd > MAX_FORWARDED_FD) return base;
|
|
95
|
+
const out: SpawnStdioEntry[] = [...base];
|
|
96
|
+
while (out.length < fd) out.push("ignore");
|
|
97
|
+
out[fd] = fd;
|
|
98
|
+
return out as [SpawnStdioEntry, SpawnStdioEntry, SpawnStdioEntry, ...SpawnStdioEntry[]];
|
|
99
|
+
}
|
|
100
|
+
|
|
53
101
|
async function runEngine(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
54
102
|
const binPath = getEngineBinPath();
|
|
55
103
|
const startedAt = performance.now();
|
|
56
104
|
log.debug(`spawn ${binPath} ${args.join(" ")}`);
|
|
57
105
|
const proc = Bun.spawn([binPath, ...args], {
|
|
58
|
-
|
|
59
|
-
stderr: "pipe",
|
|
106
|
+
stdio: spawnStdioWithDebugFd(["ignore", "pipe", "pipe"]),
|
|
60
107
|
});
|
|
108
|
+
// `stdio: [...]` widens stdout/stderr into a union; indices 1/2 are
|
|
109
|
+
// pinned to "pipe" by the helper, so the narrow ReadableStream type
|
|
110
|
+
// is correct. Cast to drop the spurious `number` arm.
|
|
111
|
+
const stdoutStream = proc.stdout as ReadableStream<Uint8Array>;
|
|
112
|
+
const stderrStream = proc.stderr as ReadableStream<Uint8Array>;
|
|
61
113
|
|
|
62
114
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
63
|
-
new Response(
|
|
64
|
-
new Response(
|
|
115
|
+
new Response(stdoutStream).text(),
|
|
116
|
+
new Response(stderrStream).text(),
|
|
65
117
|
proc.exited,
|
|
66
118
|
]);
|
|
67
119
|
|
package/src/star.ts
CHANGED
|
@@ -57,14 +57,13 @@ export function hasStarMarker(binPath: string): boolean {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* Prompt the user to star the repo if and only if
|
|
61
|
-
* agrees (first install + major-or-minor bumps,
|
|
60
|
+
* Prompt the user to star the repo during `kesha install` if and only if
|
|
61
|
+
* `shouldShowStarPrompt` agrees (first install + major-or-minor bumps,
|
|
62
|
+
* never on patch). Records
|
|
62
63
|
* the prompt against the current version up front so a single run never
|
|
63
64
|
* prompts twice — failures from the gh subprocess below don't reopen the
|
|
64
|
-
* gate.
|
|
65
|
-
*
|
|
66
|
-
* saw the prompt on the base install won't see it again on the opt-in or
|
|
67
|
-
* the status check.
|
|
65
|
+
* gate. Install variants (`--tts`, `--diarize`) reuse the same marker; status
|
|
66
|
+
* stays diagnostic-only and does not consume this slot.
|
|
68
67
|
*
|
|
69
68
|
* No-ops when the gate says skip, when `currentVersion` is null, or when
|
|
70
69
|
* the user has already starred. When `gh` is missing or unauthenticated,
|
package/src/status.ts
CHANGED
|
@@ -3,11 +3,8 @@ import { homedir } from "os";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { isEngineInstalled, getEngineBinPath, getEngineCapabilities } from "./engine";
|
|
5
5
|
import { log } from "./log";
|
|
6
|
-
import { maybeAskForStar } from "./star";
|
|
7
6
|
import pc from "picocolors";
|
|
8
7
|
|
|
9
|
-
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
|
|
10
|
-
|
|
11
8
|
function humanBytes(bytes: number): string {
|
|
12
9
|
if (bytes < 1024) return `${bytes} B`;
|
|
13
10
|
const units = ["KB", "MB", "GB"];
|
|
@@ -55,7 +52,12 @@ export async function showStatus(): Promise<void> {
|
|
|
55
52
|
log.info(formatStatusLine("Binary", installed ? binPath : null, installed));
|
|
56
53
|
|
|
57
54
|
if (installed) {
|
|
58
|
-
|
|
55
|
+
let caps: Awaited<ReturnType<typeof getEngineCapabilities>> = null;
|
|
56
|
+
try {
|
|
57
|
+
caps = await getEngineCapabilities();
|
|
58
|
+
} catch {
|
|
59
|
+
caps = null;
|
|
60
|
+
}
|
|
59
61
|
if (caps) {
|
|
60
62
|
log.info(formatStatusLine("Backend", caps.backend, true));
|
|
61
63
|
log.info(formatStatusLine("Protocol", `v${caps.protocolVersion}`, true));
|
|
@@ -91,14 +93,6 @@ export async function showStatus(): Promise<void> {
|
|
|
91
93
|
log.warn('Run "kesha install" to download the engine and models.');
|
|
92
94
|
return;
|
|
93
95
|
}
|
|
94
|
-
|
|
95
|
-
// Star prompt at the very end. Same gate + marker as `kesha install`, so a
|
|
96
|
-
// user prompted by either path won't be re-prompted by the other within the
|
|
97
|
-
// same major.minor. Headless `kesha install` (no `gh`, or no tty) leaves no
|
|
98
|
-
// marker only when the prompt path itself errored out — readStarSeen still
|
|
99
|
-
// sees the marker in the common case.
|
|
100
|
-
const currentVersion = typeof pkg.version === "string" ? pkg.version : null;
|
|
101
|
-
await maybeAskForStar(binPath, currentVersion, log);
|
|
102
96
|
}
|
|
103
97
|
|
|
104
98
|
function showDiskUsage(binPath: string): void {
|
package/src/synth.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getEngineBinPath,
|
|
3
|
+
isEngineInstalled,
|
|
4
|
+
getEngineCapabilities,
|
|
5
|
+
spawnStdioWithDebugFd,
|
|
6
|
+
type EngineCapabilities,
|
|
7
|
+
} from "./engine";
|
|
2
8
|
import { log } from "./log";
|
|
3
9
|
|
|
4
10
|
/**
|
|
@@ -109,21 +115,27 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
|
|
|
109
115
|
const startedAt = performance.now();
|
|
110
116
|
log.debug(`spawn ${getEngineBinPath()} ${args.join(" ")} (text: ${opts.text?.length ?? 0} chars)`);
|
|
111
117
|
const proc = Bun.spawn([getEngineBinPath(), ...args], {
|
|
112
|
-
|
|
113
|
-
stdout: "pipe",
|
|
114
|
-
stderr: "pipe",
|
|
118
|
+
stdio: spawnStdioWithDebugFd(["pipe", "pipe", "pipe"]),
|
|
115
119
|
});
|
|
120
|
+
// The `stdio: [...]` form widens stdin/stdout/stderr into a union
|
|
121
|
+
// (Bun.spawn can't infer that indices 0/1/2 are always "pipe" given
|
|
122
|
+
// the helper's return type). All three are guaranteed `FileSink` /
|
|
123
|
+
// `ReadableStream` here because the helper preserves index 0/1/2
|
|
124
|
+
// from the base tuple. Narrow back via cast.
|
|
125
|
+
const stdin = proc.stdin as Bun.FileSink;
|
|
126
|
+
const stdout = proc.stdout as ReadableStream<Uint8Array>;
|
|
127
|
+
const stderr = proc.stderr as ReadableStream<Uint8Array>;
|
|
116
128
|
|
|
117
129
|
if (opts.text !== undefined && opts.text.length > 0) {
|
|
118
|
-
|
|
119
|
-
await
|
|
130
|
+
stdin.write(opts.text);
|
|
131
|
+
await stdin.end();
|
|
120
132
|
} else {
|
|
121
|
-
await
|
|
133
|
+
await stdin.end();
|
|
122
134
|
}
|
|
123
135
|
|
|
124
136
|
const [stdoutBuf, stderrText, exitCode] = await Promise.all([
|
|
125
|
-
new Response(
|
|
126
|
-
new Response(
|
|
137
|
+
new Response(stdout).arrayBuffer(),
|
|
138
|
+
new Response(stderr).text(),
|
|
127
139
|
proc.exited,
|
|
128
140
|
]);
|
|
129
141
|
|
package/src/transcribe.ts
CHANGED
|
@@ -33,9 +33,10 @@ export async function transcribeWithSegments(
|
|
|
33
33
|
throw new Error(
|
|
34
34
|
"Error: No transcription backend is installed\n\n" +
|
|
35
35
|
"╔══════════════════════════════════════════════════════════╗\n" +
|
|
36
|
-
"║ Please run the following
|
|
36
|
+
"║ Please run the following commands to get started: ║\n" +
|
|
37
37
|
"║ ║\n" +
|
|
38
|
-
"║
|
|
38
|
+
"║ bun add -g @drakulavich/kesha-voice-kit ║\n" +
|
|
39
|
+
"║ kesha install ║\n" +
|
|
39
40
|
"╚══════════════════════════════════════════════════════════╝",
|
|
40
41
|
);
|
|
41
42
|
}
|