@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 +4 -0
- package/SKILL.md +33 -0
- package/openclaw-plugin.cjs +1 -1
- package/package.json +3 -2
- package/src/cli/dispatch.ts +19 -5
- package/src/cli/main.ts +45 -8
- package/src/cli/say.ts +20 -2
- package/src/cli/stats.ts +67 -0
- package/src/cli.ts +1 -0
- package/src/engine.ts +56 -4
- package/src/star.ts +5 -6
- package/src/stats.ts +580 -0
- 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/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
|
package/openclaw-plugin.cjs
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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": [
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
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
|
-
"
|
|
112
|
-
"
|
|
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(
|
|
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
|
|
214
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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);
|
package/src/cli/stats.ts
ADDED
|
@@ -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
|
-
|
|
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/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
|
-
|
|
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
|
}
|