@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 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.15.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.15.0"
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",
@@ -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
- if (firstArg && !firstArg.startsWith("-") && !firstArg.includes(".") && !firstArg.includes("/")) {
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.
@@ -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
- stdout: "pipe",
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(proc.stdout).text(),
64
- new Response(proc.stderr).text(),
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 `shouldShowStarPrompt`
61
- * agrees (first install + major-or-minor bumps, never on patch). Records
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. Shared by `kesha install` and `kesha status` so opt-in installs
65
- * (`--tts`, `--diarize`) and `status` reuse the same marker; a user who
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
- const caps = await getEngineCapabilities();
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 { getEngineBinPath, isEngineInstalled, getEngineCapabilities, type EngineCapabilities } from "./engine";
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
- stdin: "pipe",
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
- proc.stdin.write(opts.text);
119
- await proc.stdin.end();
130
+ stdin.write(opts.text);
131
+ await stdin.end();
120
132
  } else {
121
- await proc.stdin.end();
133
+ await stdin.end();
122
134
  }
123
135
 
124
136
  const [stdoutBuf, stderrText, exitCode] = await Promise.all([
125
- new Response(proc.stdout).arrayBuffer(),
126
- new Response(proc.stderr).text(),
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 command to get started: ║\n" +
36
+ "║ Please run the following commands to get started: ║\n" +
37
37
  "║ ║\n" +
38
- "║ bunx @drakulavich/kesha-voice-kit install ║\n" +
38
+ "║ bun add -g @drakulavich/kesha-voice-kit ║\n" +
39
+ "║ kesha install ║\n" +
39
40
  "╚══════════════════════════════════════════════════════════╝",
40
41
  );
41
42
  }