@drakulavich/kesha-voice-kit 1.17.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/SKILL.md +35 -2
- package/openclaw-plugin.cjs +1 -1
- package/package.json +2 -2
- package/src/cli/dispatch.ts +7 -1
- package/src/cli/main.ts +38 -8
- package/src/cli/say.ts +20 -2
- package/src/cli/stats.ts +67 -0
- package/src/cli.ts +1 -0
- package/src/engine-install.ts +83 -1
- package/src/stats.ts +580 -0
package/README.md
CHANGED
|
@@ -100,7 +100,7 @@ Each segment gets a `speaker` integer (cluster ID, stable within one file). Linu
|
|
|
100
100
|
|
|
101
101
|
## Text-to-speech
|
|
102
102
|
|
|
103
|
-
Kesha speaks back via Kokoro-82M (English) and Vosk-TTS (Russian) — voice auto-picks from the text's language:
|
|
103
|
+
Kesha speaks back via Kokoro-82M (English) and Vosk-TTS (Russian) — voice auto-picks from the text's language. On darwin-arm64, Kokoro uses FluidAudio CoreML instead of ONNX:
|
|
104
104
|
|
|
105
105
|
```bash
|
|
106
106
|
kesha install --tts # ~990MB (Kokoro + Vosk-TTS RU, opt-in)
|
|
@@ -158,7 +158,7 @@ See [BENCHMARK.md](BENCHMARK.md) for the full per-file breakdown (Russian + Engl
|
|
|
158
158
|
| SpeechBrain ECAPA-TDNN | Audio language detection | ~86MB | [HuggingFace](https://huggingface.co/speechbrain/lang-id-voxlingua107-ecapa) |
|
|
159
159
|
| Apple NLLanguageRecognizer | Text language detection | built-in | macOS system framework |
|
|
160
160
|
| Silero VAD v5 (opt-in) | Voice activity detection | ~2.3MB | [snakers4/silero-vad](https://github.com/snakers4/silero-vad) |
|
|
161
|
-
| Kokoro-82M / Vosk-TTS (opt-in) | Text-to-speech | ~990MB | [Kokoro](https://
|
|
161
|
+
| Kokoro-82M / Vosk-TTS (opt-in) | Text-to-speech | ~990MB | [FluidAudio Kokoro](https://github.com/FluidInference/FluidAudio) on darwin-arm64; ONNX Kokoro elsewhere · [Vosk-TTS](https://github.com/alphacep/vosk-tts) |
|
|
162
162
|
|
|
163
163
|
All models run through `kesha-engine` — a Rust binary using [FluidAudio](https://github.com/FluidInference/FluidAudio) (CoreML) on Apple Silicon and [ort](https://github.com/pykeio/ort) (ONNX Runtime) on other platforms.
|
|
164
164
|
|
package/SKILL.md
CHANGED
|
@@ -22,10 +22,43 @@ Local voice toolkit: transcribe voice messages to text, synthesize speech, detec
|
|
|
22
22
|
## When to use
|
|
23
23
|
|
|
24
24
|
- **Voice memo arrived** (Telegram, WhatsApp, Slack, Signal .ogg/.opus/.m4a): transcribe with `kesha --json <path>` and branch on the detected language.
|
|
25
|
-
- **Need to reply with audio (file playback)**: synthesize with `kesha say "<text>" > reply.wav`. Auto-routes by detected language (Kokoro-82M for English, Vosk-TTS for Russian). For other languages and ~180 more voices use `--voice macos-*` on macOS (zero model download).
|
|
25
|
+
- **Need to reply with audio (file playback)**: synthesize with `kesha say "<text>" > reply.wav`. Auto-routes by detected language (Kokoro-82M for English, Vosk-TTS for Russian). On darwin-arm64, English Kokoro uses FluidAudio CoreML instead of ONNX. For other languages and ~180 more voices use `--voice macos-*` on macOS (zero model download).
|
|
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
|
|
@@ -120,7 +153,7 @@ No system deps — English G2P is embedded (`misaki-rs`); Russian G2P is bundled
|
|
|
120
153
|
|
|
121
154
|
**Speech-to-text (25):** Bulgarian, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hungarian, Italian, Latvian, Lithuanian, Maltese, Polish, Portuguese, Romanian, Russian, Slovak, Slovenian, Spanish, Swedish, Ukrainian.
|
|
122
155
|
|
|
123
|
-
**Text-to-speech:** English (Kokoro-82M,
|
|
156
|
+
**Text-to-speech:** English (Kokoro-82M; FluidAudio CoreML on darwin-arm64, ONNX elsewhere), Russian (Vosk-TTS, 5 baked-in speakers — default `ru-vosk-m02`), plus any macOS system voice via `--voice macos-*`.
|
|
124
157
|
|
|
125
158
|
## Performance
|
|
126
159
|
|
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.18.0",
|
|
4
4
|
"description": "Open-source voice toolkit. Speech-to-text (25 langs, ~19x faster than Whisper on Apple Silicon, ONNX fallback on CPU), text-to-speech (Kokoro + Vosk-TTS + ~180 macOS system voices, SSML), voice-activity detection, language detection (107 langs). Rust engine, OpenClaw skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
]
|
|
31
31
|
},
|
|
32
32
|
"keshaEngine": {
|
|
33
|
-
"version": "1.
|
|
33
|
+
"version": "1.18.0"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"test": "bun test",
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -4,10 +4,11 @@ import { log } from "../log";
|
|
|
4
4
|
import { suggestCommand } from "../suggest-command";
|
|
5
5
|
import { installCommand } from "./install";
|
|
6
6
|
import { sayCommand } from "./say";
|
|
7
|
+
import { statsCommand } from "./stats";
|
|
7
8
|
import { statusCommand } from "./status";
|
|
8
9
|
import { mainCommand } from "./main";
|
|
9
10
|
|
|
10
|
-
const SUBCOMMANDS = ["install", "status", "say"];
|
|
11
|
+
const SUBCOMMANDS = ["install", "status", "say", "stats"];
|
|
11
12
|
|
|
12
13
|
function isPathLike(arg: string): boolean {
|
|
13
14
|
return arg.includes(".") || arg.includes("/") || existsSync(arg);
|
|
@@ -31,6 +32,11 @@ export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
|
|
|
31
32
|
return;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
if (firstArg === "stats") {
|
|
36
|
+
await runMain(statsCommand, { rawArgs: restArgs });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
// Check for unknown subcommands (non-flag, non-file-path args).
|
|
35
41
|
// Extensionless existing files remain valid transcription inputs; missing
|
|
36
42
|
// bare tokens are more likely command typos and should not start the engine.
|
package/src/cli/main.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
formatVerboseOutput,
|
|
14
14
|
} from "../format";
|
|
15
15
|
import { formatToonOutput } from "../toon";
|
|
16
|
+
import { artifactFromFile, createStatsRecorder } from "../stats";
|
|
16
17
|
|
|
17
18
|
const pkg = await Bun.file(new URL("../../package.json", import.meta.url)).json();
|
|
18
19
|
|
|
@@ -109,8 +110,12 @@ export const mainCommand = defineCommand({
|
|
|
109
110
|
version: pkg.version,
|
|
110
111
|
description:
|
|
111
112
|
"Kesha Voice Kit — open-source voice toolkit for Apple Silicon.\n" +
|
|
112
|
-
"
|
|
113
|
-
"
|
|
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.",
|
|
114
119
|
},
|
|
115
120
|
args: {
|
|
116
121
|
json: {
|
|
@@ -197,28 +202,46 @@ export const mainCommand = defineCommand({
|
|
|
197
202
|
const vadMode = args.vad ? "on" : args["no-vad"] ? "off" : "auto";
|
|
198
203
|
|
|
199
204
|
if (files.length === 0) {
|
|
200
|
-
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
|
+
);
|
|
201
212
|
process.exit(1);
|
|
202
213
|
}
|
|
203
214
|
|
|
204
215
|
let hasError = false;
|
|
205
216
|
const results: TranscribeResult[] = [];
|
|
217
|
+
const stats = createStatsRecorder("transcribe");
|
|
206
218
|
|
|
207
219
|
const wantsLangId = !!(args.lang || args.verbose || wantsJson || wantsToon || wantsTranscript);
|
|
220
|
+
const reportProgress = process.stdout.isTTY !== true;
|
|
208
221
|
|
|
209
222
|
for (const file of files) {
|
|
210
223
|
if (!existsSync(file)) {
|
|
211
224
|
hasError = true;
|
|
225
|
+
stats.recordError("input", new Error("File not found"), "file_not_found");
|
|
212
226
|
log.error(`${file}: File not found`);
|
|
213
227
|
continue;
|
|
214
228
|
}
|
|
229
|
+
const inputArtifact = artifactFromFile(file, "input_audio");
|
|
230
|
+
if (inputArtifact) stats.recordArtifact(inputArtifact);
|
|
215
231
|
|
|
216
232
|
const startedAt = performance.now();
|
|
217
233
|
try {
|
|
234
|
+
if (reportProgress) {
|
|
235
|
+
console.error(`Transcribing ${file}...`);
|
|
236
|
+
}
|
|
218
237
|
// Run audio lang-id and transcription concurrently.
|
|
219
238
|
const [audioResult, transcript] = await Promise.all([
|
|
220
|
-
wantsLangId
|
|
221
|
-
|
|
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
|
+
),
|
|
222
245
|
]);
|
|
223
246
|
const { text, segments } = transcript;
|
|
224
247
|
|
|
@@ -236,7 +259,7 @@ export const mainCommand = defineCommand({
|
|
|
236
259
|
let textLanguage: LangDetectResult | undefined;
|
|
237
260
|
|
|
238
261
|
if (wantsLangId) {
|
|
239
|
-
const engineTextResult = await detectTextLanguageEngine(text);
|
|
262
|
+
const engineTextResult = await stats.timeStage("lang_id_text", () => detectTextLanguageEngine(text));
|
|
240
263
|
if (engineTextResult && engineTextResult.code) {
|
|
241
264
|
textLanguage = engineTextResult;
|
|
242
265
|
}
|
|
@@ -247,20 +270,25 @@ export const mainCommand = defineCommand({
|
|
|
247
270
|
const mismatchWarning = checkLanguageMismatch(args.lang, lang);
|
|
248
271
|
if (mismatchWarning) log.warn(`${file}: ${mismatchWarning}`);
|
|
249
272
|
|
|
273
|
+
const sttTimeMs = Math.round(performance.now() - startedAt);
|
|
250
274
|
const result: TranscribeResult = {
|
|
251
275
|
file,
|
|
252
276
|
text,
|
|
253
277
|
lang,
|
|
254
278
|
audioLanguage,
|
|
255
279
|
textLanguage: textLanguage ?? (tinyldLang ? { code: tinyldLang, confidence: 0 } : undefined),
|
|
256
|
-
sttTimeMs
|
|
280
|
+
sttTimeMs,
|
|
257
281
|
};
|
|
258
|
-
if (args.timestamps) {
|
|
282
|
+
if (args.timestamps || args.speakers) {
|
|
259
283
|
result.segments = segments;
|
|
260
284
|
}
|
|
261
285
|
results.push(result);
|
|
286
|
+
if (reportProgress) {
|
|
287
|
+
console.error(`Transcribed ${file} (${sttTimeMs}ms)`);
|
|
288
|
+
}
|
|
262
289
|
} catch (err: unknown) {
|
|
263
290
|
hasError = true;
|
|
291
|
+
stats.recordError("transcribe", err);
|
|
264
292
|
const message = err instanceof Error ? err.message : String(err);
|
|
265
293
|
log.error(`${file}: ${message}`);
|
|
266
294
|
}
|
|
@@ -278,6 +306,8 @@ export const mainCommand = defineCommand({
|
|
|
278
306
|
process.stdout.write(formatTextOutput(results));
|
|
279
307
|
}
|
|
280
308
|
|
|
309
|
+
stats.finish(hasError ? "failed" : "success", files.length);
|
|
310
|
+
|
|
281
311
|
if (hasError) process.exit(1);
|
|
282
312
|
},
|
|
283
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-install.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dirname, join } from "path";
|
|
2
|
-
import {
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { existsSync, mkdirSync, chmodSync, accessSync, constants, rmSync } from "fs";
|
|
3
4
|
import {
|
|
4
5
|
getEngineBinPath,
|
|
5
6
|
getEngineCapabilities,
|
|
@@ -74,6 +75,13 @@ const SIDECARS: SidecarSpec[] = [
|
|
|
74
75
|
availableHint: "--speakers available",
|
|
75
76
|
unavailableHint: "--speakers unavailable",
|
|
76
77
|
},
|
|
78
|
+
{
|
|
79
|
+
fileBasename: "kesha-kokoro",
|
|
80
|
+
assetName: "kesha-kokoro-darwin-arm64",
|
|
81
|
+
displayName: "FluidAudio Kokoro sidecar",
|
|
82
|
+
availableHint: "Kokoro uses FluidAudio CoreML",
|
|
83
|
+
unavailableHint: "en-* Kokoro voices unavailable on Darwin",
|
|
84
|
+
},
|
|
77
85
|
{
|
|
78
86
|
// Runtime resolver looks for plain `kesha-textlang` next to the engine
|
|
79
87
|
// (see `rust/src/text_lang.rs::helper_path`), not the platform-suffixed
|
|
@@ -221,6 +229,76 @@ async function downloadSidecar(
|
|
|
221
229
|
}
|
|
222
230
|
}
|
|
223
231
|
|
|
232
|
+
async function warmDarwinKokoro(binPath: string): Promise<void> {
|
|
233
|
+
if (process.platform !== "darwin" || process.arch !== "arm64") return;
|
|
234
|
+
const sidecarPath = join(dirname(binPath), "kesha-kokoro");
|
|
235
|
+
if (!existsSync(sidecarPath)) return;
|
|
236
|
+
|
|
237
|
+
const outPath = join(tmpdir(), `kesha-kokoro-warmup-${process.pid}.wav`);
|
|
238
|
+
log.progress("Warming FluidAudio Kokoro CoreML cache...");
|
|
239
|
+
|
|
240
|
+
const startedAt = performance.now();
|
|
241
|
+
const proc = Bun.spawn(
|
|
242
|
+
[
|
|
243
|
+
binPath,
|
|
244
|
+
"say",
|
|
245
|
+
"--voice",
|
|
246
|
+
"en-am_michael",
|
|
247
|
+
"--out",
|
|
248
|
+
outPath,
|
|
249
|
+
"Kesha warmup.",
|
|
250
|
+
],
|
|
251
|
+
{
|
|
252
|
+
stdout: "ignore",
|
|
253
|
+
stderr: "pipe",
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
let timedOut = false;
|
|
258
|
+
const timer = setTimeout(() => {
|
|
259
|
+
timedOut = true;
|
|
260
|
+
proc.kill();
|
|
261
|
+
}, 180_000);
|
|
262
|
+
|
|
263
|
+
let stderr = "";
|
|
264
|
+
try {
|
|
265
|
+
const stderrStream = proc.stderr as ReadableStream<Uint8Array>;
|
|
266
|
+
const [stderrText, exitCode] = await Promise.all([
|
|
267
|
+
new Response(stderrStream).text(),
|
|
268
|
+
proc.exited,
|
|
269
|
+
]);
|
|
270
|
+
stderr = stderrText.trim();
|
|
271
|
+
|
|
272
|
+
if (timedOut) {
|
|
273
|
+
log.warn("FluidAudio Kokoro warmup timed out; first `kesha say en-*` may still be slow.");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (exitCode !== 0) {
|
|
277
|
+
log.warn(
|
|
278
|
+
`FluidAudio Kokoro warmup failed${stderr ? `: ${stderr}` : ""}; first ` +
|
|
279
|
+
"`kesha say en-*` may still be slow.",
|
|
280
|
+
);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
log.success(
|
|
285
|
+
`FluidAudio Kokoro warmed (${Math.round(performance.now() - startedAt)}ms).`,
|
|
286
|
+
);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
log.warn(
|
|
289
|
+
`FluidAudio Kokoro warmup failed (${e instanceof Error ? e.message : e}); ` +
|
|
290
|
+
"first `kesha say en-*` may still be slow.",
|
|
291
|
+
);
|
|
292
|
+
} finally {
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
try {
|
|
295
|
+
rmSync(outPath, { force: true });
|
|
296
|
+
} catch {
|
|
297
|
+
// best-effort cleanup only
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
224
302
|
export interface InstallOptions {
|
|
225
303
|
/** Also install Kokoro + Vosk-TTS models. */
|
|
226
304
|
tts?: boolean;
|
|
@@ -427,6 +505,10 @@ export async function downloadEngine(
|
|
|
427
505
|
throw new Error(detail ? `Failed to install models: ${detail}` : "Failed to install models");
|
|
428
506
|
}
|
|
429
507
|
|
|
508
|
+
if (options.tts) {
|
|
509
|
+
await warmDarwinKokoro(binPath);
|
|
510
|
+
}
|
|
511
|
+
|
|
430
512
|
log.success("Backend installed successfully.");
|
|
431
513
|
return binPath;
|
|
432
514
|
}
|
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
|
+
}
|