@drakulavich/kesha-voice-kit 1.16.0 → 1.17.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 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/SKILL.md CHANGED
@@ -26,6 +26,39 @@ Local voice toolkit: transcribe voice messages to text, synthesize speech, detec
26
26
  - **Need to send a voice note (Telegram, WhatsApp, Signal, Discord)**: synthesize directly into the messenger-native format with `kesha say "<text>" --format ogg-opus --out reply.ogg`. Default is mono 24 kHz @ 32 kbps — what Telegram `sendVoice` expects. No `ffmpeg` round-trip needed.
27
27
  - **Need to detect what language a file is in** before choosing a pipeline: `kesha --json audio.ogg` returns both audio-based and text-based language detection with confidence scores.
28
28
 
29
+ ## OpenClaw plugin setup
30
+
31
+ Install the plugin, then explicitly route OpenClaw audio understanding through the CLI model entry. The plugin registration makes Kesha discoverable, but real voice-message transcription uses `tools.media.audio.models` with a `type: "cli"` entry.
32
+
33
+ ```bash
34
+ bun add -g @drakulavich/kesha-voice-kit
35
+ kesha install
36
+ openclaw plugins install @drakulavich/kesha-voice-kit
37
+ openclaw config set tools.media.audio.enabled true
38
+ openclaw config set tools.media.audio.models \
39
+ '[{"type":"cli","command":"kesha","args":["--format","transcript","{{MediaPath}}"],"timeoutSeconds":15}]'
40
+ ```
41
+
42
+ Use `--format transcript` for OpenClaw's normal voice-message path: stdout is compact text plus language metadata, while progress and errors stay off the transcript payload.
43
+
44
+ For agents that need timestamped segments, switch the model entry to JSON output and allow a longer timeout:
45
+
46
+ ```bash
47
+ openclaw config set tools.media.audio.models \
48
+ '[{"type":"cli","command":"kesha","args":["--json","--timestamps","{{MediaPath}}"],"timeoutSeconds":30}]'
49
+ ```
50
+
51
+ Verification checklist:
52
+
53
+ ```bash
54
+ which kesha
55
+ kesha status
56
+ openclaw plugins list
57
+ openclaw config get tools.media.audio.models
58
+ ```
59
+
60
+ Do not rely on `openclaw.plugin.json` to patch `tools.media.audio.models`; OpenClaw ignores non-schema fields such as `configPatch`. Keep the CLI route in user config.
61
+
29
62
  ## STT: transcribe audio
30
63
 
31
64
  ```bash
@@ -6,7 +6,7 @@
6
6
  // 25 languages, no cloud, ~19x faster than Whisper.
7
7
  //
8
8
  // The same `kesha` CLI also produces messenger-ready voice notes via
9
- // `kesha say --text "..." --format ogg-opus --out reply.ogg` (Telegram /
9
+ // `kesha say "..." --format ogg-opus --out reply.ogg` (Telegram /
10
10
  // Discord ingest OGG/Opus directly — no transcoding needed). Not wired into
11
11
  // OpenClaw's media-understanding capability here; invoke it from a hook or
12
12
  // agent tool when you want Kesha to speak.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.16.0",
3
+ "version": "1.17.1",
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.16.0"
33
+ "version": "1.17.0"
34
34
  },
35
35
  "scripts": {
36
36
  "test": "bun test",
@@ -39,6 +39,7 @@
39
39
  "lint": "bunx tsc --noEmit",
40
40
  "lint:tsgo": "bunx tsgo --noEmit",
41
41
  "check:workflows": "bun .github/scripts/check-workflows.ts",
42
+ "check:versions": "bun .github/scripts/check-versions.ts",
42
43
  "postinstall": "node scripts/postinstall.cjs"
43
44
  },
44
45
  "keywords": [
@@ -1,12 +1,18 @@
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";
5
6
  import { sayCommand } from "./say";
7
+ import { statsCommand } from "./stats";
6
8
  import { statusCommand } from "./status";
7
9
  import { mainCommand } from "./main";
8
10
 
9
- const SUBCOMMANDS = ["install", "status", "say"];
11
+ const SUBCOMMANDS = ["install", "status", "say", "stats"];
12
+
13
+ function isPathLike(arg: string): boolean {
14
+ return arg.includes(".") || arg.includes("/") || existsSync(arg);
15
+ }
10
16
 
11
17
  export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
12
18
  const [firstArg, ...restArgs] = rawArgs;
@@ -26,14 +32,22 @@ export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
26
32
  return;
27
33
  }
28
34
 
29
- // Check for unknown subcommands (non-flag, non-file-path args)
30
- if (firstArg && !firstArg.startsWith("-") && !firstArg.includes(".") && !firstArg.includes("/")) {
35
+ if (firstArg === "stats") {
36
+ await runMain(statsCommand, { rawArgs: restArgs });
37
+ return;
38
+ }
39
+
40
+ // Check for unknown subcommands (non-flag, non-file-path args).
41
+ // Extensionless existing files remain valid transcription inputs; missing
42
+ // bare tokens are more likely command typos and should not start the engine.
43
+ if (firstArg && !firstArg.startsWith("-") && !isPathLike(firstArg)) {
31
44
  const suggestion = suggestCommand(firstArg, SUBCOMMANDS);
45
+ log.error(`unknown command '${firstArg}'`);
32
46
  if (suggestion && suggestion !== firstArg) {
33
- log.error(`unknown command '${firstArg}'`);
34
47
  log.warn(`(Did you mean ${suggestion}?)`);
35
- process.exit(1);
36
48
  }
49
+ log.warn(`If this is an audio file, pass a path like './${firstArg}'.`);
50
+ process.exit(1);
37
51
  }
38
52
 
39
53
  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";
@@ -12,6 +13,7 @@ import {
12
13
  formatVerboseOutput,
13
14
  } from "../format";
14
15
  import { formatToonOutput } from "../toon";
16
+ import { artifactFromFile, createStatsRecorder } from "../stats";
15
17
 
16
18
  const pkg = await Bun.file(new URL("../../package.json", import.meta.url)).json();
17
19
 
@@ -108,8 +110,12 @@ export const mainCommand = defineCommand({
108
110
  version: pkg.version,
109
111
  description:
110
112
  "Kesha Voice Kit — open-source voice toolkit for Apple Silicon.\n" +
111
- " Run 'kesha install [--no-cache]' to download engine and models.\n" +
112
- " Run 'kesha status' to inspect installed backend.",
113
+ "\n" +
114
+ "Commands:\n" +
115
+ " install Download engine and models.\n" +
116
+ " status Inspect installed backend.\n" +
117
+ " say Synthesize speech from text.\n" +
118
+ " stats Manage local anonymous performance stats.",
113
119
  },
114
120
  args: {
115
121
  json: {
@@ -196,22 +202,46 @@ export const mainCommand = defineCommand({
196
202
  const vadMode = args.vad ? "on" : args["no-vad"] ? "off" : "auto";
197
203
 
198
204
  if (files.length === 0) {
199
- log.info("Usage: kesha <audio_file> [audio_file ...]\n kesha install [--no-cache]\n kesha status");
205
+ log.info(
206
+ "Usage: kesha <audio_file> [audio_file ...]\n" +
207
+ " kesha install [--no-cache]\n" +
208
+ " kesha status\n" +
209
+ " kesha say <text>\n" +
210
+ " kesha stats [enable|disable|status|week|errors]",
211
+ );
200
212
  process.exit(1);
201
213
  }
202
214
 
203
215
  let hasError = false;
204
216
  const results: TranscribeResult[] = [];
217
+ const stats = createStatsRecorder("transcribe");
205
218
 
206
219
  const wantsLangId = !!(args.lang || args.verbose || wantsJson || wantsToon || wantsTranscript);
220
+ const reportProgress = process.stdout.isTTY !== true;
207
221
 
208
222
  for (const file of files) {
223
+ if (!existsSync(file)) {
224
+ hasError = true;
225
+ stats.recordError("input", new Error("File not found"), "file_not_found");
226
+ log.error(`${file}: File not found`);
227
+ continue;
228
+ }
229
+ const inputArtifact = artifactFromFile(file, "input_audio");
230
+ if (inputArtifact) stats.recordArtifact(inputArtifact);
231
+
209
232
  const startedAt = performance.now();
210
233
  try {
234
+ if (reportProgress) {
235
+ console.error(`Transcribing ${file}...`);
236
+ }
211
237
  // Run audio lang-id and transcription concurrently.
212
238
  const [audioResult, transcript] = await Promise.all([
213
- wantsLangId ? detectAudioLanguageEngine(file) : Promise.resolve(null),
214
- transcribeWithSegments(file, { vad: vadMode, timestamps: args.timestamps, speakers: args.speakers }),
239
+ wantsLangId
240
+ ? stats.timeStage("lang_id_audio", () => detectAudioLanguageEngine(file))
241
+ : Promise.resolve(null),
242
+ stats.timeStage("transcribe", () =>
243
+ transcribeWithSegments(file, { vad: vadMode, timestamps: args.timestamps, speakers: args.speakers })
244
+ ),
215
245
  ]);
216
246
  const { text, segments } = transcript;
217
247
 
@@ -229,7 +259,7 @@ export const mainCommand = defineCommand({
229
259
  let textLanguage: LangDetectResult | undefined;
230
260
 
231
261
  if (wantsLangId) {
232
- const engineTextResult = await detectTextLanguageEngine(text);
262
+ const engineTextResult = await stats.timeStage("lang_id_text", () => detectTextLanguageEngine(text));
233
263
  if (engineTextResult && engineTextResult.code) {
234
264
  textLanguage = engineTextResult;
235
265
  }
@@ -240,20 +270,25 @@ export const mainCommand = defineCommand({
240
270
  const mismatchWarning = checkLanguageMismatch(args.lang, lang);
241
271
  if (mismatchWarning) log.warn(`${file}: ${mismatchWarning}`);
242
272
 
273
+ const sttTimeMs = Math.round(performance.now() - startedAt);
243
274
  const result: TranscribeResult = {
244
275
  file,
245
276
  text,
246
277
  lang,
247
278
  audioLanguage,
248
279
  textLanguage: textLanguage ?? (tinyldLang ? { code: tinyldLang, confidence: 0 } : undefined),
249
- sttTimeMs: Math.round(performance.now() - startedAt),
280
+ sttTimeMs,
250
281
  };
251
- if (args.timestamps) {
282
+ if (args.timestamps || args.speakers) {
252
283
  result.segments = segments;
253
284
  }
254
285
  results.push(result);
286
+ if (reportProgress) {
287
+ console.error(`Transcribed ${file} (${sttTimeMs}ms)`);
288
+ }
255
289
  } catch (err: unknown) {
256
290
  hasError = true;
291
+ stats.recordError("transcribe", err);
257
292
  const message = err instanceof Error ? err.message : String(err);
258
293
  log.error(`${file}: ${message}`);
259
294
  }
@@ -271,6 +306,8 @@ export const mainCommand = defineCommand({
271
306
  process.stdout.write(formatTextOutput(results));
272
307
  }
273
308
 
309
+ stats.finish(hasError ? "failed" : "success", files.length);
310
+
274
311
  if (hasError) process.exit(1);
275
312
  },
276
313
  });
package/src/cli/say.ts CHANGED
@@ -2,6 +2,7 @@ import { defineCommand } from "citty";
2
2
  import { detectTextLanguageEngine, getEngineBinPath } from "../engine";
3
3
  import { log } from "../log";
4
4
  import { say, SayError, type SayFormat } from "../synth";
5
+ import { artifactFromBytes, artifactFromFile, createStatsRecorder } from "../stats";
5
6
  import { pickVoiceForLang } from "../voice-routing";
6
7
 
7
8
  /** Run NLLanguageRecognizer (via engine) on the text and pick a default voice. */
@@ -133,19 +134,36 @@ export const sayCommand = defineCommand({
133
134
  sampleRate: args["sample-rate"] ? Number(args["sample-rate"]) : undefined,
134
135
  noExpandAbbrev: Boolean(args["no-expand-abbrev"]),
135
136
  };
137
+ const stats = createStatsRecorder("say");
136
138
 
137
139
  try {
138
140
  const startedAt = performance.now();
139
- const audio = await say(opts);
141
+ if (opts.out) {
142
+ const voiceLabel = opts.voice ?? "default voice";
143
+ console.error(`Synthesizing ${voiceLabel} -> ${opts.out}...`);
144
+ }
145
+ const audio = await stats.timeStage("tts", () => say(opts));
140
146
  const ttsTimeMs = Math.round(performance.now() - startedAt);
141
- if (args.verbose) {
147
+ if (opts.out) {
148
+ console.error(`Saved ${opts.out} (${ttsTimeMs}ms)`);
149
+ }
150
+ if (args.verbose && !opts.out) {
142
151
  // stderr — stdout may carry raw audio bytes when --out is omitted.
143
152
  console.error(`TTS time: ${ttsTimeMs}ms`);
144
153
  }
154
+ if (opts.out) {
155
+ const outputArtifact = artifactFromFile(opts.out, "output_audio");
156
+ if (outputArtifact) stats.recordArtifact(outputArtifact);
157
+ } else {
158
+ stats.recordArtifact(artifactFromBytes(audio.byteLength, "output_audio", opts.format ?? "wav"));
159
+ }
145
160
  if (!opts.out) {
146
161
  process.stdout.write(audio);
147
162
  }
163
+ stats.finish("success", 1);
148
164
  } catch (err) {
165
+ stats.recordError("tts", err);
166
+ stats.finish("failed", 1);
149
167
  if (err instanceof SayError) {
150
168
  log.error(err.stderr.trim() || err.message);
151
169
  process.exit(err.exitCode);
@@ -0,0 +1,67 @@
1
+ import { defineCommand } from "citty";
2
+ import { log } from "../log";
3
+ import {
4
+ disableStats,
5
+ enableStats,
6
+ getRecentErrors,
7
+ getStatsStatus,
8
+ getWeekSummary,
9
+ renderErrors,
10
+ renderWeekSummary,
11
+ } from "../stats";
12
+
13
+ interface StatsCommandArgs {
14
+ action?: string;
15
+ }
16
+
17
+ export const statsCommand = defineCommand({
18
+ meta: {
19
+ name: "stats",
20
+ description: "Manage local anonymous Kesha Stats",
21
+ },
22
+ args: {
23
+ action: {
24
+ type: "positional",
25
+ required: false,
26
+ description: "Action: enable | disable | status | week | errors",
27
+ },
28
+ },
29
+ run({ args }: { args: StatsCommandArgs }) {
30
+ const action = args.action ?? "status";
31
+ switch (action) {
32
+ case "enable": {
33
+ enableStats();
34
+ const status = getStatsStatus();
35
+ log.success("Kesha Stats enabled");
36
+ log.info(`Database: ${status.dbPath}`);
37
+ return;
38
+ }
39
+ case "disable": {
40
+ disableStats();
41
+ const status = getStatsStatus();
42
+ log.info("Kesha Stats disabled");
43
+ log.info(`Database: ${status.dbPath}`);
44
+ return;
45
+ }
46
+ case "status": {
47
+ const status = getStatsStatus();
48
+ log.info(`Kesha Stats: ${status.enabled ? "enabled" : "disabled"}`);
49
+ log.info(`Database: ${status.dbPath}`);
50
+ log.info(`Runs: ${status.runCount}`);
51
+ return;
52
+ }
53
+ case "week": {
54
+ log.info(renderWeekSummary(getWeekSummary()));
55
+ return;
56
+ }
57
+ case "errors": {
58
+ log.info(renderErrors(getRecentErrors()));
59
+ return;
60
+ }
61
+ default:
62
+ log.error(`unknown stats action '${action}'`);
63
+ log.warn("supported: enable, disable, status, week, errors");
64
+ process.exit(2);
65
+ }
66
+ },
67
+ });
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@ export { installCommand } from "./cli/install";
7
7
  export { sayCommand } from "./cli/say";
8
8
  export { pickVoiceForLang } from "./voice-routing";
9
9
  export { statusCommand } from "./cli/status";
10
+ export { statsCommand } from "./cli/stats";
10
11
  export {
11
12
  mainCommand,
12
13
  detectLanguage,
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/stats.ts ADDED
@@ -0,0 +1,580 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { existsSync, mkdirSync, readFileSync, statSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { dirname, extname, join } from "path";
5
+ import { log } from "./log";
6
+
7
+ const SCHEMA_VERSION = 1;
8
+ const MAX_ERROR_MESSAGE_CHARS = 300;
9
+ const APP_VERSION = readPackageVersion();
10
+
11
+ export type StatsCommandName = "transcribe" | "say";
12
+ export type StatsStageStatus = "success" | "failed";
13
+ export type StatsRunStatus = "success" | "failed";
14
+
15
+ export interface StatsArtifactInput {
16
+ kind: "input_audio" | "output_audio";
17
+ format?: string | null;
18
+ sizeBytes?: number | null;
19
+ durationMs?: number | null;
20
+ sampleRate?: number | null;
21
+ channels?: number | null;
22
+ }
23
+
24
+ export interface StatsRecorder {
25
+ readonly enabled: boolean;
26
+ recordArtifact(input: StatsArtifactInput): void;
27
+ timeStage<T>(stage: string, fn: () => Promise<T>): Promise<T>;
28
+ recordError(stage: string, err: unknown, errorCode?: string): void;
29
+ finish(status: StatsRunStatus, itemCount?: number): void;
30
+ }
31
+
32
+ class NoopStatsRecorder implements StatsRecorder {
33
+ readonly enabled = false;
34
+ recordArtifact(): void {}
35
+ async timeStage<T>(_stage: string, fn: () => Promise<T>): Promise<T> {
36
+ return fn();
37
+ }
38
+ recordError(): void {}
39
+ finish(): void {}
40
+ }
41
+
42
+ class SqliteStatsRecorder implements StatsRecorder {
43
+ readonly enabled = true;
44
+ private closed = false;
45
+
46
+ constructor(
47
+ private readonly db: Database,
48
+ private readonly runId: number,
49
+ ) {}
50
+
51
+ recordArtifact(input: StatsArtifactInput): void {
52
+ this.safeWrite(() => {
53
+ this.db.query(
54
+ `insert into artifacts
55
+ (run_id, kind, format, size_bytes, duration_ms, sample_rate, channels)
56
+ values (?, ?, ?, ?, ?, ?, ?)`,
57
+ ).run(
58
+ this.runId,
59
+ input.kind,
60
+ normalizeFormat(input.format),
61
+ input.sizeBytes ?? null,
62
+ input.durationMs ?? null,
63
+ input.sampleRate ?? null,
64
+ input.channels ?? null,
65
+ );
66
+ });
67
+ }
68
+
69
+ async timeStage<T>(stage: string, fn: () => Promise<T>): Promise<T> {
70
+ const startedAt = new Date().toISOString();
71
+ const t0 = performance.now();
72
+ try {
73
+ const result = await fn();
74
+ this.recordStage(stage, startedAt, t0, "success");
75
+ return result;
76
+ } catch (err) {
77
+ this.recordStage(stage, startedAt, t0, "failed");
78
+ throw err;
79
+ }
80
+ }
81
+
82
+ recordError(stage: string, err: unknown, errorCode?: string): void {
83
+ const { errorClass, message } = sanitizeStatsError(err);
84
+ this.safeWrite(() => {
85
+ this.db.query(
86
+ `insert into errors
87
+ (run_id, stage, error_class, error_code, sanitized_message, occurred_at)
88
+ values (?, ?, ?, ?, ?, ?)`,
89
+ ).run(
90
+ this.runId,
91
+ stage,
92
+ errorClass,
93
+ errorCode ?? null,
94
+ message,
95
+ new Date().toISOString(),
96
+ );
97
+ });
98
+ }
99
+
100
+ finish(status: StatsRunStatus, itemCount = 0): void {
101
+ if (this.closed) return;
102
+ this.safeWrite(() => {
103
+ this.db.query(
104
+ "update runs set finished_at = ?, status = ?, item_count = ? where id = ?",
105
+ ).run(new Date().toISOString(), status, itemCount, this.runId);
106
+ });
107
+ this.closed = true;
108
+ this.db.close();
109
+ }
110
+
111
+ private recordStage(
112
+ stage: string,
113
+ startedAt: string,
114
+ startedMs: number,
115
+ status: StatsStageStatus,
116
+ ): void {
117
+ this.safeWrite(() => {
118
+ this.db.query(
119
+ `insert into stage_timings
120
+ (run_id, stage, started_at, duration_ms, status)
121
+ values (?, ?, ?, ?, ?)`,
122
+ ).run(
123
+ this.runId,
124
+ stage,
125
+ startedAt,
126
+ Math.max(0, Math.round(performance.now() - startedMs)),
127
+ status,
128
+ );
129
+ });
130
+ }
131
+
132
+ private safeWrite(fn: () => void): void {
133
+ try {
134
+ fn();
135
+ } catch (err) {
136
+ warnStatsWriteFailed(err);
137
+ }
138
+ }
139
+ }
140
+
141
+ let warnedStatsWriteFailed = false;
142
+
143
+ function warnStatsWriteFailed(err: unknown): void {
144
+ if (!warnedStatsWriteFailed) {
145
+ log.warn("warning: failed to write Kesha Stats; continuing without stats for this event");
146
+ warnedStatsWriteFailed = true;
147
+ }
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ log.debug(`stats write failed: ${message}`);
150
+ }
151
+
152
+ export function createStatsRecorder(command: StatsCommandName): StatsRecorder {
153
+ const dbPath = resolveStatsDbPath();
154
+ if (!existsSync(dbPath)) return new NoopStatsRecorder();
155
+
156
+ try {
157
+ const db = openStatsDatabase(dbPath);
158
+ if (!getStatsEnabled(db)) {
159
+ db.close();
160
+ return new NoopStatsRecorder();
161
+ }
162
+ const runId = insertRun(db, command);
163
+ return new SqliteStatsRecorder(db, runId);
164
+ } catch (err) {
165
+ warnStatsWriteFailed(err);
166
+ return new NoopStatsRecorder();
167
+ }
168
+ }
169
+
170
+ export function enableStats(): void {
171
+ const db = openStatsDatabase(resolveStatsDbPath());
172
+ try {
173
+ setSetting(db, "enabled", "1");
174
+ } finally {
175
+ db.close();
176
+ }
177
+ }
178
+
179
+ export function disableStats(): void {
180
+ const db = openStatsDatabase(resolveStatsDbPath());
181
+ try {
182
+ setSetting(db, "enabled", "0");
183
+ } finally {
184
+ db.close();
185
+ }
186
+ }
187
+
188
+ export interface StatsStatus {
189
+ enabled: boolean;
190
+ dbPath: string;
191
+ runCount: number;
192
+ exists: boolean;
193
+ }
194
+
195
+ export function getStatsStatus(): StatsStatus {
196
+ const dbPath = resolveStatsDbPath();
197
+ if (!existsSync(dbPath)) {
198
+ return { enabled: false, dbPath, runCount: 0, exists: false };
199
+ }
200
+ const db = openStatsDatabase(dbPath);
201
+ try {
202
+ return {
203
+ enabled: getStatsEnabled(db),
204
+ dbPath,
205
+ runCount: Number((db.query("select count(*) as n from runs").get() as { n: number }).n),
206
+ exists: true,
207
+ };
208
+ } finally {
209
+ db.close();
210
+ }
211
+ }
212
+
213
+ export interface StatsWeekSummary {
214
+ runs: number;
215
+ successes: number;
216
+ failures: number;
217
+ inputFiles: number;
218
+ inputBytes: number;
219
+ inputDurationMs: number;
220
+ sttTimeMs: number;
221
+ ttsTimeMs: number;
222
+ }
223
+
224
+ export function getWeekSummary(now = new Date()): StatsWeekSummary {
225
+ const dbPath = resolveStatsDbPath();
226
+ if (!existsSync(dbPath)) return emptyWeekSummary();
227
+ const since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
228
+ const db = openStatsDatabase(dbPath);
229
+ try {
230
+ const runs = db.query(
231
+ `select
232
+ count(*) as runs,
233
+ sum(case when status = 'success' then 1 else 0 end) as successes,
234
+ sum(case when status = 'failed' then 1 else 0 end) as failures
235
+ from runs
236
+ where started_at >= ?`,
237
+ ).get(since) as { runs: number; successes: number | null; failures: number | null };
238
+
239
+ const artifacts = db.query(
240
+ `select
241
+ count(*) as inputFiles,
242
+ coalesce(sum(size_bytes), 0) as inputBytes,
243
+ coalesce(sum(duration_ms), 0) as inputDurationMs
244
+ from artifacts
245
+ where kind = 'input_audio'
246
+ and run_id in (select id from runs where started_at >= ?)`,
247
+ ).get(since) as { inputFiles: number; inputBytes: number; inputDurationMs: number };
248
+
249
+ const stages = db.query(
250
+ `select
251
+ coalesce(sum(case when stage = 'transcribe' then duration_ms else 0 end), 0) as sttTimeMs,
252
+ coalesce(sum(case when stage = 'tts' then duration_ms else 0 end), 0) as ttsTimeMs
253
+ from stage_timings
254
+ where run_id in (select id from runs where started_at >= ?)`,
255
+ ).get(since) as { sttTimeMs: number; ttsTimeMs: number };
256
+
257
+ return {
258
+ runs: Number(runs.runs ?? 0),
259
+ successes: Number(runs.successes ?? 0),
260
+ failures: Number(runs.failures ?? 0),
261
+ inputFiles: Number(artifacts.inputFiles ?? 0),
262
+ inputBytes: Number(artifacts.inputBytes ?? 0),
263
+ inputDurationMs: Number(artifacts.inputDurationMs ?? 0),
264
+ sttTimeMs: Number(stages.sttTimeMs ?? 0),
265
+ ttsTimeMs: Number(stages.ttsTimeMs ?? 0),
266
+ };
267
+ } finally {
268
+ db.close();
269
+ }
270
+ }
271
+
272
+ export interface StatsErrorRow {
273
+ occurredAt: string;
274
+ command: string | null;
275
+ stage: string | null;
276
+ errorClass: string | null;
277
+ errorCode: string | null;
278
+ message: string;
279
+ }
280
+
281
+ export function getRecentErrors(limit = 20): StatsErrorRow[] {
282
+ const dbPath = resolveStatsDbPath();
283
+ if (!existsSync(dbPath)) return [];
284
+ const db = openStatsDatabase(dbPath);
285
+ try {
286
+ const rows = db.query(
287
+ `select
288
+ e.occurred_at as occurredAt,
289
+ r.command as command,
290
+ e.stage as stage,
291
+ e.error_class as errorClass,
292
+ e.error_code as errorCode,
293
+ e.sanitized_message as message
294
+ from errors e
295
+ left join runs r on r.id = e.run_id
296
+ order by e.occurred_at desc
297
+ limit ?`,
298
+ ).all(limit) as StatsErrorRow[];
299
+ return rows;
300
+ } finally {
301
+ db.close();
302
+ }
303
+ }
304
+
305
+ export function renderWeekSummary(summary: StatsWeekSummary): string {
306
+ const realtime = summary.inputDurationMs > 0 && summary.sttTimeMs > 0
307
+ ? `${(summary.inputDurationMs / summary.sttTimeMs).toFixed(1)}x`
308
+ : "n/a";
309
+ return [
310
+ "Kesha Stats - last 7 days",
311
+ `Runs: ${summary.runs} (${summary.successes} success, ${summary.failures} failed)`,
312
+ `Input files: ${summary.inputFiles}`,
313
+ `Input audio: ${humanDuration(summary.inputDurationMs)}`,
314
+ `Input size: ${humanBytes(summary.inputBytes)}`,
315
+ `STT time: ${humanDuration(summary.sttTimeMs)} (${realtime} realtime)`,
316
+ `TTS time: ${humanDuration(summary.ttsTimeMs)}`,
317
+ ].join("\n");
318
+ }
319
+
320
+ export function renderErrors(rows: StatsErrorRow[]): string {
321
+ if (rows.length === 0) return "Kesha Stats - no recorded errors";
322
+ const lines = ["Kesha Stats - recent errors"];
323
+ for (const row of rows) {
324
+ const parts = [
325
+ row.occurredAt,
326
+ row.command ?? "unknown",
327
+ row.stage ?? "unknown",
328
+ row.errorCode ?? row.errorClass ?? "error",
329
+ ];
330
+ lines.push(`${parts.join(" | ")} | ${row.message}`);
331
+ }
332
+ return lines.join("\n");
333
+ }
334
+
335
+ export function resolveStatsDbPath(): string {
336
+ if (process.env.KESHA_STATS_DB) return process.env.KESHA_STATS_DB;
337
+ if (process.platform === "darwin") {
338
+ return join(homedir(), "Library", "Application Support", "kesha", "stats.sqlite");
339
+ }
340
+ if (process.platform === "win32") {
341
+ const base = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
342
+ return join(base, "kesha", "stats.sqlite");
343
+ }
344
+ const base = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
345
+ return join(base, "kesha", "stats.sqlite");
346
+ }
347
+
348
+ export function artifactFromFile(
349
+ path: string,
350
+ kind: StatsArtifactInput["kind"],
351
+ ): StatsArtifactInput | null {
352
+ try {
353
+ const st = statSync(path);
354
+ if (!st.isFile()) return null;
355
+ return {
356
+ kind,
357
+ format: extname(path),
358
+ sizeBytes: st.size,
359
+ };
360
+ } catch {
361
+ return null;
362
+ }
363
+ }
364
+
365
+ export function artifactFromBytes(
366
+ bytes: number,
367
+ kind: StatsArtifactInput["kind"],
368
+ format?: string,
369
+ ): StatsArtifactInput {
370
+ return {
371
+ kind,
372
+ format,
373
+ sizeBytes: bytes,
374
+ };
375
+ }
376
+
377
+ export function sanitizeStatsError(err: unknown): { errorClass: string; message: string } {
378
+ const errorClass = err instanceof Error ? err.name : typeof err;
379
+ const raw = err instanceof Error ? err.message || err.name : String(err);
380
+ let message = raw
381
+ .split(/\r?\n/)
382
+ .filter((line) => !/^\s*at\s+/.test(line))
383
+ .join(" ")
384
+ .replace(/\s+/g, " ")
385
+ .trim();
386
+
387
+ const home = homedir();
388
+ const cwd = process.cwd();
389
+ for (const path of [home, cwd]) {
390
+ if (path) {
391
+ message = message.replace(new RegExp(escapeRegExp(path), "g"), "<path>");
392
+ }
393
+ }
394
+
395
+ message = message
396
+ .replace(/(https?:\/\/[^\s?]+)\?[^\s]+/g, "$1?<redacted>")
397
+ .replace(/("text"\s*:\s*)"[^"]*"/gi, '$1"<redacted>"')
398
+ .replace(/("transcript"\s*:\s*)"[^"]*"/gi, '$1"<redacted>"')
399
+ .replace(/("stdout"\s*:\s*)"[^"]*"/gi, '$1"<redacted>"')
400
+ .replace(/("stderr"\s*:\s*)"[^"]*"/gi, '$1"<redacted>"')
401
+ .replace(/\/(?:Users|home|tmp|private\/tmp|var\/folders)\/[^\s)"']+/g, "<path>")
402
+ .replace(/[A-Za-z]:\\[^\s)"']+/g, "<path>");
403
+
404
+ if (message.length === 0) message = errorClass || "unknown error";
405
+ if (message.length > MAX_ERROR_MESSAGE_CHARS) {
406
+ message = `${message.slice(0, MAX_ERROR_MESSAGE_CHARS - 3)}...`;
407
+ }
408
+ return { errorClass, message };
409
+ }
410
+
411
+ function openStatsDatabase(dbPath: string): Database {
412
+ mkdirSync(dirname(dbPath), { recursive: true });
413
+ const db = new Database(dbPath);
414
+ try {
415
+ db.exec("pragma journal_mode = WAL");
416
+ db.exec("pragma busy_timeout = 1000");
417
+ migrateStatsDatabase(db);
418
+ return db;
419
+ } catch (err) {
420
+ db.close();
421
+ throw err;
422
+ }
423
+ }
424
+
425
+ function migrateStatsDatabase(db: Database): void {
426
+ db.exec(`
427
+ create table if not exists schema_migrations (
428
+ version integer primary key,
429
+ applied_at text not null
430
+ );
431
+ `);
432
+
433
+ const currentVersion = currentSchemaVersion(db);
434
+ if (currentVersion >= SCHEMA_VERSION) return;
435
+
436
+ if (currentVersion < 1) {
437
+ db.exec(`
438
+ create table if not exists settings (
439
+ key text primary key,
440
+ value text not null
441
+ );
442
+
443
+ create table if not exists runs (
444
+ id integer primary key autoincrement,
445
+ command text not null,
446
+ started_at text not null,
447
+ finished_at text,
448
+ status text not null,
449
+ app_version text not null,
450
+ item_count integer not null default 0
451
+ );
452
+
453
+ create table if not exists artifacts (
454
+ id integer primary key autoincrement,
455
+ run_id integer not null references runs(id),
456
+ kind text not null,
457
+ format text,
458
+ size_bytes integer,
459
+ duration_ms integer,
460
+ sample_rate integer,
461
+ channels integer
462
+ );
463
+
464
+ create table if not exists stage_timings (
465
+ id integer primary key autoincrement,
466
+ run_id integer not null references runs(id),
467
+ stage text not null,
468
+ started_at text not null,
469
+ duration_ms integer not null,
470
+ status text not null
471
+ );
472
+
473
+ create table if not exists errors (
474
+ id integer primary key autoincrement,
475
+ run_id integer references runs(id),
476
+ stage text,
477
+ error_class text,
478
+ error_code text,
479
+ sanitized_message text not null,
480
+ occurred_at text not null
481
+ );
482
+
483
+ create index if not exists runs_started_at_idx on runs(started_at);
484
+ create index if not exists stage_timings_run_id_idx on stage_timings(run_id);
485
+ create index if not exists artifacts_run_id_idx on artifacts(run_id);
486
+ create index if not exists errors_occurred_at_idx on errors(occurred_at);
487
+ `);
488
+
489
+ recordSchemaVersion(db, 1);
490
+ }
491
+ }
492
+
493
+ function currentSchemaVersion(db: Database): number {
494
+ const row = db.query("select coalesce(max(version), 0) as version from schema_migrations").get() as {
495
+ version: number;
496
+ };
497
+ return Number(row.version ?? 0);
498
+ }
499
+
500
+ function recordSchemaVersion(db: Database, version: number): void {
501
+ db.query(
502
+ "insert or ignore into schema_migrations (version, applied_at) values (?, ?)",
503
+ ).run(version, new Date().toISOString());
504
+ }
505
+
506
+ function getStatsEnabled(db: Database): boolean {
507
+ const row = db.query("select value from settings where key = 'enabled'").get() as
508
+ | { value: string }
509
+ | null;
510
+ return row?.value === "1";
511
+ }
512
+
513
+ function setSetting(db: Database, key: string, value: string): void {
514
+ db.query(
515
+ "insert into settings (key, value) values (?, ?) on conflict(key) do update set value = excluded.value",
516
+ ).run(key, value);
517
+ }
518
+
519
+ function insertRun(db: Database, command: StatsCommandName): number {
520
+ const result = db.query(
521
+ "insert into runs (command, started_at, status, app_version) values (?, ?, 'failed', ?) returning id",
522
+ ).get(command, new Date().toISOString(), APP_VERSION) as { id: number };
523
+ return Number(result.id);
524
+ }
525
+
526
+ function normalizeFormat(format?: string | null): string | null {
527
+ if (!format) return null;
528
+ return format.replace(/^\./, "").toLowerCase();
529
+ }
530
+
531
+ function emptyWeekSummary(): StatsWeekSummary {
532
+ return {
533
+ runs: 0,
534
+ successes: 0,
535
+ failures: 0,
536
+ inputFiles: 0,
537
+ inputBytes: 0,
538
+ inputDurationMs: 0,
539
+ sttTimeMs: 0,
540
+ ttsTimeMs: 0,
541
+ };
542
+ }
543
+
544
+ function humanBytes(bytes: number): string {
545
+ if (bytes < 1024) return `${bytes} B`;
546
+ const units = ["KB", "MB", "GB", "TB"];
547
+ let n = bytes / 1024;
548
+ let i = 0;
549
+ while (n >= 1024 && i < units.length - 1) {
550
+ n /= 1024;
551
+ i++;
552
+ }
553
+ return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
554
+ }
555
+
556
+ function humanDuration(ms: number): string {
557
+ const seconds = Math.round(ms / 1000);
558
+ if (seconds < 1) return `${ms}ms`;
559
+ const h = Math.floor(seconds / 3600);
560
+ const m = Math.floor((seconds % 3600) / 60);
561
+ const s = seconds % 60;
562
+ if (h > 0) return `${h}h ${m}m ${s}s`;
563
+ if (m > 0) return `${m}m ${s}s`;
564
+ return `${s}s`;
565
+ }
566
+
567
+ function escapeRegExp(input: string): string {
568
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
569
+ }
570
+
571
+ function readPackageVersion(): string {
572
+ try {
573
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
574
+ version?: unknown;
575
+ };
576
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
577
+ } catch {
578
+ return "unknown";
579
+ }
580
+ }
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
  }